From 55d120f201771acbb32b2cf122d4a6dc82593a57 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:05:20 -0500 Subject: [PATCH] Add craft-figma-rest: a typed model of the Figma REST file JSON (#954) * Add craft-figma-rest: a typed model of the Figma REST file JSON The neutral model for design files: the .fig and .sketch readers map onto it and an application's importer takes it. Typed records for the document tree, paints, effects, text styles, layout grids, constraints, export settings and the component and style metadata; every record keeps the fields it has no name for, so a file read and written loses nothing. The vectorNetwork extension is typed and documented with the others. The layer tree is converted level by level in both directions, and the depth and node limits are checked on the JSON before any typed value is built. * craft-figma-rest: Warnings and style_name for the readers that build this shape Moved from FigCraft's REST importer and .fig reader (crates/format/src/rest.rs Warnings, fig.rs style_name), which the .fig and Sketch mappings share. --- Cargo.lock | 9 + Cargo.toml | 1 + README.md | 4 + crates/figma-rest/Cargo.toml | 16 + crates/figma-rest/README.md | 114 +++++++ crates/figma-rest/src/build.rs | 59 ++++ crates/figma-rest/src/ext.rs | 91 ++++++ crates/figma-rest/src/lib.rs | 192 +++++++++++ crates/figma-rest/src/model.rs | 577 +++++++++++++++++++++++++++++++++ crates/figma-rest/src/tests.rs | 344 ++++++++++++++++++++ 10 files changed, 1407 insertions(+) create mode 100644 crates/figma-rest/Cargo.toml create mode 100644 crates/figma-rest/README.md create mode 100644 crates/figma-rest/src/build.rs create mode 100644 crates/figma-rest/src/ext.rs create mode 100644 crates/figma-rest/src/lib.rs create mode 100644 crates/figma-rest/src/model.rs create mode 100644 crates/figma-rest/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 2c97dfa99..cf4937b93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1067,6 +1067,15 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-figma-rest" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-font-catalog" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a7304151b..e37fcb7f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ craft-doc = { path = "crates/doc" } craft-docpath = { path = "crates/docpath" } craft-dpx = { path = "crates/dpx" } craft-ecproj-format = { path = "crates/ecproj-format" } +craft-figma-rest = { path = "crates/figma-rest" } craft-exr = { path = "crates/exr" } craft-font-catalog = { path = "crates/font-catalog" } craft-font-manager = { path = "crates/font-manager" } diff --git a/README.md b/README.md index 78db406ec..08c98e556 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = ", + index: BTreeMap, +} + +impl Warnings { + /// Note `msg`: counted when it was met before, kept when fewer than [`MAX_WARNINGS`] are. + pub fn add(&mut self, msg: String) { + if let Some(i) = self.index.get(&msg) { + if let Some(e) = self.list.get_mut(*i) { + e.1 += 1; + } + } else if self.list.len() < MAX_WARNINGS { + self.index.insert(msg.clone(), self.list.len()); + self.list.push((msg, 1)); + } + } + + /// The notes met so far, without their counts. + pub fn seen(&self) -> impl Iterator { + self.list.iter().map(|(msg, _)| msg.as_str()) + } + + /// The notes, each with `(×n)` after it where it was met more than once (a reader of the + /// notes can take the suffix off to look the note up). + pub fn finish(self) -> Vec { + self.list.into_iter().map(|(m, n)| if n > 1 { format!("{m} (×{n})") } else { m }).collect() + } +} + +/// A font style under the name the format uses: the standard weight names as words ("SemiBold" +/// → "Semi Bold", "ExtraBoldItalic" → "Extra Bold Italic", "Regular Italic" → "Italic"). Other +/// names stay as they are. +pub fn style_name(style: &str) -> String { + const WEIGHTS: [&str; 12] = + ["Thin", "Extra Light", "Ultra Light", "Light", "Regular", "Medium", "Semi Bold", "Demi Bold", "Bold", "Extra Bold", "Ultra Bold", "Black"]; + let compact = |s: &str| s.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).flat_map(char::to_lowercase).collect::(); + let key = compact(style); + let (base, italic) = match key.strip_suffix("italic") { + Some(b) => (b, true), + None => (key.as_str(), false), + }; + let weight = if base.is_empty() && italic { Some("Regular") } else { WEIGHTS.into_iter().find(|w| compact(w) == base) }; + match (weight, italic) { + (None, _) => style.to_string(), + (Some("Regular"), true) => "Italic".into(), + (Some(w), true) => format!("{w} Italic"), + (Some(w), false) => w.into(), + } +} diff --git a/crates/figma-rest/src/ext.rs b/crates/figma-rest/src/ext.rs new file mode 100644 index 000000000..e5e7308f2 --- /dev/null +++ b/crates/figma-rest/src/ext.rs @@ -0,0 +1,91 @@ +//! Extensions: fields that are not in the REST API, which the readers that map other files onto +//! this shape add so that nothing those files hold is lost. Each is optional; a consumer that +//! doesn't know one ignores it, and the public fields still describe the layer. +//! +//! | Field | On | Holds | +//! |---|---|---| +//! | `vectorNetwork` | a `VECTOR` node | the editable network ([`VectorNetwork`]); `fillGeometry` / `strokeGeometry` stay as the outlines | +//! | `vectorNetworkRef` | a `VECTOR` node | an index into a list of networks handed over beside the JSON, instead of `vectorNetwork` (far smaller for large files) | +//! | `pointCount` | `STAR`, `REGULAR_POLYGON` | the number of points or sides | +//! | `innerRadius` | `STAR` | the inner radius as a fraction of the outer one | +//! | `_letterSpacingPercent` | a text style | letter spacing as a percentage of the font size, where `letterSpacing` is in pixels | +//! +//! Fields starting with `_fig` are private handoffs between a reader and one importer; they are +//! kept like any other unknown field and are not part of this contract. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// The name of the vector network field. +pub const VECTOR_NETWORK: &str = "vectorNetwork"; +/// The name of the vector network reference field. +pub const VECTOR_NETWORK_REF: &str = "vectorNetworkRef"; + +/// An editable vector network: points, the curves between them, and the regions they fill. +/// Coordinates are in the node's own space (0, 0 is its top left corner). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct VectorNetwork { + #[serde(default)] + pub vertices: Vec, + #[serde(default)] + pub segments: Vec, + #[serde(default)] + pub regions: Vec, + /// Fields this model has no name for. + #[serde(flatten)] + pub extra: Map, +} + +/// A point of a network. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Vertex { + pub x: f64, + pub y: f64, + /// The corner radius at this point. + #[serde(default)] + pub radius: f64, + /// Handle mirroring, end cap, join: kept as written. + #[serde(flatten)] + pub extra: Map, +} + +/// A curve from vertex `a` to vertex `b`; `ta` and `tb` are the tangents at each end, relative to +/// their vertex (both zero: a straight line). +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Segment { + pub a: usize, + pub b: usize, + #[serde(default)] + pub ta: (f64, f64), + #[serde(default)] + pub tb: (f64, f64), +} + +/// A filled region: loops of segment indices. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Region { + pub loops: Vec>, + #[serde(default)] + pub winding: Winding, + /// Fields this model has no name for. + #[serde(flatten)] + pub extra: Map, +} + +/// How a region's loops fill. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Winding { + #[default] + NonZero, + EvenOdd, +} + +impl VectorNetwork { + /// Whether every segment names vertices the network has and every loop names segments it + /// has. A network read from a file is checked before its indices are used. + pub fn is_consistent(&self) -> bool { + let (v, s) = (self.vertices.len(), self.segments.len()); + self.segments.iter().all(|seg| seg.a < v && seg.b < v) && self.regions.iter().flat_map(|r| &r.loops).flatten().all(|i| *i < s) + } +} diff --git a/crates/figma-rest/src/lib.rs b/crates/figma-rest/src/lib.rs new file mode 100644 index 000000000..3998e4c1c --- /dev/null +++ b/crates/figma-rest/src/lib.rs @@ -0,0 +1,192 @@ +//! A typed model of the Figma REST API file JSON: the response of `GET /v1/files/:key` (a +//! `document` with `CANVAS` pages, plus `components`, `componentSets` and `styles` metadata) and +//! of `GET /v1/files/:key/nodes` (a `nodes` map of such entries). Written from the published REST +//! API reference only. +//! +//! This is the neutral model the `.fig` and `.sketch` readers map onto, and the shape an +//! application's importer takes. It is the file's own shape, named as the API names it; it knows +//! nothing of any application's document. +//! +//! # Nothing is lost +//! +//! Every record keeps the fields this model has no name for in `extra`, in the JSON they came +//! in, and writes them back. So a file read and written keeps properties the API added after this +//! model was written, and the documented extensions ([`ext`]). Enumerations keep values they don't +//! know (`Other`). Two things do change on a round trip: a field that was `null` is left out, and +//! a whole number written as `10` comes back as `10.0`. +//! +//! # Hostile input +//! +//! [`File::from_value`] and [`File::from_slice`] refuse a tree nested deeper than [`MAX_DEPTH`] +//! or holding more than the caller's node limit before any typed value is built. The layer tree +//! is converted level by level, without recursion, in both directions; only the JSON tree itself +//! (text to [`serde_json::Value`], and dropping one) is walked by recursion, in small frames. A field of the +//! wrong type fails the read (the error names it): a reader that must skip bad fields one by one +//! reads the [`serde_json::Value`] instead. Nothing panics. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod build; +pub mod ext; +mod model; +#[cfg(test)] +mod tests; + +pub use build::{MAX_WARNINGS, Warnings, style_name}; +pub use model::{ + ArcData, BlendMode, Color, ColorStop, Component, ComponentSet, Effect, EffectType, ExportSetting, File, FileNode, FileNodes, LayoutConstraint, + LayoutGrid, Node, NodeProps, NodeType, Paint, PaintType, Path, Rectangle, Style, StyleType, Transform, TypeStyle, Vector, +}; + +use serde_json::Value; + +/// Deepest JSON nesting read: about 500 layers inside one another (each layer is two levels: its +/// `children` array and itself). +pub const MAX_DEPTH: usize = 1024; +/// Most nodes [`File::from_slice`] reads. +pub const DEFAULT_MAX_NODES: usize = 200_000; + +/// Why a file JSON could not be read. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// Not JSON, or a field of the wrong type. + #[error("file JSON: {0}")] + Json(String), + /// Nested deeper than [`MAX_DEPTH`]. + #[error("file JSON: nested deeper than {MAX_DEPTH} levels")] + TooDeep, + /// More nodes than the limit given. + #[error("file JSON: more than {0} layers")] + TooManyNodes(usize), +} + +/// Checks `v` against [`MAX_DEPTH`] and `max_nodes` (objects with a `children` array or an `id` +/// and a `type` count as nodes) without recursion. +pub fn check(v: &Value, max_nodes: usize) -> Result<(), Error> { + let mut nodes = 0usize; + let mut pending = vec![(v, 0usize)]; + while let Some((value, depth)) = pending.pop() { + if depth > MAX_DEPTH { + return Err(Error::TooDeep); + } + match value { + Value::Object(fields) => { + if fields.contains_key("children") || (fields.contains_key("id") && fields.contains_key("type")) { + nodes += 1; + if nodes > max_nodes { + return Err(Error::TooManyNodes(max_nodes)); + } + } + pending.extend(fields.values().map(|f| (f, depth + 1))); + } + Value::Array(items) => pending.extend(items.iter().map(|i| (i, depth + 1))), + _ => {} + } + } + Ok(()) +} + +fn json(e: serde_json::Error) -> Error { + Error::Json(e.to_string()) +} + +/// Takes `document` out of the JSON of a file or of a `nodes` entry and builds it level by level, +/// so that serde converts only the shallow rest. +fn take_document(v: &mut Value) -> Result, Error> { + match v.as_object_mut().and_then(|o| o.remove("document")) { + None | Some(Value::Null) => Ok(None), + Some(doc) => Node::build(doc).map(Some).map_err(json), + } +} + +/// Puts `document` into the JSON of a file or of a `nodes` entry that was written without it. +fn put_document(v: &mut Value, document: Option<&Node>) -> Result<(), Error> { + if let (Some(doc), Value::Object(o)) = (document, v) { + o.insert("document".into(), doc.unbuild().map_err(json)?); + } + Ok(()) +} + +impl File { + /// A file from its JSON text, within [`MAX_DEPTH`] and [`DEFAULT_MAX_NODES`]. (serde_json + /// itself refuses text nested past 128 levels; [`File::from_value`] takes deeper trees.) + pub fn from_slice(bytes: &[u8]) -> Result { + File::from_value(serde_json::from_slice(bytes).map_err(json)?, DEFAULT_MAX_NODES) + } + + /// A file from its JSON, within [`MAX_DEPTH`] and `max_nodes`. + pub fn from_value(mut v: Value, max_nodes: usize) -> Result { + check(&v, max_nodes)?; + let document = take_document(&mut v)?; + let mut file: File = serde_json::from_value(v).map_err(json)?; + file.document = document; + Ok(file) + } + + /// The file as JSON. Unlike the file's `Serialize`, this does not recurse through the layers. + pub fn to_value(&self) -> Result { + let shallow = File { document: None, extra: self.extra.clone(), components: self.components.clone(), ..self.metadata() }; + let mut v = serde_json::to_value(shallow).map_err(json)?; + put_document(&mut v, self.document.as_ref())?; + Ok(v) + } + + /// The file's own fields, without the document, the component maps and the unnamed fields. + fn metadata(&self) -> File { + File { + name: self.name.clone(), + last_modified: self.last_modified.clone(), + thumbnail_url: self.thumbnail_url.clone(), + version: self.version.clone(), + schema_version: self.schema_version, + component_sets: self.component_sets.clone(), + styles: self.styles.clone(), + ..File::default() + } + } +} + +impl FileNodes { + /// The response of `GET /v1/files/:key/nodes`, within [`MAX_DEPTH`] and `max_nodes`. + pub fn from_value(mut v: Value, max_nodes: usize) -> Result { + check(&v, max_nodes)?; + let mut documents = Vec::new(); + if let Some(Value::Object(entries)) = v.get_mut("nodes") { + for (id, entry) in entries.iter_mut() { + if let Some(doc) = take_document(entry)? { + documents.push((id.clone(), doc)); + } + } + } + let mut read: FileNodes = serde_json::from_value(v).map_err(json)?; + for (id, doc) in documents { + if let Some(Some(entry)) = read.nodes.as_mut().and_then(|n| n.get_mut(&id)) { + entry.document = Some(doc); + } + } + Ok(read) + } +} + +impl Node { + /// A node (and its subtree) from its JSON, within [`MAX_DEPTH`] and `max_nodes`. + pub fn from_value(v: Value, max_nodes: usize) -> Result { + check(&v, max_nodes)?; + Node::build(v).map_err(json) + } + + /// The node as JSON. + pub fn to_value(&self) -> Result { + self.unbuild().map_err(json) + } + + /// The node and every node under it, parents first, without recursion. + pub fn walk(&self) -> impl Iterator { + let mut pending = vec![self]; + std::iter::from_fn(move || { + let node = pending.pop()?; + pending.extend(node.children.iter().flatten().rev()); + Some(node) + }) + } +} diff --git a/crates/figma-rest/src/model.rs b/crates/figma-rest/src/model.rs new file mode 100644 index 000000000..e7c161970 --- /dev/null +++ b/crates/figma-rest/src/model.rs @@ -0,0 +1,577 @@ +//! The records of the file JSON, named as the REST API reference names them. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::ext::VectorNetwork; + +/// A record: every field optional (the API leaves out what has its default), plus `extra` for the +/// fields this model has no name for. +macro_rules! record { + ($(#[$meta:meta])* $name:ident { $($(#[$fmeta:meta])* $field:ident: $ty:ty,)* }) => { + $(#[$meta])* + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct $name { + $( + $(#[$fmeta])* + #[serde(default, skip_serializing_if = "Option::is_none")] + pub $field: Option<$ty>, + )* + /// Fields this model has no name for, as the JSON they came in. + #[serde(flatten)] + pub extra: Map, + } + }; +} + +/// An enumeration the API may add values to: the values known here, and `Other` for the rest, +/// written back as it was read. +macro_rules! open_enum { + ($(#[$meta:meta])* $name:ident { $($variant:ident = $text:literal,)* }) => { + $(#[$meta])* + #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[serde(from = "String", into = "String")] + pub enum $name { + $($variant,)* + /// A value this model has no name for. + Other(String), + } + + impl $name { + /// The value as the API writes it. + pub fn as_str(&self) -> &str { + match self { + $($name::$variant => $text,)* + $name::Other(s) => s, + } + } + } + + impl From for $name { + fn from(s: String) -> Self { + match s.as_str() { + $($text => $name::$variant,)* + _ => $name::Other(s), + } + } + } + + impl From<$name> for String { + fn from(v: $name) -> String { + match v { + $name::Other(s) => s, + known => known.as_str().to_string(), + } + } + } + }; +} + +open_enum! { + /// A node's `type`. + NodeType { + Document = "DOCUMENT", Canvas = "CANVAS", Frame = "FRAME", Group = "GROUP", Section = "SECTION", Vector = "VECTOR", + BooleanOperation = "BOOLEAN_OPERATION", Star = "STAR", Line = "LINE", Ellipse = "ELLIPSE", RegularPolygon = "REGULAR_POLYGON", + Rectangle = "RECTANGLE", Text = "TEXT", Slice = "SLICE", Component = "COMPONENT", ComponentSet = "COMPONENT_SET", Instance = "INSTANCE", + Sticky = "STICKY", ShapeWithText = "SHAPE_WITH_TEXT", Connector = "CONNECTOR", Table = "TABLE", TableCell = "TABLE_CELL", + WashiTape = "WASHI_TAPE", Slot = "SLOT", + } +} + +open_enum! { + /// A paint's `type`. + PaintType { + Solid = "SOLID", GradientLinear = "GRADIENT_LINEAR", GradientRadial = "GRADIENT_RADIAL", GradientAngular = "GRADIENT_ANGULAR", + GradientDiamond = "GRADIENT_DIAMOND", Image = "IMAGE", Emoji = "EMOJI", Video = "VIDEO", Pattern = "PATTERN", + } +} + +open_enum! { + /// An effect's `type`. + EffectType { + InnerShadow = "INNER_SHADOW", DropShadow = "DROP_SHADOW", LayerBlur = "LAYER_BLUR", BackgroundBlur = "BACKGROUND_BLUR", + Texture = "TEXTURE", Noise = "NOISE", Glass = "GLASS", + } +} + +open_enum! { + /// How a layer or a paint blends with what is under it. + BlendMode { + PassThrough = "PASS_THROUGH", Normal = "NORMAL", Darken = "DARKEN", Multiply = "MULTIPLY", LinearBurn = "LINEAR_BURN", + ColorBurn = "COLOR_BURN", Lighten = "LIGHTEN", Screen = "SCREEN", LinearDodge = "LINEAR_DODGE", ColorDodge = "COLOR_DODGE", + Overlay = "OVERLAY", SoftLight = "SOFT_LIGHT", HardLight = "HARD_LIGHT", Difference = "DIFFERENCE", Exclusion = "EXCLUSION", + Hue = "HUE", Saturation = "SATURATION", Color = "COLOR", Luminosity = "LUMINOSITY", + } +} + +open_enum! { + /// The kind of a shared style. + StyleType { Fill = "FILL", Text = "TEXT", Effect = "EFFECT", Grid = "GRID", } +} + +/// A colour: red, green, blue and alpha, each 0–1. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Color { + pub r: f64, + pub g: f64, + pub b: f64, + #[serde(default = "one")] + pub a: f64, +} + +fn one() -> f64 { + 1.0 +} + +/// A point or an offset. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Vector { + pub x: f64, + pub y: f64, +} + +/// A bounding box. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Rectangle { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// A 2 × 3 affine matrix, row by row: `[[a, c, tx], [b, d, ty]]`. +pub type Transform = [[f64; 3]; 2]; + +/// A gradient stop. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ColorStop { + pub position: f64, + pub color: Color, + /// Fields this model has no name for (`boundVariables`). + #[serde(flatten)] + pub extra: Map, +} + +record! { + /// A fill or a stroke paint. + Paint { + #[serde(rename = "type")] + kind: PaintType, + visible: bool, + opacity: f64, + blend_mode: BlendMode, + /// Solid paints. + color: Color, + /// Gradients: the start, the end, and a third point giving the width. + gradient_handle_positions: Vec, + gradient_stops: Vec, + /// Images: `FILL`, `FIT`, `TILE` or `STRETCH`. + scale_mode: String, + /// Images: the key of the image (`GET /v1/files/:key/images` gives its URL; a `.fig` + /// names its `images/` members by it). + image_ref: String, + /// Images with `scaleMode` `STRETCH`. + image_transform: Transform, + /// Images with `scaleMode` `TILE`. + scaling_factor: f64, + /// Images: rotation in degrees. + rotation: f64, + gif_ref: String, + } +} + +record! { + /// A shadow, a blur or another effect. + Effect { + #[serde(rename = "type")] + kind: EffectType, + visible: bool, + radius: f64, + color: Color, + blend_mode: BlendMode, + offset: Vector, + spread: f64, + show_shadow_behind_node: bool, + } +} + +record! { + /// How a layer follows its parent when the parent is resized. + LayoutConstraint { + /// `TOP`, `BOTTOM`, `CENTER`, `TOP_BOTTOM` or `SCALE`. + vertical: String, + /// `LEFT`, `RIGHT`, `CENTER`, `LEFT_RIGHT` or `SCALE`. + horizontal: String, + } +} + +record! { + /// A layout guide of a frame. + LayoutGrid { + /// `ROWS`, `COLUMNS` or `GRID`. + pattern: String, + section_size: f64, + visible: bool, + color: Color, + /// `MIN`, `MAX`, `CENTER` or `STRETCH`. + alignment: String, + gutter_size: f64, + offset: f64, + count: f64, + } +} + +record! { + /// One export preset of a layer. + ExportSetting { + suffix: String, + /// `JPG`, `PNG`, `SVG` or `PDF`. + format: String, + /// `{type: SCALE | WIDTH | HEIGHT, value}`. + constraint: Value, + } +} + +record! { + /// An outline in the node's own space. + Path { + /// SVG path data. + path: String, + /// `NONZERO` or `EVENODD`. + winding_rule: String, + override_id: f64, + } +} + +record! { + /// The sweep of an ellipse: angles in radians, the inner radius as a fraction. + ArcData { + starting_angle: f64, + ending_angle: f64, + inner_radius: f64, + } +} + +record! { + /// The style of a text node, or of a run in its `styleOverrideTable`. + TypeStyle { + font_family: String, + font_post_script_name: String, + font_style: String, + italic: bool, + font_weight: f64, + font_size: f64, + /// `ORIGINAL`, `UPPER`, `LOWER`, `TITLE`, `SMALL_CAPS` or `SMALL_CAPS_FORCED`. + text_case: String, + /// `NONE`, `STRIKETHROUGH` or `UNDERLINE`. + text_decoration: String, + /// `NONE`, `HEIGHT`, `WIDTH_AND_HEIGHT` or `TRUNCATE`. + text_auto_resize: String, + /// `DISABLED` or `ENDING`. + text_truncation: String, + max_lines: f64, + /// `LEFT`, `RIGHT`, `CENTER` or `JUSTIFIED`. + text_align_horizontal: String, + /// `TOP`, `CENTER` or `BOTTOM`. + text_align_vertical: String, + /// In pixels. + letter_spacing: f64, + fills: Vec, + /// `{type: URL | NODE, url | nodeID}`. + hyperlink: Value, + /// OpenType feature tags and whether each is on (1) or off (0). + opentype_flags: BTreeMap, + line_height_px: f64, + line_height_percent_font_size: f64, + /// `PIXELS`, `FONT_SIZE_%` or `INTRINSIC_%`. + line_height_unit: String, + paragraph_spacing: f64, + paragraph_indent: f64, + list_spacing: f64, + } +} + +record! { + /// A layer's own properties ([`Node`] adds its children). One record for every node type, as + /// the API's node types share most properties: which ones a node carries follows from its + /// `type`. + NodeProps { + id: String, + name: String, + #[serde(rename = "type")] + kind: NodeType, + visible: bool, + locked: bool, + /// In radians. + rotation: f64, + blend_mode: BlendMode, + opacity: f64, + absolute_bounding_box: Rectangle, + absolute_render_bounds: Rectangle, + /// The node's place in its parent (asked for with `geometry=paths`). + relative_transform: Transform, + /// Width and height before the transform (asked for with `geometry=paths`). + size: Vector, + constraints: LayoutConstraint, + clips_content: bool, + /// A canvas's background. + background_color: Color, + fills: Vec, + strokes: Vec, + stroke_weight: f64, + /// `{top, right, bottom, left}`. + individual_stroke_weights: Value, + /// `INSIDE`, `OUTSIDE` or `CENTER`. + stroke_align: String, + /// `MITER`, `BEVEL` or `ROUND`. + stroke_join: String, + /// `NONE`, `ROUND`, `SQUARE`, `LINE_ARROW`, `TRIANGLE_ARROW`, … + stroke_cap: String, + stroke_dashes: Vec, + stroke_miter_angle: f64, + fill_geometry: Vec, + stroke_geometry: Vec, + corner_radius: f64, + corner_smoothing: f64, + /// Top left, top right, bottom right, bottom left. + rectangle_corner_radii: [f64; 4], + effects: Vec, + is_mask: bool, + /// `ALPHA`, `VECTOR` or `LUMINANCE`. + mask_type: String, + export_settings: Vec, + layout_grids: Vec, + /// The shared styles the node uses: `fill`, `stroke`, `text`, `effect`, `grid` → style id. + styles: BTreeMap, + + /// Auto layout: `NONE`, `HORIZONTAL`, `VERTICAL` or `GRID`. + layout_mode: String, + /// `NO_WRAP` or `WRAP`. + layout_wrap: String, + /// `FIXED` or `AUTO`. + primary_axis_sizing_mode: String, + counter_axis_sizing_mode: String, + /// `MIN`, `CENTER`, `MAX` or `SPACE_BETWEEN`. + primary_axis_align_items: String, + /// `MIN`, `CENTER`, `MAX` or `BASELINE`. + counter_axis_align_items: String, + /// `AUTO` or `SPACE_BETWEEN`. + counter_axis_align_content: String, + padding_left: f64, + padding_right: f64, + padding_top: f64, + padding_bottom: f64, + item_spacing: f64, + counter_axis_spacing: f64, + item_reverse_z_index: bool, + strokes_included_in_layout: bool, + /// As a child of an auto layout: `INHERIT`, `STRETCH`, `MIN`, `CENTER`, `MAX`. + layout_align: String, + layout_grow: f64, + /// `AUTO` or `ABSOLUTE`. + layout_positioning: String, + /// `FIXED`, `HUG` or `FILL`. + layout_sizing_horizontal: String, + layout_sizing_vertical: String, + min_width: f64, + max_width: f64, + min_height: f64, + max_height: f64, + /// `NONE`, `HORIZONTAL_SCROLLING`, `VERTICAL_SCROLLING` or `HORIZONTAL_AND_VERTICAL_SCROLLING`. + overflow_direction: String, + + /// Text: the characters, the base style, and for each character an index into + /// `styleOverrideTable` (0: the base style). + characters: String, + style: TypeStyle, + character_style_overrides: Vec, + style_override_table: BTreeMap, + /// Per line: `NONE`, `ORDERED` or `UNORDERED`. + line_types: Vec, + line_indentations: Vec, + + /// An instance's main component. + component_id: String, + /// `UNION`, `INTERSECT`, `SUBTRACT` or `EXCLUDE`. + boolean_operation: String, + arc_data: ArcData, + section_contents_hidden: bool, + + /// Extension ([`crate::ext`]): the editable network of a vector. + vector_network: VectorNetwork, + } +} + +record! { + /// A component's entry in the file's `components`. + Component { + key: String, + name: String, + description: String, + component_set_id: String, + remote: bool, + } +} + +record! { + /// A component set's entry in the file's `componentSets`. + ComponentSet { + key: String, + name: String, + description: String, + remote: bool, + } +} + +record! { + /// A shared style's entry in the file's `styles`. + Style { + key: String, + name: String, + description: String, + remote: bool, + style_type: StyleType, + } +} + +record! { + /// The response of `GET /v1/files/:key`. + File { + name: String, + last_modified: String, + thumbnail_url: String, + version: String, + schema_version: f64, + /// The `DOCUMENT` node: its children are the `CANVAS` pages. + document: Node, + /// By node id. + components: BTreeMap, + component_sets: BTreeMap, + styles: BTreeMap, + } +} + +record! { + /// One entry of the `nodes` map of `GET /v1/files/:key/nodes`. + FileNode { + document: Node, + components: BTreeMap, + component_sets: BTreeMap, + styles: BTreeMap, + schema_version: f64, + } +} + +record! { + /// The response of `GET /v1/files/:key/nodes`. An entry is `None` for an id the file lacks. + FileNodes { + name: String, + nodes: BTreeMap>, + } +} + +/// A layer: its properties (read through the node itself: `node.fills`) and its children. +/// +/// Reading and writing a node does not recurse through its layers: the tree is taken apart and +/// put together level by level, so a deep file needs no deep stack. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Node { + pub props: NodeProps, + /// `None` for a node without a `children` array (a leaf); `Some(vec![])` for an empty one. + pub children: Option>, +} + +impl std::ops::Deref for Node { + type Target = NodeProps; + fn deref(&self) -> &NodeProps { + &self.props + } +} + +impl std::ops::DerefMut for Node { + fn deref_mut(&mut self) -> &mut NodeProps { + &mut self.props + } +} + +impl Node { + /// A leaf with `props`. + pub fn new(props: NodeProps) -> Node { + Node { props, children: None } + } + + /// A node with `props` and `children`. + pub fn with_children(props: NodeProps, children: Vec) -> Node { + Node { props, children: Some(children) } + } + + /// The node and its subtree from JSON, level by level. A `children` that is not an array is + /// kept as an unnamed field. + pub(crate) fn build(v: Value) -> Result { + // The tree in pre-order, each node without its children, with how many it had. + let mut order: Vec<(Value, Option)> = Vec::new(); + let mut pending = vec![v]; + while let Some(mut v) = pending.pop() { + let kids = match &mut v { + Value::Object(o) if o.get("children").is_some_and(Value::is_array) => match o.remove("children") { + Some(Value::Array(a)) => Some(a), + _ => None, + }, + _ => None, + }; + let count = kids.as_ref().map(Vec::len); + pending.extend(kids.into_iter().flatten().rev()); + order.push((v, count)); + } + // Backwards, every node finds its children built already, the first on top. + let mut built: Vec = Vec::new(); + for (v, count) in order.into_iter().rev() { + let props: NodeProps = serde_json::from_value(v)?; + let children = count.map(|n| { + let mut kids = built.split_off(built.len().saturating_sub(n)); + kids.reverse(); + kids + }); + built.push(Node { props, children }); + } + Ok(built.pop().unwrap_or_default()) + } + + /// The node and its subtree as JSON, level by level. + pub(crate) fn unbuild(&self) -> Result { + let mut order: Vec<&Node> = Vec::new(); + let mut pending = vec![self]; + while let Some(node) = pending.pop() { + pending.extend(node.children.iter().flatten().rev()); + order.push(node); + } + let mut built: Vec = Vec::new(); + for node in order.into_iter().rev() { + let mut v = serde_json::to_value(&node.props)?; + if let (Some(kids), Value::Object(o)) = (&node.children, &mut v) { + let mut kids = built.split_off(built.len().saturating_sub(kids.len())); + kids.reverse(); + o.insert("children".into(), Value::Array(kids)); + } + built.push(v); + } + Ok(built.pop().unwrap_or_default()) + } +} + +/// Through [`Value`], so that only the JSON tree (small frames) is walked by recursion. +impl Serialize for Node { + fn serialize(&self, serializer: S) -> Result { + self.unbuild().map_err(serde::ser::Error::custom)?.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Node { + fn deserialize>(deserializer: D) -> Result { + Node::build(Value::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} diff --git a/crates/figma-rest/src/tests.rs b/crates/figma-rest/src/tests.rs new file mode 100644 index 000000000..ead117148 --- /dev/null +++ b/crates/figma-rest/src/tests.rs @@ -0,0 +1,344 @@ +//! Reading and writing file JSON written by hand from the REST API reference. +#![allow(clippy::unwrap_used, clippy::panic)] + +use serde_json::{Value, json}; + +use super::ext::{VectorNetwork, Winding}; +use super::*; + +/// A file using each typed record, plus fields the model has no name for at every level. +fn file_json() -> Value { + serde_json::from_str(FILE).unwrap() +} + +const FILE: &str = r##"{ + "name": "Fixture", + "lastModified": "2026-01-01T00:00:00Z", + "schemaVersion": 0.0, + "role": "owner", + "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [{ + "id": "0:1", "name": "Page 1", "type": "CANVAS", "backgroundColor": {"r": 0.9, "g": 0.9, "b": 0.9, "a": 1.0}, + "prototypeStartNodeID": "1:2", + "flowStartingPoints": [{"nodeId": "1:2", "name": "Flow 1"}], + "children": [{ + "id": "1:2", "name": "Card", "type": "FRAME", "blendMode": "PASS_THROUGH", "clipsContent": true, + "absoluteBoundingBox": {"x": 10.5, "y": 20.0, "width": 300.0, "height": 200.0}, + "relativeTransform": [[1.0, 0.0, 10.5], [0.0, 1.0, 20.0]], "size": {"x": 300.0, "y": 200.0}, + "constraints": {"vertical": "TOP", "horizontal": "LEFT_RIGHT"}, + "fills": [ + {"type": "SOLID", "blendMode": "NORMAL", "color": {"r": 1.0, "g": 0.5, "b": 0.0, "a": 1.0}, "boundVariables": {"color": {"type": "VARIABLE_ALIAS", "id": "VariableID:1:5"}}}, + {"type": "GRADIENT_LINEAR", "opacity": 0.5, "gradientHandlePositions": [{"x": 0.0, "y": 0.5}, {"x": 1.0, "y": 0.5}, {"x": 0.0, "y": 1.0}], + "gradientStops": [{"position": 0.0, "color": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}}, {"position": 1.0, "color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 0.0}}]}, + {"type": "IMAGE", "scaleMode": "FILL", "imageRef": "0a1b2c", "filters": {"exposure": 0.25}}, + {"type": "SHINY_NEW_PAINT", "sparkle": 3.5} + ], + "strokes": [{"type": "SOLID", "color": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}}], + "strokeWeight": 2.0, "strokeAlign": "INSIDE", "strokeDashes": [4.0, 2.0], + "individualStrokeWeights": {"top": 1.0, "right": 2.0, "bottom": 3.0, "left": 4.0}, + "cornerRadius": 8.0, "rectangleCornerRadii": [8.0, 8.0, 0.0, 0.0], "cornerSmoothing": 0.6, + "effects": [{"type": "DROP_SHADOW", "visible": true, "radius": 4.0, "color": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 0.25}, + "blendMode": "NORMAL", "offset": {"x": 0.0, "y": 2.0}, "spread": 1.0, "showShadowBehindNode": false}, + {"type": "LAYER_BLUR", "radius": 3.0, "blurType": "PROGRESSIVE"}], + "layoutMode": "VERTICAL", "primaryAxisSizingMode": "AUTO", "counterAxisAlignItems": "CENTER", "itemSpacing": 12.0, + "paddingLeft": 16.0, "paddingRight": 16.0, "paddingTop": 8.0, "paddingBottom": 8.0, "layoutWrap": "NO_WRAP", + "layoutGrids": [{"pattern": "COLUMNS", "sectionSize": 60.0, "visible": true, "color": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 0.1}, + "alignment": "STRETCH", "gutterSize": 20.0, "offset": 0.0, "count": 4.0}], + "exportSettings": [{"suffix": "@2x", "format": "PNG", "constraint": {"type": "SCALE", "value": 2.0}}], + "styles": {"fill": "5:1", "effect": "5:2"}, + "interactions": [{"trigger": {"type": "ON_CLICK"}, "actions": [{"type": "NODE", "destinationId": "1:9", "navigation": "NAVIGATE"}]}], + "boundVariables": {"itemSpacing": {"type": "VARIABLE_ALIAS", "id": "VariableID:1:7"}}, + "children": [ + {"id": "1:3", "name": "Title", "type": "TEXT", "characters": "Hello\nworld", + "style": {"fontFamily": "Inter", "fontPostScriptName": "Inter-Bold", "fontWeight": 700.0, "fontSize": 24.0, "italic": false, + "textAlignHorizontal": "LEFT", "textAlignVertical": "TOP", "letterSpacing": 0.5, "lineHeightPx": 28.0, + "lineHeightPercentFontSize": 116.5, "lineHeightUnit": "PIXELS", "opentypeFlags": {"LIGA": 0.0}, + "hyperlink": {"type": "URL", "url": "https://example.com"}, "_letterSpacingPercent": 2.0}, + "characterStyleOverrides": [0.0, 0.0, 1.0, 1.0], + "styleOverrideTable": {"1": {"fontWeight": 400.0, "fills": [{"type": "SOLID", "color": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0}}]}}, + "lineTypes": ["NONE", "ORDERED"], "lineIndentations": [0.0, 1.0]}, + {"id": "1:4", "name": "Shape", "type": "VECTOR", + "fillGeometry": [{"path": "M0 0 L10 0 L5 8 Z", "windingRule": "NONZERO"}], "strokeGeometry": [], + "vectorNetwork": {"vertices": [{"x": 0.0, "y": 0.0, "radius": 0.0}, {"x": 10.0, "y": 0.0, "radius": 2.0, "cap": "round"}, {"x": 5.0, "y": 8.0, "radius": 0.0}], + "segments": [{"a": 0, "b": 1, "ta": [0.0, 0.0], "tb": [0.0, 0.0]}, {"a": 1, "b": 2, "ta": [1.0, 2.0], "tb": [-1.0, 0.5]}, {"a": 2, "b": 0, "ta": [0.0, 0.0], "tb": [0.0, 0.0]}], + "regions": [{"loops": [[0, 1, 2]], "winding": "evenOdd", "_figRegionFills": [{"type": "SOLID"}]}]}}, + {"id": "1:5", "name": "Ring", "type": "ELLIPSE", "arcData": {"startingAngle": 0.0, "endingAngle": 3.5, "innerRadius": 0.5}}, + {"id": "1:6", "name": "Star", "type": "STAR", "pointCount": 5.0, "innerRadius": 0.4, "vectorNetworkRef": 3.0}, + {"id": "1:7", "name": "Button", "type": "INSTANCE", "componentId": "2:1", + "componentProperties": {"Label#1:0": {"type": "TEXT", "value": "OK"}}, "overrides": [{"id": "I1:7;2:2", "overriddenFields": ["characters"]}]}, + {"id": "1:8", "name": "Cut", "type": "BOOLEAN_OPERATION", "booleanOperation": "SUBTRACT", "isMask": true, "maskType": "ALPHA", "children": []}, + {"id": "1:9", "name": "Later", "type": "HOLOGRAM", "depth": 3.0} + ] + }] + }]}, + "components": {"2:1": {"key": "abc", "name": "Button", "description": "", "componentSetId": "2:0", "remote": false, "documentationLinks": []}}, + "componentSets": {"2:0": {"key": "set", "name": "Buttons", "description": ""}}, + "styles": {"5:1": {"key": "s1", "name": "Brand", "styleType": "FILL", "description": "", "remote": false}, "5:2": {"key": "s2", "name": "Glow", "styleType": "SPARKLE"}} + }"##; + +fn first_page(file: &File) -> &Node { + &file.document.as_ref().unwrap().children.as_ref().unwrap()[0] +} + +#[test] +fn a_file_reads_into_the_typed_records() { + let file = File::from_value(file_json(), 100).unwrap(); + assert_eq!(file.name.as_deref(), Some("Fixture")); + let page = first_page(&file); + assert_eq!(page.kind, Some(NodeType::Canvas)); + assert_eq!(page.background_color, Some(Color { r: 0.9, g: 0.9, b: 0.9, a: 1.0 })); + let card = &page.children.as_ref().unwrap()[0]; + assert_eq!((card.kind.clone(), card.blend_mode.clone()), (Some(NodeType::Frame), Some(BlendMode::PassThrough))); + assert_eq!(card.absolute_bounding_box, Some(Rectangle { x: 10.5, y: 20.0, width: 300.0, height: 200.0 })); + assert_eq!(card.relative_transform, Some([[1.0, 0.0, 10.5], [0.0, 1.0, 20.0]])); + assert_eq!(card.constraints.as_ref().unwrap().horizontal.as_deref(), Some("LEFT_RIGHT")); + let fills = card.fills.as_ref().unwrap(); + assert_eq!( + fills.iter().map(|p| p.kind.clone().unwrap()).collect::>(), + [PaintType::Solid, PaintType::GradientLinear, PaintType::Image, PaintType::Other("SHINY_NEW_PAINT".into())] + ); + assert_eq!(fills[1].gradient_stops.as_ref().unwrap()[1].color.a, 0.0); + assert_eq!(fills[2].image_ref.as_deref(), Some("0a1b2c")); + assert_eq!(card.rectangle_corner_radii, Some([8.0, 8.0, 0.0, 0.0])); + let effects = card.effects.as_ref().unwrap(); + assert_eq!((effects[0].kind.clone(), effects[0].offset), (Some(EffectType::DropShadow), Some(Vector { x: 0.0, y: 2.0 }))); + assert_eq!(card.styles.as_ref().unwrap()["fill"], "5:1"); + assert_eq!(card.layout_grids.as_ref().unwrap()[0].count, Some(4.0)); + let kids = card.children.as_ref().unwrap(); + let text = &kids[0]; + assert_eq!(text.characters.as_deref(), Some("Hello\nworld")); + assert_eq!(text.style.as_ref().unwrap().font_weight, Some(700.0)); + assert_eq!(text.style.as_ref().unwrap().opentype_flags.as_ref().unwrap()["LIGA"], 0.0); + assert_eq!(text.style_override_table.as_ref().unwrap()["1"].fills.as_ref().unwrap()[0].kind, Some(PaintType::Solid)); + assert_eq!(kids[1].fill_geometry.as_ref().unwrap()[0].path.as_deref(), Some("M0 0 L10 0 L5 8 Z")); + assert_eq!(kids[2].arc_data.as_ref().unwrap().inner_radius, Some(0.5)); + assert_eq!(kids[4].component_id.as_deref(), Some("2:1")); + assert_eq!(kids[5].boolean_operation.as_deref(), Some("SUBTRACT")); + assert_eq!(kids[6].kind, Some(NodeType::Other("HOLOGRAM".into()))); + assert_eq!(file.components.as_ref().unwrap()["2:1"].component_set_id.as_deref(), Some("2:0")); + assert_eq!(file.styles.as_ref().unwrap()["5:1"].style_type, Some(StyleType::Fill)); + assert_eq!(file.styles.as_ref().unwrap()["5:2"].style_type, Some(StyleType::Other("SPARKLE".into()))); + // Every node, parents first. + let ids: Vec<&str> = file.document.as_ref().unwrap().walk().filter_map(|n| n.id.as_deref()).collect(); + assert_eq!(ids, ["0:0", "0:1", "1:2", "1:3", "1:4", "1:5", "1:6", "1:7", "1:8", "1:9"]); +} + +#[test] +fn nothing_is_lost_between_reading_and_writing() { + let original = file_json(); + let file = File::from_value(original.clone(), 100).unwrap(); + assert_eq!(file.to_value().unwrap(), original); + // Fields without a name here stay where they were, as they were. + assert_eq!(file.extra["role"], "owner"); + let card = &first_page(&file).children.as_ref().unwrap()[0]; + assert!(card.extra.contains_key("interactions") && card.extra.contains_key("boundVariables")); + assert_eq!(card.fills.as_ref().unwrap()[2].extra["filters"]["exposure"], 0.25); + assert_eq!(card.fills.as_ref().unwrap()[3].extra["sparkle"], 3.5); + assert_eq!(card.effects.as_ref().unwrap()[1].extra["blurType"], "PROGRESSIVE"); + assert_eq!(card.children.as_ref().unwrap()[6].extra["depth"], 3.0); + // Through text and back, too. + let text = serde_json::to_vec(&file).unwrap(); + assert_eq!(File::from_slice(&text).unwrap(), file); +} + +#[test] +fn the_extensions_are_read_and_kept() { + let file = File::from_value(file_json(), 100).unwrap(); + let kids = first_page(&file).children.as_ref().unwrap()[0].children.as_ref().unwrap(); + let net = kids[1].vector_network.as_ref().unwrap(); + assert_eq!((net.vertices.len(), net.segments.len(), net.regions.len()), (3, 3, 1)); + assert_eq!(net.vertices[1].radius, 2.0); + assert_eq!(net.vertices[1].extra["cap"], "round"); + assert_eq!(net.segments[1].ta, (1.0, 2.0)); + assert_eq!(net.regions[0].winding, Winding::EvenOdd); + assert!(net.regions[0].extra.contains_key("_figRegionFills")); + assert!(net.is_consistent()); + // The others have no typed field: they are kept by name. + assert_eq!(kids[3].extra[ext::VECTOR_NETWORK_REF], 3.0); + assert_eq!(kids[3].extra["pointCount"], 5.0); + assert_eq!(kids[0].style.as_ref().unwrap().extra["_letterSpacingPercent"], 2.0); + // A network naming a vertex or a segment it lacks is told before its indices are used. + let bad: VectorNetwork = serde_json::from_value(json!({"vertices": [{"x": 0, "y": 0}], "segments": [{"a": 0, "b": 1}], "regions": []})).unwrap(); + assert!(!bad.is_consistent()); + let bad: VectorNetwork = serde_json::from_value(json!({"vertices": [], "segments": [], "regions": [{"loops": [[0]]}]})).unwrap(); + assert!(!bad.is_consistent()); + assert_eq!(bad.regions[0].winding, Winding::NonZero); +} + +#[test] +fn a_built_file_writes_only_what_it_holds() { + let rect = Node::new(NodeProps { + id: Some("1:1".into()), + name: Some("R".into()), + kind: Some(NodeType::Rectangle), + size: Some(Vector { x: 10.0, y: 5.0 }), + fills: Some(vec![Paint { kind: Some(PaintType::Solid), color: Some(Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }), ..Paint::default() }]), + ..NodeProps::default() + }); + let mut page = Node::with_children(NodeProps { id: Some("0:1".into()), kind: Some(NodeType::Canvas), ..NodeProps::default() }, vec![rect]); + page.extra.insert("pointCount".into(), json!(3)); + let document = Node::with_children( + NodeProps { kind: Some(NodeType::Document), ..NodeProps::default() }, + vec![page, Node::with_children(NodeProps::default(), vec![])], + ); + let file = File { document: Some(document), ..File::default() }; + assert_eq!( + file.to_value().unwrap(), + json!({"document": {"type": "DOCUMENT", "children": [{"id": "0:1", "type": "CANVAS", "pointCount": 3, "children": [ + {"id": "1:1", "name": "R", "type": "RECTANGLE", "size": {"x": 10.0, "y": 5.0}, + "fills": [{"type": "SOLID", "color": {"r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0}}]}]}, {"children": []}]}}) + ); +} + +#[test] +fn nulls_are_left_out_and_whole_numbers_come_back_as_floats() { + let file = File::from_value(json!({"name": null, "schemaVersion": 3, "document": {"id": "0:0", "opacity": 1, "locked": null}}), 10).unwrap(); + assert_eq!(file.to_value().unwrap(), json!({"schemaVersion": 3.0, "document": {"id": "0:0", "opacity": 1.0}})); + // A `children` that is not a list is kept as it is; an empty list stays a list. + let odd = json!({"document": {"id": "0:0", "children": {"id": "1"}}}); + assert_eq!(File::from_value(odd.clone(), 10).unwrap().to_value().unwrap(), odd); + let empty = json!({"id": "1", "children": []}); + assert_eq!(Node::from_value(empty.clone(), 10).unwrap().children, Some(vec![])); + assert_eq!(Node::from_value(empty.clone(), 10).unwrap().to_value().unwrap(), empty); + // A colour without alpha is opaque. + let paint: Paint = serde_json::from_value(json!({"color": {"r": 0, "g": 0, "b": 0}})).unwrap(); + assert_eq!(paint.color.unwrap().a, 1.0); +} + +#[test] +fn a_field_of_the_wrong_type_fails_the_read_and_says_which() { + for (bad, field) in [ + (json!({"document": {"visible": "yes"}}), "boolean"), + (json!({"document": {"children": [{"opacity": []}]}}), "f64"), + (json!({"document": {"fills": [{"color": "red"}]}}), "Color"), + (json!({"document": {"relativeTransform": [[1, 0], [0, 1]]}}), "array of length 3"), + (json!({"name": 5}), "string"), + ] { + let e = File::from_value(bad, 10).unwrap_err().to_string(); + assert!(e.starts_with("file JSON: ") && e.contains(field), "{e}"); + } + assert!(File::from_slice(b"{not json").is_err()); + assert!(File::from_slice(b"[]").is_err()); + assert_eq!(File::from_slice(b"{}").unwrap(), File::default()); +} + +/// `depth` layers inside one another. +fn nested(depth: usize) -> Value { + let mut node = json!({"id": "leaf", "type": "RECTANGLE"}); + for i in 0..depth { + node = json!({"id": format!("g{i}"), "type": "GROUP", "children": [node]}); + } + node +} + +#[test] +fn the_depth_limit_holds() { + // Each layer is two levels (its `children` array and itself); the file adds one. The tree is + // read, written and dropped at the limit on this test's thread (2 MiB of stack). + let deepest = (MAX_DEPTH - 1) / 2; + let original = json!({"document": nested(deepest)}); + let file = File::from_value(original.clone(), 1000).unwrap(); + assert_eq!(file.document.as_ref().unwrap().walk().count(), deepest + 1); + assert_eq!(file.to_value().unwrap(), original); + assert_eq!(File::from_value(json!({"document": nested(deepest + 1)}), 1000).err(), Some(Error::TooDeep)); + // Depth inside an unnamed field counts as well. + let mut deep = json!(1); + for _ in 0..MAX_DEPTH + 1 { + deep = json!([deep]); + } + assert_eq!(File::from_value(json!({"mystery": deep}), 10).err(), Some(Error::TooDeep)); +} + +#[test] +fn the_node_limit_holds() { + let wide = |n: usize| json!({"document": {"id": "0:0", "type": "DOCUMENT", "children": (0..n).map(|i| json!({"id": format!("1:{i}"), "type": "RECTANGLE"})).collect::>()}}); + assert!(File::from_value(wide(99), 100).is_ok()); + assert_eq!(File::from_value(wide(100), 100).err(), Some(Error::TooManyNodes(100))); + let nodes = json!({"name": "N", "nodes": {"1:2": {"document": nested(3)}, "9:9": null}}); + let read = FileNodes::from_value(nodes.clone(), 4).unwrap(); + assert!(read.nodes.as_ref().unwrap()["9:9"].is_none()); + assert_eq!(serde_json::to_value(&read).unwrap(), nodes); + assert_eq!(FileNodes::from_value(nodes, 3).err(), Some(Error::TooManyNodes(3))); +} + +#[test] +fn junk_values_never_panic() { + // Every value of the fixture in turn replaced by a value of another type: the read ends in a + // file or an error. + fn paths(v: &Value, at: &mut Vec, out: &mut Vec>) { + out.push(at.clone()); + match v { + Value::Object(m) => { + for (k, c) in m { + at.push(k.clone()); + paths(c, at, out); + at.pop(); + } + } + Value::Array(a) => { + for (i, c) in a.iter().enumerate() { + at.push(i.to_string()); + paths(c, at, out); + at.pop(); + } + } + _ => {} + } + } + let original = file_json(); + let mut all = Vec::new(); + paths(&original, &mut Vec::new(), &mut all); + let junk = [json!(null), json!(5), json!(-1e300), json!("x"), json!(true), json!([]), json!({}), json!([1, "a"])]; + let mut read = 0; + for (n, at) in all.iter().enumerate().skip(1) { + let mut changed = original.clone(); + let mut v = &mut changed; + for k in at { + v = match v { + Value::Object(m) => m.get_mut(k).unwrap(), + Value::Array(a) => &mut a[k.parse::().unwrap()], + _ => panic!("a path only runs through objects and arrays"), + }; + } + *v = junk[n % junk.len()].clone(); + if let Ok(file) = File::from_value(changed, 1000) { + read += 1; + assert!(file.to_value().is_ok()); + } + } + assert!(read > 0 && read < all.len()); +} + +#[test] +fn warnings_are_counted_once_each_and_capped() { + let mut w = Warnings::default(); + for _ in 0..3 { + w.add("a".into()); + } + w.add("b".into()); + assert_eq!(w.seen().collect::>(), ["a", "b"]); + assert_eq!(w.finish(), ["a (×3)", "b"]); + let mut w = Warnings::default(); + for i in 0..MAX_WARNINGS + 5 { + w.add(format!("{i}")); + } + w.add("0".into()); + let out = w.finish(); + assert_eq!(out.len(), MAX_WARNINGS); + assert_eq!(out[0], "0 (×2)"); +} + +#[test] +fn style_names_are_spelled_as_words() { + for (from, to) in [ + ("SemiBold", "Semi Bold"), + ("ExtraBoldItalic", "Extra Bold Italic"), + ("Regular Italic", "Italic"), + ("italic", "Italic"), + ("bold", "Bold"), + ("Demi-Bold", "Demi Bold"), + ("Condensed", "Condensed"), + ("", ""), + ] { + assert_eq!(style_name(from), to, "{from}"); + } +}