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}"); + } +}