From 6f51ec7f8da4514b26643ea224b0459879fa2b1c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:15:28 -0500 Subject: [PATCH] craft-fig: map the .fig message onto the REST file JSON (#980) * 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. * craft-fig: map the message onto the REST file JSON FigCraft's .fig mapping (crates/format/src/fig/map and fig/text_path.rs at e8b53e33) moved onto craft-figma-rest: rest::to_rest turns a decoded message into the REST API file JSON, with the vector networks layers name by vectorNetworkRef handed over beside it. Connector routing and the text path measure check are the caller's geometry (rest::Geometry; rest::Straight when it has none). The plugin data key under which a writer keeps an outlined stroke is an option. The geometry blob is read with craft_fig::path instead of a second decoder. * craft-fig: an empty overriddenSymbolID is no swap The empty GUID (4294967295:4294967295) in overriddenSymbolID means "no swap". The mapping took it as the main, found no node and made the instance an empty frame; it now keeps the main its symbolData names, in the instance mapping and in the list of mains a file's instances use. Ported from storytold/figcraft#2208. * craft-fig: a swapped nested instance takes its new main's look A nested instance swapped to another main stores the previous main's look on its own node, so the mapping drew that. Each group of the look (fills, strokes, stroke settings, effects, corners) whose stored values are a copy of the previous main's now takes the new main's; a group the node or a row above it changes is kept, and a style link is decided by the topmost layer that names it. Ported from storytold/figcraft#2204 (the fig/map part; the warning's locale entry stays in the app). * craft-fig: a vector point without a corner radius of its own takes the layer's A point whose style sets no cornerRadius, or that has no style, takes the layer's cornerRadius, as the file draws it; a point's own value (an explicit 0 included) still wins. The layer's radius is part of the network cache key, so an instance overriding the radius doesn't share its main's network. Ported from storytold/figcraft#2115 (the fig/map part; its export change stays in the app). * craft-fig: map deep files within a 1 MiB stack The mapping recursed once per level of layer nesting in a frame of about 2 KB (an unoptimised build): 300 nested frames, or a chain of instances of mains holding each other, overflowed a 1 MiB stack before the 256 level limit could stop them, a crash on hostile input. The work on one layer now happens in layer_head and layer_tail, which return before and after its children are mapped, and what they hand over is boxed, so the recursive frame is about 800 bytes. An unoptimised build now maps past the limit on 512 KiB; the limit stays 256. The test at the limit runs on a 1 MiB thread, for frames and for instances, and overflowed before this change. --------- Co-authored-by: Trevor Brown Co-authored-by: Giovanni Almeida --- Cargo.lock | 2 + crates/fig/Cargo.toml | 2 + crates/fig/README.md | 38 +- crates/fig/src/lib.rs | 1 + crates/fig/src/rest.rs | 725 ++++++++++++++ crates/fig/src/rest/component.rs | 506 ++++++++++ crates/fig/src/rest/connector.rs | 521 ++++++++++ .../rest/connector_snapshot_budget_tests.rs | 179 ++++ crates/fig/src/rest/document.rs | 333 +++++++ crates/fig/src/rest/geometry.rs | 107 ++ crates/fig/src/rest/image_filter_tests.rs | 39 + crates/fig/src/rest/layer.rs | 938 ++++++++++++++++++ crates/fig/src/rest/layout.rs | 190 ++++ crates/fig/src/rest/paint.rs | 354 +++++++ crates/fig/src/rest/proto.rs | 179 ++++ crates/fig/src/rest/swap.rs | 167 ++++ crates/fig/src/rest/swap_tests.rs | 407 ++++++++ crates/fig/src/rest/tests.rs | 752 ++++++++++++++ crates/fig/src/rest/text.rs | 327 ++++++ crates/fig/src/rest/text_path.rs | 297 ++++++ crates/fig/src/rest/variables.rs | 168 ++++ 21 files changed, 6228 insertions(+), 4 deletions(-) create mode 100644 crates/fig/src/rest.rs create mode 100644 crates/fig/src/rest/component.rs create mode 100644 crates/fig/src/rest/connector.rs create mode 100644 crates/fig/src/rest/connector_snapshot_budget_tests.rs create mode 100644 crates/fig/src/rest/document.rs create mode 100644 crates/fig/src/rest/geometry.rs create mode 100644 crates/fig/src/rest/image_filter_tests.rs create mode 100644 crates/fig/src/rest/layer.rs create mode 100644 crates/fig/src/rest/layout.rs create mode 100644 crates/fig/src/rest/paint.rs create mode 100644 crates/fig/src/rest/proto.rs create mode 100644 crates/fig/src/rest/swap.rs create mode 100644 crates/fig/src/rest/swap_tests.rs create mode 100644 crates/fig/src/rest/tests.rs create mode 100644 crates/fig/src/rest/text.rs create mode 100644 crates/fig/src/rest/text_path.rs create mode 100644 crates/fig/src/rest/variables.rs diff --git a/Cargo.lock b/Cargo.lock index d03ab3a90..346ca29be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1106,9 +1106,11 @@ dependencies = [ name = "craft-fig" version = "0.1.0" dependencies = [ + "craft-figma-rest", "craft-kiwi", "craft-zip", "flate2", + "kurbo 0.13.1", "ruzstd", "serde_json", "thiserror 2.0.21", diff --git a/crates/fig/Cargo.toml b/crates/fig/Cargo.toml index 19c25c069..b452a49d6 100644 --- a/crates/fig/Cargo.toml +++ b/crates/fig/Cargo.toml @@ -11,7 +11,9 @@ repository.workspace = true workspace = true [dependencies] +craft-figma-rest = { workspace = true } craft-kiwi = { workspace = true } +kurbo = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } craft-zip = { workspace = true } diff --git a/crates/fig/README.md b/crates/fig/README.md index 849ccefe7..224c711b3 100644 --- a/crates/fig/README.md +++ b/crates/fig/README.md @@ -83,8 +83,38 @@ to: a small file can't claim a large tree however far its chunks inflate. The archive is read with the `zip` crate directly, with the bounds above applied to each member. A shared bounded ZIP reader is planned (craft-libs #126); this crate moves to it when it lands. -## Not here yet +## The message as REST file JSON (`rest`) -A typed model of the message (nodes, paints, text, components, variables, prototypes). FigCraft -maps the `Kv` tree straight onto its own document today; a model mirroring the file's schema is -the next step. +`rest::to_rest` maps a decoded message onto the REST API file JSON that +[`craft-figma-rest`](../figma-rest/README.md) models: pages, the layer tree, geometry, paints, +strokes, effects, text with styled runs, auto layout and grids, components, component sets and +instances (expanded from their main, with overrides and property values applied), shared styles, +variables, prototype interactions and flows. Layer kinds the REST shape doesn't have (sticky +notes, connectors, tables, …) come out as the closest frames, text and vectors. What isn't +understood is skipped with a warning. + +```rust +let mapped = craft_fig::rest::to_rest(&msg, "Untitled", &craft_fig::rest::Options { + max_nodes: craft_fig::rest::layer_budget(packed), + ..Default::default() +}); +let file = craft_figma_rest::File::from_value(mapped.json, usize::MAX)?; // or read the JSON +// mapped.networks: the vector networks layers name by `vectorNetworkRef` +// mapped.warnings: what was skipped or approximated +``` + +- **Geometry the caller owns.** A connector's route between its ends, and whether a text path can + be measured, come from `Options::geometry` (the `rest::Geometry` trait): an editor routes + imported connectors as it routes its own. `rest::Straight` draws straight lines and accepts + every text path. +- **Extensions.** Besides the public fields, the JSON carries the extensions craft-figma-rest + documents and private `_fig*` handoffs (each layer's provenance, cached library definitions, + mains the layer cap kept out, region paints, saved line breaks, text paths) for an importer + that wants them. +- **Budgets.** At most `Options::max_nodes` layers are produced or scanned, instance expansion + included (`rest::layer_budget` follows the compressed canvas: 8 bytes a layer, 200 000 to + 2 000 000); nesting is followed 256 levels deep (the walk recurses once per level in small frames: an unoptimised build maps past that limit on 512 KiB, half the 1 MiB stack of a WebAssembly build, and a test at the limit runs on a 1 MiB thread); conditional actions 16, expressions 32; grid + tracks 1 000; region paint elements 200 000 per file; scale factors between 0.001 and 1000. + +The mapping was written in FigCraft (`crates/format/src/fig/map`) by its contributors, from the +same public format knowledge as the rest of this crate. diff --git a/crates/fig/src/lib.rs b/crates/fig/src/lib.rs index b8ea98615..4c7a57d14 100644 --- a/crates/fig/src/lib.rs +++ b/crates/fig/src/lib.rs @@ -25,6 +25,7 @@ mod canvas; mod images; pub mod network; pub mod path; +pub mod rest; #[cfg(test)] mod tests; diff --git a/crates/fig/src/rest.rs b/crates/fig/src/rest.rs new file mode 100644 index 000000000..bbe9a31d1 --- /dev/null +++ b/crates/fig/src/rest.rs @@ -0,0 +1,725 @@ +//! A decoded `.fig` message mapped onto the REST API file JSON ([`craft_figma_rest`]), the shape +//! an application's importer takes. +//! +//! The message lists node changes; each names its id (`guid`), its parent and its position among +//! the siblings (`parentIndex`). Nodes are read by field name, so files written with newer +//! schemas still map what is understood. Instances carry only their overrides: their layers are +//! expanded here from the main component (overrides and derived layout matched by id path, +//! component property values applied) so the result looks as saved. Layer kinds the REST shape +//! doesn't have (sticky notes, connectors, tables, …) come out as the closest frames, text and +//! vectors. Anything not understood is skipped with a warning; malformed input never panics. +//! +//! Besides the public REST fields, the result carries the extensions [`craft_figma_rest::ext`] +//! documents (`vectorNetwork`, `vectorNetworkRef`, `pointCount`, `innerRadius`, +//! `_letterSpacingPercent`) and private `_fig*` handoffs for an importer that wants the file's +//! provenance ([`FIG_SOURCE_FIELD`], [`FIG_LIBRARY_CONTEXTS_FIELD`], [`FIG_CAPPED_MAINS_FIELD`], +//! `_figRegionFills`, `_figLineStarts`, `_figTextPath`). + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::rc::Rc; + +use craft_figma_rest::ext::VectorNetwork; +use kurbo::{Affine, Point, Rect}; +use serde_json::{Map, Value, json}; + +use crate::kiwi::Kv; +use crate::network; + +mod component; +mod connector; +mod document; +mod geometry; +mod layer; +mod layout; +mod paint; +mod proto; +mod swap; +mod text; +mod text_path; +mod variables; + +pub use geometry::{Geometry, MAX_RUNS, RouteEnd, Routing, SavedRuns, Side, Straight}; + +use component::OverrideScope; +#[cfg(test)] +use paint::paint; + +type Obj = Map; + +/// Most layers mapped from a file when the caller has no budget of its own +/// ([`layer_budget`] gives one that follows the file's size). +pub const MAX_NODES: usize = 200_000; +/// The provenance of a layer mapped from the file (its guid, whether it was expanded from a +/// main, …), on each layer. +pub const FIG_SOURCE_FIELD: &str = "_figSource"; +/// Component-bearing branches of the file's internal canvases (cached library definitions), on +/// the file. +pub const FIG_LIBRARY_CONTEXTS_FIELD: &str = "_figLibraryContexts"; +/// Mains in the file that the layer cap kept out, on the file: their instances are told apart +/// from those of components the file doesn't have. +pub const FIG_CAPPED_MAINS_FIELD: &str = "_figCappedMains"; + +/// Deepest nesting followed. +const MAX_DEPTH: usize = 256; +/// Deepest nesting of conditional prototype actions followed. +const MAX_ACTION_DEPTH: usize = 16; +/// Deepest nesting of variable expressions followed. +const MAX_EXPR_DEPTH: usize = 32; +const MAX_GRID_TRACKS: usize = 1_000; +const MAX_GRID_TRACK_VALUE: f64 = 1e6; +/// Smallest and largest scale factor of a scaled instance (`symbolData.uniformScaleFactor`, times +/// those of the instances around it) that is followed: a junk factor changes nothing. +const MIN_SCALE_FACTOR: f64 = 1e-3; +const MAX_SCALE_FACTOR: f64 = 1e3; +/// Id of "no style". +const NO_STYLE: &str = "4294967295:4294967295"; +/// Longest asset `key` or `version` kept for resolving references given as assets. +const MAX_ASSET_PART: usize = 256; +/// Compressed canvas bytes per layer of [`layer_budget`]. +const PACKED_BYTES_PER_LAYER: usize = 8; +/// Most layers [`layer_budget`] gives. +pub const MAX_FIG_NODES: usize = 2_000_000; + +/// Most layers to map from a file whose compressed canvas is `packed` bytes: one per 8 bytes, at +/// least [`MAX_NODES`] and at most [`MAX_FIG_NODES`]. Instances expand into a layer per layer of +/// their component, and chunks inflate up to [`crate::MAX_CHUNK`], so the budget follows what the +/// file takes, not what it unpacks to: a small file can't claim a large count, however it's +/// built; a large one gets room. Design systems are mostly instances, which pack tightly: a 7.8 MB +/// canvas stopped at 244 248 layers at 32 bytes a layer. At 8, files under about 1.6 MB keep the +/// floor and the ceiling comes at a 16 MB canvas. +pub fn layer_budget(packed: usize) -> usize { + (packed / PACKED_BYTES_PER_LAYER).clamp(MAX_NODES, MAX_FIG_NODES) +} + +/// How to map a message. +#[derive(Clone, Copy)] +pub struct Options<'g> { + /// Most layers produced or scanned, instance expansion included ([`layer_budget`]). + pub max_nodes: usize, + /// Connector routing and text path checks ([`Straight`] when the caller has none). + pub geometry: &'g dyn Geometry, + /// The plugin data (plugin id, key) under which a writer kept what a layer written as the + /// outline of its stroke was: read back as `outlinedStroke` (an extension of the file JSON). + pub outlined_stroke: Option<(&'g str, &'g str)>, +} + +impl Default for Options<'_> { + fn default() -> Self { + Options { max_nodes: MAX_NODES, geometry: &Straight, outlined_stroke: None } + } +} + +/// What [`to_rest`] makes of a message. +#[derive(Debug)] +pub struct Mapped { + /// The file JSON, in the shape [`craft_figma_rest::File`] reads. + pub json: Value, + /// What was skipped or approximated, each once (`(×n)` after one met n times). + pub warnings: Vec, + /// The vector networks layers name by `vectorNetworkRef` (each built once, however many + /// instances show it). `None` where one couldn't be built. + pub networks: Vec>, +} + +/// The REST API file JSON for message `msg` (decoded with its file's schema), named `name`. +pub fn to_rest<'a>(msg: &'a Kv<'a>, name: &str, options: &Options<'a>) -> Mapped { + let mut m = Mapper::new(msg, options); + let _canon = VarCanon::install(&m); + let doc = m.document(); + // Large values are moved in, never passed through `json!`, which serialises its arguments: + // a deep copy of the whole tree, briefly holding it twice. + let mut out = json!({"name": name}); + out["document"] = doc; + if !m.library_contexts.is_empty() { + out[FIG_LIBRARY_CONTEXTS_FIELD] = Value::Array(std::mem::take(&mut m.library_contexts)); + } + // Mains in the file that the layer cap kept out: their instances are told apart from those of + // components the file doesn't have. + if m.count >= m.max_nodes { + let capped: Vec = m.referenced_mains.difference(&m.mapped_mains).map(|id| json!(id)).collect(); + if !capped.is_empty() { + out[FIG_CAPPED_MAINS_FIELD] = Value::Array(capped); + } + } + if !m.styles.is_empty() { + out["styles"] = Value::Object(std::mem::take(&mut m.styles)); + } + if !m.components.is_empty() { + out["components"] = Value::Object(std::mem::take(&mut m.components)); + } + if !m.sets.is_empty() { + out["componentSets"] = Value::Object(std::mem::take(&mut m.sets)); + } + if let Some(vars) = m.variables() { + out["meta"] = vars; + } + let styles = m.style_definitions(); + if !styles.is_empty() { + out["styleDefinitions"] = Value::Array(styles); + } + let networks = std::mem::take(&mut m.networks); + Mapped { json: out, warnings: std::mem::take(&mut m.warnings).finish(), networks } +} + +/// The axis-aligned bounds of `rect` after `xf`. +fn bbox(xf: Affine, rect: Rect) -> Rect { + let corners = [Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)]; + let mut it = corners.into_iter().map(|p| xf * p); + let Some(first) = it.next() else { return Rect::ZERO }; + it.fold(Rect::from_points(first, first), |r, p| r.union_pt(p)) +} + +/// `s` cut at 200 characters, for quoting names from the file in warnings. +fn clip_str(s: &str) -> &str { + s.char_indices().nth(200).and_then(|(i, _)| s.get(..i)).unwrap_or(s) +} + +#[cfg(test)] +mod image_filter_tests; +#[cfg(test)] +mod swap_tests; +#[cfg(test)] +mod tests; + +// ---- small readers ---- + +/// A generated child is identified by one exact template GUID, not its array position. +fn generated_record<'a>(v: &View<'a>, field: &str, local_id: u32) -> Option<&'a Kv<'a>> { + v.get(field)?.get("overrides")?.list().iter().find(|row| { + row.get("guidPath").and_then(|path| path.get("guids")).map(Kv::list).is_some_and(|guids| { + guids.len() == 1 && num(&guids[0], "sessionID") == Some(40_000_000.0) && num(&guids[0], "localID") == Some(f64::from(local_id)) + }) + }) +} + +fn num(n: &Kv, k: &str) -> Option { + n.get(k).and_then(Kv::f64) +} + +fn guid(v: &Kv) -> Option { + Some(format!("{}:{}", num(v, "sessionID")? as u64, num(v, "localID")? as u64)) +} + +/// The main an `overriddenSymbolID` swaps to: none for the empty GUID written there for "no +/// swap", which names no node. +fn swap_guid(v: &Kv) -> Option { + guid(v).filter(|g| g != NO_STYLE) +} + +#[cfg(test)] +mod connector_snapshot_budget_tests; + +/// A `GUID`-holding reference (`StyleId`, `VariableID`, …): its `guid`. +fn ref_guid(v: &Kv) -> Option { + v.get("guid").and_then(guid).filter(|g| g != NO_STYLE) +} + +fn vec2(v: &Kv) -> Option { + Some(json!({"x": num(v, "x")?, "y": num(v, "y")?})) +} + +fn size(v: &Kv) -> Option<(f64, f64)> { + Some((num(v, "x")?, num(v, "y")?)) +} + +fn matrix(m: &Kv) -> Option { + let c = |k: &str| num(m, k); + let a = Affine::new([c("m00")?, c("m10")?, c("m01")?, c("m11")?, c("m02")?, c("m12")?]); + a.as_coeffs().iter().all(|x| x.is_finite()).then_some(a) +} + +fn rows(a: Affine) -> Value { + let [a, b, c, d, e, f] = a.as_coeffs(); + json!([[a, c, e], [b, d, f]]) +} + +fn copy_num(o: &mut Obj, n: &View, from: &str, to: &str) { + if let Some(x) = n.num(from) { + o.insert(to.into(), json!(x)); + } +} + +/// [`copy_num`] for a length: the layers of a scaled instance give it at the instance's scale. +fn copy_len(o: &mut Obj, n: &View, from: &str, to: &str) { + if let Some(x) = n.len(from) { + o.insert(to.into(), json!(x)); + } +} + +/// `x` at scale `k`; `None` when the result is not finite. +fn scaled(x: f64, k: f64) -> Option { + if k == 1.0 { + return Some(x); + } + let r = x * k; + r.is_finite().then_some(r) +} + +fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) { + if let Some(s) = n.str(from) { + o.insert(to.into(), json!(s)); + } +} + +/// A node seen through override layers: the last layer with a field wins. +#[derive(Clone)] +struct View<'a> { + layers: Vec<&'a Kv<'a>>, + /// The factor each layer's lengths take (`scales[i]` is for `layers[i]`): the layers of a main + /// inside a scaled instance have the instance's, the override and derived rows of the instance + /// 1, being at the instance's scale already (#2091). + scales: Vec, + /// Size and transform worked out from the constraints, over the layers' own. + fitted: Option<((f64, f64), Affine)>, +} + +impl<'a> View<'a> { + fn of(n: &'a Kv<'a>) -> Self { + Self::of_scaled(n, 1.0) + } + + /// A node of a main inside an instance scaled by `k`: its lengths read `k` times as long. + fn of_scaled(n: &'a Kv<'a>, k: f64) -> Self { + Self { layers: vec![n], scales: vec![k], fitted: None } + } + + /// This view with the layers of `upper` over it (`upper`'s fitted size and place). + fn over(&self, upper: &Self) -> Self { + let mut v = self.clone(); + v.layers.extend(upper.layers.iter().copied()); + v.scales.extend(upper.scales.iter().copied()); + v.fitted = upper.fitted; + v + } + + /// The factor of the lowest layer (the layer's own main), for values a style gives. + fn base_scale(&self) -> f64 { + self.scales.first().copied().unwrap_or(1.0) + } + + fn size(&self) -> Option<(f64, f64)> { + self.fitted.map(|f| f.0).or_else(|| { + let (v, k) = self.find("size")?; + let (x, y) = size(v)?; + Some((scaled(x, k)?, scaled(y, k)?)) + }) + } + + fn transform(&self) -> Option { + self.fitted.map(|f| f.1).or_else(|| { + let (v, k) = self.find("transform")?; + let m = matrix(v)?; + if k == 1.0 { + return Some(m); + } + let [a, b, c, d, e, f] = m.as_coeffs(); + let m = Affine::new([a, b, c, d, scaled(e, k)?, scaled(f, k)?]); + Some(m) + }) + } + + /// This view with `top` over it, each layer with the factor its lengths take. + fn with(&self, top: impl IntoIterator, f64)>) -> Self { + let mut v = self.clone(); + for (layer, scale) in top { + v.layers.push(layer); + v.scales.push(scale); + } + v + } + + /// The factor of the layer that links a style in `key` (or `legacy`), the lowest layer's when + /// none does: what the linked style's lengths take. + fn link_scale(&self, key: &str, legacy: &str) -> f64 { + self.find(key).or_else(|| self.find(legacy)).map_or_else(|| self.base_scale(), |f| f.1) + } + + /// This view with `style` just above the layer that links it in `key` (or `legacy`): the style + /// beats that layer's and lower layers' copies of its fields (stale when the style changed + /// since, or written for the style linked before), and override rows above the link beat it. + fn styled(&self, style: Option<&'a Kv<'a>>, key: &str, legacy: &str) -> Self { + let mut v = self.clone(); + if let Some(s) = style { + let linked_in = |k: &str| v.layers.iter().rposition(|l| l.get(k).is_some()); + let at = linked_in(key).or_else(|| linked_in(legacy)).map_or(1, |i| i + 1); + let at = at.min(v.layers.len()); + // The style is at the scale of the layer that links it. + let k = at.checked_sub(1).and_then(|i| v.scales.get(i)).copied().unwrap_or(1.0); + v.layers.insert(at, s); + v.scales.insert(at, k); + } + v + } + + /// An effect/grid list follows the same precedence as text styles: an override above the + /// style link wins, while the style beats stale copies on and below its linking row. A style + /// without the list is empty; it must not expose a stale list from a lower row. + fn styled_list(&self, style: &'a Kv<'a>, list: &str, key: &str, legacy: &str) -> (&'a [Kv<'a>], f64) { + let linked_in = |k: &str| self.layers.iter().rposition(|l| l.get(k).is_some()); + let above = linked_in(key).or_else(|| linked_in(legacy)).map_or(1, |i| i + 1); + self.layers + .iter() + .zip(&self.scales) + .skip(above) + .rev() + .find_map(|(row, scale)| row.get(list).map(|v| (v.list(), *scale))) + .unwrap_or_else(|| (style.get(list).map(Kv::list).unwrap_or_default(), self.link_scale(key, legacy))) + } + + fn get(&self, k: &str) -> Option<&'a Kv<'a>> { + self.layers.iter().rev().find_map(|l| l.get(k)) + } + + /// [`Self::get`], and the factor of the layer that has it. + fn find(&self, k: &str) -> Option<(&'a Kv<'a>, f64)> { + self.layers.iter().zip(&self.scales).rev().find_map(|(l, s)| l.get(k).map(|v| (v, *s))) + } + + /// A length: the number `k` holds at the scale of the layer that has it. + fn len(&self, k: &str) -> Option { + let (v, s) = self.find(k)?; + scaled(v.f64()?, s) + } + + /// [`Self::list`], and the factor of the layer that has it. + fn list_scaled(&self, k: &str) -> (&'a [Kv<'a>], f64) { + self.find(k).map_or((&[], 1.0), |(v, s)| (v.list(), s)) + } + + fn num(&self, k: &str) -> Option { + self.get(k).and_then(Kv::f64) + } + + fn str(&self, k: &str) -> Option<&'a str> { + self.get(k).and_then(Kv::str) + } + + fn flag(&self, k: &str) -> Option { + self.get(k).and_then(Kv::bool) + } + + fn list(&self, k: &str) -> &'a [Kv<'a>] { + self.get(k).map(Kv::list).unwrap_or_default() + } +} + +struct Mapper<'a> { + nodes: BTreeMap>, + kids: BTreeMap>, + blobs: Vec<&'a [u8]>, + /// Component property definitions: id → (REST name "Name#id", type). + prop_defs: BTreeMap, + /// The copies of a set's definitions each variant keeps (no name or type of their own; the + /// variant's layers and its instances' assignments use them): copy id → the definition's id. + prop_parents: BTreeMap, + /// File nodes by asset `key` and `version`: references can name a collection, variable or + /// style as the asset instead of the guid, and the file carries the resources to resolve + /// them (the collection node itself says the same `key`). + assets: BTreeMap>, + styles: Obj, + /// Descriptions of components and component sets (REST `components` / `componentSets`). + components: Obj, + sets: Obj, + /// Layers and keys of each main, built when an instance of it first needs them. + override_scopes: BTreeMap>, + /// Entries held by `override_scopes` (bounded by `max_nodes` together). + override_scope_entries: usize, + /// Component-bearing branches of the file's internal canvases (cached library definitions). + library_contexts: Vec, + /// Layers visited while scanning internal canvases, across all of them. + library_scan_count: usize, + warnings: craft_figma_rest::Warnings, + geometry: &'a dyn Geometry, + outlined_stroke: Option<(&'a str, &'a str)>, + count: usize, + /// The mains in the file that instances or instance swap defaults name, and the mains mapped + /// as layers: the difference is what the layer cap kept out. + referenced_mains: BTreeSet, + mapped_mains: BTreeSet, + /// Most layers produced or scanned, instance expansion included; the one place the cap comes + /// from (see [`layer_budget`]). + max_nodes: usize, + /// Layers kept back for the visible pages' top level while library copies are mapped, so the + /// copies can't take the whole budget and leave the pages empty. + reserved: usize, + /// Vector networks, each built once: the layers that show one (every instance of a main + /// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's + /// networks took gigabytes. + networks: Vec>, + /// Index in `networks` by its blob, position scale, style table, point-radius scale and the + /// layer's corner radius (which points without one of their own take). + network_ids: HashMap<(usize, u64, u64, usize, u64, u64), usize>, + /// Region paint elements (paints and gradient stops) read so far: one budget for the whole + /// file, since layers with region paints don't share their network. + region_paint_elements: usize, +} + +impl<'a> Mapper<'a> { + fn new(msg: &'a Kv<'a>, options: &Options<'a>) -> Self { + let max_nodes = options.max_nodes; + let mut nodes = BTreeMap::new(); + let mut kids: BTreeMap> = BTreeMap::new(); + let mut prop_defs = BTreeMap::new(); + let mut prop_parents = BTreeMap::new(); + let changes = msg.get("nodeChanges").map(Kv::list).unwrap_or_default(); + // The last change of a GUID wins; a REMOVED change deletes what came before it. + for n in changes { + let Some(id) = n.get("guid").and_then(guid) else { continue }; + if n.get("phase").and_then(Kv::str) == Some("REMOVED") { + nodes.remove(&id); + } else { + nodes.insert(id, n); + } + } + for n in changes { + let Some(id) = n.get("guid").and_then(guid) else { continue }; + if !nodes.get(&id).is_some_and(|latest| std::ptr::eq(*latest, n)) { + continue; + } + // Variables and their collections are read from the node list (`of_type`), never + // as layers: out of the layer tree, an instance's expansion doesn't walk them again for + // every instance without counting them (#1077). + if let Some(p) = n.get("parentIndex") + && let Some(parent) = p.get("guid").and_then(guid) + && !matches!(n.get("type").and_then(Kv::str), Some("VARIABLE" | "VARIABLE_SET")) + { + kids.entry(parent).or_default().push((p.get("position").and_then(Kv::str).unwrap_or(""), id.clone())); + } + for d in n.get("componentPropDefs").map(Kv::list).unwrap_or_default() { + let Some(did) = d.get("id").and_then(guid) else { continue }; + if let (Some(name), Some(ty)) = (d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str)) { + prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty)); + } else if let Some(parent) = d.get("parentPropDefId").and_then(guid) { + prop_parents.insert(did, parent); + } + } + } + for list in kids.values_mut() { + list.sort(); + list.dedup_by(|a, b| a.1 == b.1); + } + let blobs = + msg.get("blobs").map(Kv::list).unwrap_or_default().iter().map(|b| b.get("bytes").and_then(Kv::bytes).unwrap_or_default()).collect(); + let mut assets: BTreeMap> = BTreeMap::new(); + for n in msg.get("nodeChanges").map(Kv::list).unwrap_or_default() { + let (Some(key), Some(id)) = (n.get("key").and_then(Kv::str), n.get("guid").and_then(guid)) else { continue }; + if key.is_empty() || key.len() > MAX_ASSET_PART { + continue; + } + let version = n.get("version").and_then(Kv::str).filter(|v| !v.is_empty() && v.len() <= MAX_ASSET_PART).unwrap_or(""); + assets.entry(key.to_string()).or_default().entry(version.to_string()).or_insert(id); + } + Self { + nodes, + kids, + blobs, + prop_defs, + prop_parents, + assets, + styles: Obj::new(), + components: Obj::new(), + sets: Obj::new(), + override_scopes: BTreeMap::new(), + override_scope_entries: 0, + library_contexts: Vec::new(), + library_scan_count: 0, + warnings: craft_figma_rest::Warnings::default(), + geometry: options.geometry, + outlined_stroke: options.outlined_stroke, + count: 0, + referenced_mains: BTreeSet::new(), + mapped_mains: BTreeSet::new(), + max_nodes, + reserved: 0, + networks: Vec::new(), + network_ids: HashMap::new(), + region_paint_elements: 0, + } + } + + /// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`, + /// else the file node its asset `key` and `version` names (a collection from a library is + /// referenced as the asset, and the file's own node for it says the same `key`). `None` when + /// the reference has neither or the asset names nothing (or ambiguously) in the file. + /// Original code for this importer: the field names are those of the schema each file embeds. + fn resource_id(&self, v: &Kv) -> Option { + // A guid names the resource when this file holds a node by it. One from another file (the + // library a style or collection came from, as a paste carries) names nothing here: the + // asset it also names finds the file's copy (#1638). + if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE && self.nodes.contains_key(g)) { + return Some(g); + } + let asset = v.get("assetRef")?; + let key = asset.get("key").and_then(Kv::str)?; + let versions = self.assets.get(key)?; + let version = asset.get("version").and_then(Kv::str); + match version { + Some(v) => versions.get(v), + // Without a version: an unversioned record, else the file's only one. + None => versions.get("").or_else(|| (versions.len() == 1).then(|| versions.values().next()).flatten()), + } + .cloned() + } + + /// The collection a `VariableSetID` names, as one collection per library asset: a file that + /// took several updates of a library holds a copy of the collection for each version it met, + /// all with the same asset `key`. Variables and explicit modes may name any of those copies, + /// while the mode ids stay the same across them, so every copy maps to the newest one (the + /// one [`Self::variables`] keeps, with the modes of the others added). Without this, a page set + /// to a mode an older copy didn't have yet lost its mode, and the layers fell back to the + /// default mode of the newest copy. A reference by asset that names no copy by itself (no + /// version, or a version the file doesn't hold, while it holds several) still names the asset: + /// its newest live copy. + pub(super) fn collection_id(&self, v: &Kv) -> Option { + let by_asset = || v.get("assetRef")?.get("key").and_then(Kv::str).and_then(VarCanon::newest_set); + match self.resource_id(v) { + Some(id) if self.nodes.contains_key(&id) => Some(self.canonical_collection(&id).unwrap_or(id)), + // The asset index can name a node a later change removed: the asset's live copy instead. + Some(id) => by_asset().or(Some(id)), + None => by_asset(), + } + } + + /// The newest live copy of collection node `id`'s library asset, when it has a key. + pub(super) fn canonical_collection(&self, id: &str) -> Option { + let key = self.nodes.get(id)?.get("key").and_then(Kv::str).filter(|k| !k.is_empty())?; + VarCanon::newest_set(key) + } + + /// A note about what this file's mapping left out or changed. Counted, as the REST importer + /// counts its own: these notes are met once per layer, so the kinds were named once each with + /// no sign of how much of the file they covered -- a file whose every text layer lost a + /// setting read the same as one where a single layer did (#467). The collector keeps the + /// first-seen order and the same cap, since some notes name fields of the file (image + /// filters). + fn warn(&mut self, w: impl Into) { + self.warnings.add(w.into()); + } + + fn children(&self, id: &str) -> Vec { + self.kids.get(id).map(|k| k.iter().map(|(_, c)| c.clone()).collect()).unwrap_or_default() + } + + /// How many layers may be produced now: the cap, less what is kept for the visible pages. + fn budget(&self) -> usize { + self.max_nodes.saturating_sub(self.reserved) + } +} + +/// A library version (`"40727:1183"`) as numbers to order by; unparsable parts rank lowest. +fn version_rank(v: &str) -> (u64, u64) { + let mut parts = v.split(':').map(|p| p.trim().parse::().unwrap_or(0)); + (parts.next().unwrap_or(0), parts.next().unwrap_or(0)) +} + +/// Library variables, one per asset. A file that took several updates of a library keeps a copy +/// of a variable for each version it met (same `key`, another `version`), and layers and other +/// variables name them by that asset (`assetRef {key, version}`) rather than by guid. Each +/// reference goes to the newest copy: it has the values of every mode the library has now, where +/// an older one lacks the modes added since (a layer under a page set to such a mode fell back to +/// the default one). Kept per thread while a file is mapped: the paint and variable readers that +/// meet references are free functions. +#[derive(Default)] +pub(super) struct VarCanon { + by_key: HashMap, + by_id: HashMap, + /// Every live variable node of the file, with a key or not. + local: BTreeSet, + /// The newest live copy of each library collection, by asset key. Read from the file's live + /// nodes (the last change of each guid, REMOVED ones gone), not from the asset index, which + /// keeps the first node it met for a key and version even when a later change removed it. + sets_by_key: HashMap, +} + +thread_local! { + static VAR_CANON: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// Removes the thread's table when the mapping is done. +pub(super) struct VarCanonGuard; + +impl Drop for VarCanonGuard { + /// Clears the thread's table, so a later file on this thread starts without it. + fn drop(&mut self) { + VAR_CANON.with(|c| c.borrow_mut().take()); + } +} + +impl VarCanon { + /// Builds the table from mapper `m`'s live nodes (newest copy per asset key, for variables and + /// for collections) and installs it for this thread until the returned guard is dropped. + fn install(m: &Mapper) -> VarCanonGuard { + let mut newest: HashMap = HashMap::new(); + let mut copies: Vec<(String, String)> = Vec::new(); + let mut local = BTreeSet::new(); + let mut sets: HashMap = HashMap::new(); + for (id, n) in &m.nodes { + let ty = n.get("type").and_then(Kv::str); + if !matches!(ty, Some("VARIABLE" | "VARIABLE_SET")) || n.get("isSoftDeleted").and_then(Kv::bool) == Some(true) { + continue; + } + if ty == Some("VARIABLE_SET") { + if let Some(key) = n.get("key").and_then(Kv::str).filter(|k| !k.is_empty()) { + let rank = version_rank(n.get("version").and_then(Kv::str).unwrap_or("")); + let e = sets.entry(key.to_string()).or_insert_with(|| (rank, id.clone())); + if rank > e.0 || (rank == e.0 && *id > e.1) { + *e = (rank, id.clone()); + } + } + continue; + } + local.insert(id.clone()); + let Some(key) = n.get("key").and_then(Kv::str).filter(|k| !k.is_empty()) else { continue }; + let rank = version_rank(n.get("version").and_then(Kv::str).unwrap_or("")); + copies.push((id.clone(), key.to_string())); + let e = newest.entry(key.to_string()).or_insert_with(|| (rank, id.clone())); + if rank > e.0 || (rank == e.0 && *id > e.1) { + *e = (rank, id.clone()); + } + } + let mut t = VarCanon::default(); + for (id, key) in copies { + if let Some((_, canon)) = newest.get(&key) { + t.by_id.insert(id, canon.clone()); + } + } + t.by_key = newest.into_iter().map(|(k, (_, id))| (k, id)).collect(); + t.local = local; + t.sets_by_key = sets.into_iter().map(|(k, (_, id))| (k, id)).collect(); + VAR_CANON.with(|c| *c.borrow_mut() = Some(t)); + VarCanonGuard + } + + /// The variable a reference (`{guid}` or `{assetRef}`) names, as its newest copy. A guid + /// counts when this file holds a variable by it; one from another file (a paste carries the + /// library's own guids) names nothing here, so the asset it also names is tried before it is + /// kept as it is. + pub(super) fn resolve(r: &Kv) -> Option { + let by_guid = r.get("guid").and_then(guid).filter(|g| g != NO_STYLE); + VAR_CANON.with(|c| { + let c = c.borrow(); + let Some(t) = c.as_ref() else { return by_guid.clone() }; + if let Some(g) = by_guid.as_ref().filter(|g| t.local.contains(*g)) { + return Some(t.by_id.get(g).cloned().unwrap_or_else(|| g.clone())); + } + let by_asset = r.get("assetRef").and_then(|a| a.get("key")).and_then(Kv::str).and_then(|k| t.by_key.get(k)).cloned(); + by_asset.or(by_guid) + }) + } + + /// The newest live copy of the library collection with asset key `key`. + pub(super) fn newest_set(key: &str) -> Option { + VAR_CANON.with(|c| c.borrow().as_ref().and_then(|t| t.sets_by_key.get(key).cloned())) + } + + /// Whether a table is installed on this thread (for tests: none outlives the mapping). + #[cfg(test)] + fn installed() -> bool { + VAR_CANON.with(|c| c.borrow().is_some()) + } + + /// Whether variable node `id` is an older copy another one replaces. + pub(super) fn superseded(id: &str) -> bool { + VAR_CANON.with(|c| c.borrow().as_ref().and_then(|t| t.by_id.get(id)).is_some_and(|canon| canon != id)) + } +} diff --git a/crates/fig/src/rest/component.rs b/crates/fig/src/rest/component.rs new file mode 100644 index 000000000..7b4f08625 --- /dev/null +++ b/crates/fig/src/rest/component.rs @@ -0,0 +1,506 @@ +use super::layer::own_text; +use super::text::char_styles; +use super::{BTreeMap, BTreeSet, Kv, MAX_DEPTH, MAX_SCALE_FACTOR, MIN_SCALE_FACTOR, Mapper, Obj, Rc, Value, View, guid, json, swap_guid}; + +/// An override row names a layer of the main by its current GUID or by the `overrideKey` the +/// layer had when the row was written: the key becomes the current GUID, unless the layer is +/// current already or the key is ambiguous. +fn normalize_override_segment(path: &mut [String], index: usize, scope: &OverrideScope) { + let Some(segment) = path.get(index) else { return }; + if scope.current.contains(segment) { + return; + } + if let Some(Some(current)) = scope.aliases.get(segment) { + path[index] = current.clone(); + } +} + +/// Overrides and derived layout by id path. +type Overrides<'a> = BTreeMap, Vec>>; + +/// Where an override entry comes from. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Entry { + Override, + Derived, + /// Derived layout of a nested instance for a size it no longer has: its size and place no + /// longer hold, the rest (resolved colours, text) still does. + Stale, +} + +/// One override or derived-layout row addressed to a layer. +#[derive(Clone)] +pub(super) struct OverrideRow<'a> { + pub(super) row: &'a Kv<'a>, + pub(super) entry: Entry, + /// How many mains are being expanded around the instance that wrote the row: the rows of + /// the enclosing (outer) instances win over those of the inner ones. + source_depth: usize, + /// How many levels below the writing instance the row restates (`overrideLevel`; 0 for the + /// instance's own rows). + level: usize, + /// Position among the rows of the instance that wrote it; later rows win. + ordinal: usize, + /// The factor the row's lengths take: 1 for the rows of an instance in the document, the + /// factor of the layer of the enclosing main that holds the rows for a nested instance's own + /// (they are written in that main's space, #2091). + pub(super) scale: f64, +} + +/// The nested level an override row restates: its `overrideLevel`, capped; 0 when it has none. +fn override_level(row: &Kv) -> usize { + row.get("overrideLevel").and_then(Kv::f64).filter(|l| l.is_finite() && *l > 0.0).map_or(0, |l| l.min(MAX_DEPTH as f64) as usize) +} + +/// The layers of a main by current GUID, and the old `overrideKey` of each that has one +/// (`None` when two layers claim the same key). +pub(super) struct OverrideScope { + current: BTreeSet, + aliases: BTreeMap>, +} + +/// Values by key that nested instances add to their outer instances' (property values, slot +/// contents): an outer instance's value wins. Each instance keeps only what it adds and shares the +/// rest, instead of copying every outer value (#1077); lookups walk at most the nesting depth. +pub(super) struct Chain { + own: BTreeMap, + outer: Option>>, +} + +impl Chain { + /// `outer`'s values, then `own`'s for keys `outer` lacks (shared as it is when `own` adds nothing). + fn over(outer: Option<&Rc>>, own: BTreeMap) -> Rc { + match outer { + Some(o) if own.is_empty() => Rc::clone(o), + _ => Rc::new(Self { own, outer: outer.cloned() }), + } + } + + pub(super) fn get(&self, key: &str) -> Option<&V> { + self.outer.as_ref().and_then(|o| o.get(key)).or_else(|| self.own.get(key)) + } +} + +/// Where an instance's expanded layers are: the overrides and derived layout of the outermost +/// instance (by id path), the path of the instance being expanded, and the property values. +#[derive(Clone)] +pub(super) struct Inst<'a> { + pub(super) root: String, + pub(super) path: Vec, + pub(super) overrides: Rc>, + /// Property values with the factor their lengths take (the layer of the writing instance's + /// node that holds the assignment, as for override rows). + props: Rc, f64)>>, + /// Slot property GUID → private slot-content node GUID. + pub(super) slot_contents: Rc>, + /// Layers imported from a slot's independent content subtree. + pub(super) slot_content: bool, + /// Mains being expanded (an instance inside its own main stops there). + symbols: Vec, + /// The factor the lengths of the expanded layers take: the instance's `uniformScaleFactor` + /// times those of the instances around it (#2091). Override and derived rows are already at + /// the instance's scale and take none. + pub(super) scale: f64, +} + +impl<'a> Mapper<'a> { + /// The layers of main `main` by current GUID and by `overrideKey`. An instance nested in the + /// main is not entered: its layers belong to another main. + fn override_scope(&mut self, main: &str) -> Rc { + if let Some(scope) = self.override_scopes.get(main) { + return Rc::clone(scope); + } + let mut current = BTreeSet::new(); + let mut aliases: BTreeMap> = BTreeMap::new(); + let mut visited = BTreeSet::new(); + let mut pending: Vec<(String, usize)> = self.children(main).into_iter().map(|id| (id, 1)).collect(); + let mut complete = true; + while let Some((id, depth)) = pending.pop() { + if depth > MAX_DEPTH || visited.len() >= self.max_nodes { + complete = false; + break; + } + if !visited.insert(id.clone()) { + continue; + } + let Some(node) = self.nodes.get(&id) else { continue }; + current.insert(id.clone()); + if let Some(alias) = node.get("overrideKey").and_then(guid) { + let target = aliases.entry(alias).or_insert_with(|| Some(id.clone())); + if target.as_deref().is_some_and(|target| target != id) { + *target = None; + } + } + if node.get("type").and_then(Kv::str) != Some("INSTANCE") { + pending.extend(self.children(&id).into_iter().map(|child| (child, depth + 1))); + } + } + // A scope cut short could name the wrong layer for a key: use none of the keys. + if !complete { + aliases.clear(); + } + let scope = Rc::new(OverrideScope { current, aliases }); + let cost = scope.current.len() + scope.aliases.len(); + if cost <= self.max_nodes.saturating_sub(self.override_scope_entries) { + self.override_scope_entries += cost; + self.override_scopes.insert(main.to_string(), Rc::clone(&scope)); + } + scope + } + + pub(super) fn is_instance_swap(&self, def: &Kv) -> Option<()> { + matches!(self.prop_def(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(()) + } + + /// The definition a copy of it was made from, followed to the original: the values an instance + /// sets are kept under it, since an assignment and the layers that read it may name different + /// copies of one definition. + pub(super) fn prop_root(&self, def: &str) -> String { + let mut def = def; + for _ in 0..=MAX_DEPTH { + let Some(parent) = self.prop_parents.get(def) else { break }; + def = parent; + } + def.to_string() + } + + /// The definition `def` names: its own, or the set's for a variant's copy of it. + fn prop_def(&self, def: &str) -> Option<&(String, &'a str)> { + let mut def = def; + for _ in 0..=MAX_DEPTH { + if let Some(found) = self.prop_defs.get(def) { + return Some(found); + } + def = self.prop_parents.get(def)?; + } + None + } + + /// Component property definitions and description. + pub(super) fn component(&mut self, o: &mut Obj, v: &View<'a>, id: &str) { + let mut defs = Obj::new(); + for d in v.list("componentPropDefs") { + if d.get("isDeleted").and_then(Kv::bool) == Some(true) { + continue; + } + let Some((name, ty)) = d.get("id").and_then(guid).and_then(|i| self.prop_defs.get(&i).cloned()) else { continue }; + let init = d.get("initialValue"); + let def = match ty { + "BOOL" => json!({"type": "BOOLEAN", "defaultValue": init.and_then(|i| i.get("boolValue")).and_then(Kv::bool).unwrap_or(true)}), + "TEXT" => json!({"type": "TEXT", "defaultValue": init.and_then(prop_text).unwrap_or("")}), + "INSTANCE_SWAP" => match init.and_then(|i| i.get("guidValue")).and_then(guid) { + Some(g) => { + if self.nodes.contains_key(&g) { + self.referenced_mains.insert(g.clone()); + } + json!({"type": "INSTANCE_SWAP", "defaultValue": g}) + } + None => continue, + }, + "VARIANT" => json!({"type": "VARIANT"}), + "SLOT" => { + self.warn_non_default_slot_config(d); + self.warn_dropped_slot_preferred_values(d); + json!({"type": "SLOT"}) + } + _ => { + self.warn(format!("{} component properties are not supported and were skipped", ty.to_ascii_lowercase())); + continue; + } + }; + defs.insert(name, def); + } + if !defs.is_empty() { + o.insert("componentPropertyDefinitions".into(), Value::Object(defs)); + } + if let Some(d) = v.str("symbolDescription").or_else(|| v.str("description")).filter(|d| !d.is_empty()) { + let into = if o.get("type").and_then(Value::as_str) == Some("COMPONENT_SET") { &mut self.sets } else { &mut self.components }; + into.insert(id.to_string(), json!({"description": d})); + } + } + + /// Preferred slot values are source component keys, but this mapper has no proven way to map + /// those keys to file GUIDs. They are dropped instead of guessing native NodeIds. + fn warn_dropped_slot_preferred_values(&mut self, def: &Kv<'a>) { + let entries = def.get("preferredValues").and_then(|v| v.get("instanceSwapValues")).map(Kv::list).unwrap_or_default(); + if !entries.is_empty() { + self.warn("preferred slot component keys cannot be resolved to file nodes and were skipped"); + } + } + + fn warn_non_default_slot_config(&mut self, def: &Kv<'a>) { + let Some(config) = def.get("slotPropConfig") else { return }; + let non_default = [ + ("stretchChildOnInsert", Some(false)), + ("displayByDefault", Some(false)), + ("minChildren", None), + ("maxChildren", None), + ("allowPreferredValuesOnly", Some(false)), + ] + .into_iter() + .any(|(field, bool_default)| { + let Some(value) = config.get(field) else { return false }; + if let Some(default) = bool_default { value.bool() != Some(default) } else { value.f64() != Some(0.0) } + }); + if non_default { + self.warn("non-default slot settings are not represented and were skipped"); + } + } + + /// A layer's component property references, and inside an instance the values they take. + pub(super) fn prop_refs(&mut self, o: &mut Obj, v: &View<'a>, inst: Option<&Inst<'a>>) { + let mut refs = Obj::new(); + for e in v.get("parameterConsumptionMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() { + let field = match e.get("variableField").and_then(Kv::str) { + Some("VISIBLE") => "visible", + Some("TEXT_DATA") => "characters", + Some("OVERRIDDEN_SYMBOL_ID") => "mainComponent", + Some("SLOT_CONTENT_ID") => "slot", + _ => continue, + }; + let Some(def) = + e.get("variableData").and_then(|d| d.get("value")).and_then(|x| x.get("propRefValue")).and_then(|p| p.get("defId")).and_then(guid) + else { + continue; + }; + // Expanded frames must follow their main frame's source link. A direct slot reference + // makes the engine resolve ownership against the instance, where the property is not defined. + if !(field == "slot" && inst.is_some()) + && let Some((name, _)) = self.prop_def(&def) + { + refs.insert(field.into(), json!(name)); + } + let Some(&(val, val_scale)) = inst.and_then(|i| i.props.get(&self.prop_root(&def))) else { continue }; + match field { + "visible" => { + if let Some(b) = val.get("boolValue").and_then(Kv::bool) { + o.insert("visible".into(), json!(b)); + } + } + "characters" => { + if let Some(t) = prop_text(val) { + o.insert("characters".into(), json!(t)); + // Run styles count the chars they were made for: for other chars only + // the value's own apply. + if own_text(v) != Some(t) { + o.remove("characterStyleOverrides"); + o.remove("styleOverrideTable"); + o.remove("_figLineStarts"); + if let Some(tv) = prop_text_data(val) { + let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64); + char_styles(o, tv, val_scale, size, &mut |w| self.warn(w)); + } + } + } + } + _ => {} + } + } + if !refs.is_empty() { + o.insert("componentPropertyReferences".into(), Value::Object(refs)); + } + } + + /// An instance's main and properties; the context its expanded layers are built in. + pub(super) fn instance(&mut self, o: &mut Obj, v: &View<'a>, id: &str, outer: Option<&Inst<'a>>, rest_id: &str) -> Option<(Inst<'a>, String)> { + let sd = v.get("symbolData"); + let mut sym = v.get("overriddenSymbolID").and_then(swap_guid).or_else(|| sd.and_then(|s| s.get("symbolID")).and_then(guid)); + // An instance swap property value picks the main. + if let Some(i) = outer + && let Some(def) = prop_ref(v, "OVERRIDDEN_SYMBOL_ID") + && let Some(g) = i.props.get(&self.prop_root(&def)).and_then(|(p, _)| prop_guid(p)) + { + sym = Some(g); + } + let Some(sym) = sym else { + self.warn("instances without a main component were imported as empty frames"); + return None; + }; + o.insert("componentId".into(), json!(sym)); + let assignments = v.list("componentPropAssignments"); + let mut props = Obj::new(); + for a in assignments { + let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_def(&d).cloned()) else { continue }; + let val = assigned(a); + let p = match ty { + "BOOL" => val.and_then(|x| x.get("boolValue")).and_then(Kv::bool).map(|b| json!({"type": "BOOLEAN", "value": b})), + "TEXT" => val.and_then(prop_text).map(|t| json!({"type": "TEXT", "value": t})), + "INSTANCE_SWAP" => val.and_then(prop_guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})), + _ => None, + }; + if let Some(p) = p { + props.insert(name, p); + } + } + if !props.is_empty() { + o.insert("componentProperties".into(), Value::Object(props)); + } + if !self.nodes.contains_key(&sym) { + self.warn("instances of components that aren't in the file show no layers"); + return None; + } + if !self.referenced_mains.contains(&sym) { + self.referenced_mains.insert(sym.clone()); + } + if outer.is_some_and(|i| i.symbols.contains(&sym)) { + return None; + } + // Overrides by id path below this instance (the instance itself is addressed by its main's id). + let own_path: Vec = outer.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()).unwrap_or_default(); + let scope = self.override_scope(&sym); + let mut overrides: Overrides<'a> = BTreeMap::new(); + if let Some(inherited) = outer { + // Only rows addressed below this instance can apply inside it (every path looked up in + // it starts with `own_path`); they sort together, so the others aren't even visited. A + // nested instance used to copy all of its outer instance's rows (#1077). + let below = inherited.overrides.range(own_path.clone()..).take_while(|(path, _)| path.starts_with(&own_path)); + for (path, rows) in below.filter(|(path, _)| path.len() > own_path.len()) { + let mut path = path.clone(); + // Rows an outer instance wrote for this instance's layers may use their old keys. + normalize_override_segment(&mut path, own_path.len(), &scope); + overrides.entry(path).or_default().extend(rows.iter().cloned()); + } + } + // A nested instance's derived layout is for the size it has in its main: resized here, the + // sizes and places in it no longer hold and its layers follow their constraints instead. + // A scaled size is the factor's product, rounded to a float: a looser match. + let tolerance = if outer.is_some_and(|i| i.scale != 1.0) { 1e-4 } else { 1e-6 }; + let differs = |a: f64, b: f64| (a - b).abs() > tolerance * a.abs().max(b.abs()).max(1.0); + let outer_scale = outer.map_or(1.0, |i| i.scale); + let resized = outer.is_some() + && match (self.nodes.get(id).and_then(|b| View::of_scaled(b, outer_scale).size()), v.size()) { + (Some(a), Some(b)) => differs(a.0, b.0) || differs(a.1, b.1), + (a, b) => a.is_some() != b.is_some(), + }; + let mut local: Overrides<'a> = BTreeMap::new(); + // The lists are at the scale of the layer of this node that holds them. + let scale_of = |k: &str| v.find(k).map_or(1.0, |f| f.1); + let lists = [ + (sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), scale_of("symbolData")), + (v.list("derivedSymbolData"), scale_of("derivedSymbolData")), + ]; + let source_depth = outer.map_or(0, |i| i.symbols.len()); + let mut ordinal = 0; + for (layer, (list, row_scale)) in lists.into_iter().enumerate() { + for ov in list { + let row_ordinal = ordinal; + ordinal += 1; + let mut rel: Vec = + ov.get("guidPath").and_then(|g| g.get("guids")).map(Kv::list).unwrap_or_default().iter().filter_map(guid).collect(); + if rel.is_empty() || rel == [sym.clone()] { + continue; + } + normalize_override_segment(&mut rel, 0, &scope); + // Explicit overrides, then derived layout (stale once the instance is resized). + let entry = match (layer, resized) { + (0, _) => Entry::Override, + (_, false) => Entry::Derived, + (_, true) => Entry::Stale, + }; + local.entry([own_path.as_slice(), &rel].concat()).or_default().push(OverrideRow { + row: ov, + entry, + source_depth, + level: override_level(ov), + ordinal: row_ordinal, + scale: row_scale, + }); + } + } + for (path, rows) in local { + overrides.entry(path).or_default().extend(rows); + } + for rows in overrides.values_mut() { + // Deeper rows apply first and outer rows win: a row restating a nested level counts at + // that level's depth, wherever it sits on the wire. Equal depths keep the outer source + // last, then wire order. + rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth + row.level), std::cmp::Reverse(row.source_depth), row.ordinal)); + } + // This instance's own property values and slot contents; the outer instances' win. + let assignment_scale = v.find("componentPropAssignments").map_or(1.0, |f| f.1); + let mut all_props: BTreeMap, f64)> = BTreeMap::new(); + let mut slot_contents: BTreeMap = BTreeMap::new(); + for a in assignments { + if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), assigned(a)) { + all_props.entry(self.prop_root(&d)).or_insert((val, assignment_scale)); + } + let Some(def) = a.get("defID").and_then(guid) else { continue }; + if self.prop_def(&def).is_none_or(|(_, ty)| *ty != "SLOT") { + continue; + } + let var_value = a.get("varValue"); + if var_value.and_then(|v| v.get("dataType")).and_then(Kv::str) != Some("SLOT_CONTENT_ID") { + self.warn("malformed slot content assignment was skipped"); + continue; + } + let target = var_value.and_then(|v| v.get("value")).and_then(|v| v.get("slotContentIdValue")).and_then(|v| v.get("guid")).and_then(guid); + let Some(target) = target else { + self.warn("malformed slot content assignment was skipped"); + continue; + }; + if target == "4294967295:4294967295" { + self.warn("slot content assignment uses the empty GUID sentinel and was skipped"); + continue; + } + if self.nodes.get(&target).is_none_or(|n| n.get("isSlotContent").and_then(Kv::bool) != Some(true)) { + self.warn("slot content assignment target is missing or is not slot content"); + continue; + } + slot_contents.entry(self.prop_root(&def)).or_insert(target); + } + let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default(); + symbols.push(sym.clone()); + let root = outer.map_or_else(|| rest_id.to_string(), |i| i.root.clone()); + // The scale the Scale tool gave the instance; a junk factor changes nothing. + let factor = sd + .and_then(|s| s.get("uniformScaleFactor")) + .and_then(Kv::f64) + .filter(|k| (MIN_SCALE_FACTOR..=MAX_SCALE_FACTOR).contains(k)) + .unwrap_or(1.0); + let scale = (outer_scale * factor).clamp(MIN_SCALE_FACTOR, MAX_SCALE_FACTOR); + Some(( + Inst { + root, + path: own_path, + overrides: Rc::new(overrides), + props: Chain::over(outer.map(|i| &i.props), all_props), + slot_contents: Chain::over(outer.map(|i| &i.slot_contents), slot_contents), + slot_content: false, + symbols, + scale, + }, + sym, + )) + } +} + +/// The text of a component property value. +fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> { + prop_text_data(v).and_then(|t| t.get("characters")).and_then(Kv::str) +} + +/// The text data of a component property value: `textValue`, or `textDataValue` in the variable form. +fn prop_text_data<'k, 's>(v: &'k Kv<'s>) -> Option<&'k Kv<'s>> { + v.get("textValue").or_else(|| v.get("textDataValue")) +} + +/// The main an instance swap value names: `guidValue`, or `symbolIdValue` in the variable form. +pub(super) fn prop_guid(v: &Kv) -> Option { + v.get("guidValue").and_then(guid).or_else(|| v.get("symbolIdValue")?.get("guid").and_then(guid)) +} + +/// The value a component property assignment sets: `value`, or the variable form `varValue` holds +/// when `value` is empty, as current files write many booleans and instance swaps. +pub(super) fn assigned<'k, 's>(assignment: &'k Kv<'s>) -> Option<&'k Kv<'s>> { + assignment.get("value").filter(|v| !matches!(v, Kv::Object(fields) if fields.is_empty())).or_else(|| assignment.get("varValue")?.get("value")) +} + +/// The property a layer's `field` is bound to (`parameterConsumptionMap`). +pub(super) fn prop_ref(v: &View, field: &str) -> Option { + v.get("parameterConsumptionMap")?.get("entries")?.list().iter().find_map(|e| { + if e.get("variableField").and_then(Kv::str) != Some(field) { + return None; + } + e.get("variableData")?.get("value")?.get("propRefValue")?.get("defId").and_then(guid) + }) +} diff --git a/crates/fig/src/rest/connector.rs b/crates/fig/src/rest/connector.rs new file mode 100644 index 000000000..3a4b1c34f --- /dev/null +++ b/crates/fig/src/rest/connector.rs @@ -0,0 +1,521 @@ +use super::geometry::{MAX_RUNS, RouteEnd, Routing, SavedRuns, Side}; +use super::layer::{cap_name, own_text, svg_path, valid_size}; +use super::paint::{paint, stroke}; +use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_record, guid, json, matrix, network, num, rows}; +use kurbo::{BezPath, PathEl, Rect, Shape}; + +/// How a connector imports: from its saved snapshot (`terminal`: as one vector, no room for its +/// label), or drawn from its ends, as a frame with its line and `label` when it has one. +#[derive(Clone, Copy)] +pub(super) struct ConnectorShape { + pub snapshot: bool, + pub terminal: bool, + pub label: bool, +} + +impl<'a> Mapper<'a> { + /// A connector's own properties on `o`; returns the children it adds (a line and a label). + pub(super) fn connector_layer(&mut self, o: &mut Obj, v: &View<'a>, rest_id: &str, space: Affine, shape: ConnectorShape) -> Vec { + if shape.snapshot { + if shape.terminal { + self.connector_snapshot_flatten(o, v, rest_id, space); + } else { + o.insert("fills".into(), json!([])); + o.insert("strokes".into(), json!([])); + for key in ["styles", "strokeWeight", "strokeAlign", "strokeJoin", "strokeDashes", "strokeCap"] { + o.remove(key); + } + o.insert("clipsContent".into(), json!(false)); + } + return Vec::new(); + } + let mid = self.connector(o, v, space); + self.connector_line_style(o, v); + if shape.label { + return self.connector_label(o, v, rest_id, mid); + } + if connector_has_saved_label(v) { + self.warn("connector labels were skipped"); + } + Vec::new() + } + + /// Whether a connector without a snapshot keeps its label: it has text of its own and there is + /// room for its two children under the depth and layer limits. + pub(super) fn connector_label_fits(&self, v: &View<'a>, depth: usize) -> bool { + own_text(v).is_some() && depth < super::MAX_DEPTH && self.count.saturating_add(2) < self.budget() + } + + /// A connector's end caps, and when it has no saved outline or network its line between its + /// ends (free points, or a sibling layer): straight to the point given on the layer (its + /// centre unless given), or elbowed or curved from a side of it, as a board connector runs; + /// an elbow saved with its path (FigJam's control points) runs along it (#1910). + /// Returns the middle of that line by length, in the connector's own coordinates. + pub(super) fn connector(&mut self, o: &mut Obj, v: &View<'a>, space: Affine) -> Option { + for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] { + if let Some(c) = v.str(from) { + o.insert(to.into(), json!(cap_name(c))); + } + } + if ["vectorNetwork", "vectorNetworkRef", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { + return None; + } + let routing = match v.str("connectorLineStyle") { + Some("ELBOWED") => Routing::Elbow, + Some("CURVED") => Routing::Curved, + _ => Routing::Straight, + }; + let parent = v.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid); + let end = |k: &str| -> Option { + let e = v.get(k)?; + let end = match e.get("endpointNodeID").and_then(guid).filter(|g| g != NO_STYLE) { + Some(id) => { + let n = *self.nodes.get(&id)?; + // Only siblings share the connector's coordinates. + if n.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid) != parent { + return None; + } + let (w, h) = n.get("size").map_or((0.0, 0.0), |s| (num(s, "x").unwrap_or(0.0), num(s, "y").unwrap_or(0.0))); + let at = e.get("relativePosition").and_then(|r| Some((num(r, "x")?, num(r, "y")?))); + let t = n.get("transform").and_then(matrix).unwrap_or_default(); + // An automatic magnet (`AUTO`: the side is picked when the connector is drawn; + // `CENTER`: the end aims at the layer's middle) attaches to the side facing the + // other end, as a board connector drawn here does; the file's `relativePosition` + // is not where such an end sits (#1909). + let automatic = matches!(e.get("magnet").and_then(Kv::str), Some("AUTO" | "CENTER")); + match routing { + Routing::Straight if !automatic => { + let (rx, ry) = at.unwrap_or((0.5, 0.5)); + RouteEnd::Point(t * Point::new(w * rx, h * ry)) + } + _ => RouteEnd::Box(super::bbox(t, Rect::new(0.0, 0.0, w, h)), side(e, at)), + } + } + None => RouteEnd::Point(e.get("position").and_then(|p| Some(Point::new(num(p, "x")?, num(p, "y")?)))?), + }; + let finite = |p: Point| p.x.is_finite() && p.y.is_finite(); + let ok = match end { + RouteEnd::Point(p) => finite(p), + RouteEnd::Box(b, _) => finite(b.origin()) && finite(Point::new(b.x1, b.y1)), + }; + ok.then_some(end) + }; + let ends = end("connectorStart").zip(end("connectorEnd")); + let geometry = self.geometry; + let path = ends.filter(|_| routing == Routing::Elbow).and_then(|(a, b)| saved_runs(geometry, v, a, b)); + let Some(pts) = ends.and_then(|(a, b)| geometry.route(a, b, routing, path.as_ref())) else { + self.warn("connectors whose ends can't be placed are empty"); + return None; + }; + if routing != Routing::Straight && path.is_none() { + self.warn("elbowed and curved connectors without a saved path are routed afresh"); + } + // A curved route is one curve: its ends and the two control points between them. + let (path, vertices) = match (routing, pts.as_slice()) { + (Routing::Curved, [p0, c0, c1, p1]) => { + let mut path = BezPath::new(); + path.move_to(*p0); + path.curve_to(*c0, *c1, *p1); + (path, vec![*p0, *p1]) + } + _ => (pts.iter().enumerate().map(|(i, p)| if i == 0 { PathEl::MoveTo(*p) } else { PathEl::LineTo(*p) }).collect(), pts.clone()), + }; + let b = path.bounding_box(); + let (x0, y0) = (b.x0, b.y0); + o.insert("relativeTransform".into(), rows(space * Affine::translate((x0, y0)))); + o.insert("size".into(), json!({"x": b.width(), "y": b.height()})); + let segments: Vec = match (routing, pts.as_slice()) { + (Routing::Curved, [p0, c0, c1, p1]) => vec![json!({"a": 0, "b": 1, "ta": [c0.x - p0.x, c0.y - p0.y], "tb": [c1.x - p1.x, c1.y - p1.y]})], + _ => (1..vertices.len()).map(|i| json!({"a": i - 1, "b": i})).collect(), + }; + let vertices: Vec = vertices.iter().map(|p| json!({"x": p.x - x0, "y": p.y - y0})).collect(); + o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments})); + middle(&path).map(|m| m - b.origin().to_vec2()) + } + + /// The line's paint, weight, cap and dashes, which a connector keeps in its generated line + /// record rather than on itself: without a saved snapshot the routed line took none of them and + /// drew nothing (#1807). The connector's own values, when it has any, win. + fn connector_line_style(&mut self, o: &mut Obj, v: &View<'a>) { + let Some(line_style) = generated_record(v, "nodeGenerationData", 0) else { return }; + let style = View::of(line_style); + // A connector that writes its own (even empty) paints or dash pattern keeps them: an empty + // list is no stroke, or a solid line, not a missing value. + if v.get("strokePaints").is_none() && o.get("strokes").and_then(Value::as_array).is_none_or(Vec::is_empty) { + let strokes: Vec = style.list("strokePaints").iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect(); + if !strokes.is_empty() { + o.insert("strokes".into(), Value::Array(strokes)); + } + } + let mut line = Obj::new(); + stroke(&mut line, &style, self.outlined_stroke); + if v.get("dashPattern").is_some() { + line.remove("strokeDashes"); + } + for (key, value) in line { + o.entry(key).or_insert(value); + } + } + + /// A connector with a label but no saved snapshot of it, as the frame a saved one imports as: + /// `o` keeps the connector's place and size and gives its line to a vector child, and the + /// label is a text child centred on `mid` (the line's middle; the frame's centre when the line + /// came saved). Returns the two children. + pub(super) fn connector_label(&mut self, o: &mut Obj, v: &View<'a>, rest_id: &str, mid: Option) -> Vec { + let (w, h) = o.get("size").map_or((0.0, 0.0), |s| (s["x"].as_f64().unwrap_or(0.0), s["y"].as_f64().unwrap_or(0.0))); + let mut line = Obj::new(); + line.insert("id".into(), json!(format!("{rest_id};line"))); + line.insert("type".into(), json!("VECTOR")); + line.insert("name".into(), json!(format!("{} line", v.str("name").unwrap_or("Connector")))); + line.insert("size".into(), json!({"x": w, "y": h})); + line.insert("relativeTransform".into(), rows(Affine::IDENTITY)); + // The connector's fill paints colour its label; the line is only stroked. + let label_fills = o.remove("fills").filter(|f| f.as_array().is_some_and(|a| !a.is_empty())); + for key in LINE_KEYS { + if let Some(value) = o.remove(*key) { + line.insert((*key).into(), value); + } + } + o.insert("fills".into(), json!([])); + o.insert("strokes".into(), json!([])); + o.insert("clipsContent".into(), json!(false)); + + let mut text = Obj::new(); + text.insert("id".into(), json!(format!("{rest_id};label"))); + text.insert("type".into(), json!("TEXT")); + self.text(&mut text, v); + let characters = text.get("characters").and_then(Value::as_str).unwrap_or_default().to_string(); + text.insert("name".into(), json!(characters)); + text.insert("fills".into(), label_fills.unwrap_or_else(|| json!([{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]))); + // Centred on the line's middle in a box of the label's width, as tall as its lines (laid out + // again with the fonts at hand, so the box only centres it). + let size = text.get("style").and_then(|s| s["fontSize"].as_f64()).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(16.0); + let lines = characters.lines().count().max(1) as f64; + let (bw, bh) = (LABEL_WIDTH, lines * size * 1.4); + if let Some(style) = text.get_mut("style").and_then(Value::as_object_mut) { + style.insert("textAlignHorizontal".into(), json!("CENTER")); + style.insert("textAlignVertical".into(), json!("CENTER")); + style.insert("textAutoResize".into(), json!("NONE")); + } + let at = mid.unwrap_or(Point::new(w / 2.0, h / 2.0)); + text.insert("size".into(), json!({"x": bw, "y": bh})); + text.insert("relativeTransform".into(), rows(Affine::translate((at.x - bw / 2.0, at.y - bh / 2.0)))); + self.count += 2; + self.warn("connector labels without a saved snapshot are centred on the line, without their background"); + vec![Value::Object(line), Value::Object(text)] + } + + pub(super) fn connector_snapshot_supported(&self, v: &View<'a>) -> bool { + if v.str("connectorType") != Some("MANUAL") + || v.get("connectorVersion").and_then(Kv::f64) != Some(1.0) + || v.str("connectorLineStyle") != Some("STRAIGHT") + || v.get("derivedImmutableFrameData").and_then(|data| data.get("version")).and_then(Kv::f64) != Some(1.0) + { + return false; + } + let Some(line) = generated_record(v, "derivedImmutableFrameData", 0) else { return false }; + if line.get("transform").and_then(matrix).is_none() || valid_size(line.get("size")).is_none() { + return false; + } + let geometry = ["fillGeometry", "strokeGeometry"].iter().any(|key| { + line.get(key).map(Kv::list).unwrap_or_default().iter().any(|path| { + num(path, "commandsBlob") + .and_then(|index| self.blobs.get(index as usize)) + .and_then(|blob| svg_path(blob)) + .is_some_and(|data| !data.is_empty()) + }) + }); + let network = line + .get("vectorData") + .and_then(|data| num(data, "vectorNetworkBlob")) + .and_then(|index| self.blobs.get(index as usize)) + .and_then(|blob| network::decode(blob)) + .is_some_and(|network| !network.segments.is_empty()); + (geometry || network) && generated_record(v, "nodeGenerationData", 0).is_some() + } + + pub(super) fn connector_snapshot_flatten(&mut self, out: &mut Obj, v: &View<'a>, rest_id: &str, space: Affine) { + let parent = space * v.get("transform").and_then(matrix).unwrap_or_default(); + let Some(vector) = self.connector_snapshot_vector(v, rest_id, parent, false) else { + self.warn("connector snapshot vector data is missing; connector was left empty"); + return; + }; + for key in [ + "size", + "relativeTransform", + "fills", + "strokes", + "strokeWeight", + "strokeAlign", + "strokeJoin", + "strokeDashes", + "strokeCap", + "strokeCapStart", + "strokeCapEnd", + "styles", + "fillGeometry", + "strokeGeometry", + "vectorNetwork", + ] { + if let Some(value) = vector.get(key) { + out.insert(key.to_string(), value.clone()); + } + } + if vector.get("visible").and_then(Value::as_bool) == Some(false) { + out.insert("visible".into(), json!(false)); + } + if let Some(line_opacity) = vector.get("opacity").and_then(Value::as_f64) { + let root_opacity = out.get("opacity").and_then(Value::as_f64).unwrap_or(1.0); + out.insert("opacity".into(), json!(root_opacity * line_opacity)); + } + if connector_has_saved_label(v) { + let reason = if self.count >= self.budget() { "node budget" } else { "depth limit" }; + self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children")); + } + } + + /// Build the saved vector child or flattened terminal connector from the same child-only view. + fn connector_snapshot_vector(&mut self, v: &View<'a>, rest_id: &str, space: Affine, nested: bool) -> Option { + let (Some(line), Some(line_style)) = (generated_record(v, "derivedImmutableFrameData", 0), generated_record(v, "nodeGenerationData", 0)) + else { + return None; + }; + let mut vector = Obj::new(); + vector.insert("id".into(), json!(if nested { format!("{rest_id};vector") } else { rest_id.to_string() })); + vector.insert("type".into(), json!("VECTOR")); + if let Some(name) = v.str("name") { + vector.insert("name".into(), json!(if nested { format!("{name} vector") } else { name.to_string() })); + } + let line_view = View::of(line).with([(line_style, 1.0)]); + self.common(&mut vector, &line_view, "VECTOR", space, None); + self.geometry(&mut vector, &line_view); + for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] { + if let Some(cap) = v.str(from) { + vector.entry(to).or_insert_with(|| json!(cap_name(cap))); + } + } + Some(vector) + } + + /// Snapshot the straight, manual connector contract carried by its generated children. + pub(crate) fn connector_snapshot_children(&mut self, v: &View<'a>, rest_id: &str) -> Vec { + if self.count >= self.budget() { + self.warn(format!("the file has more than {} layers; connector snapshot children were skipped", self.max_nodes)); + return Vec::new(); + } + let Some(vector) = self.connector_snapshot_vector(v, rest_id, Affine::IDENTITY, true) else { + self.warn("connector snapshot vector data is missing; connector was left empty"); + return Vec::new(); + }; + let mut children = Vec::new(); + children.push(Value::Object(vector)); + self.count += 1; + + let Some(text_frame) = generated_record(v, "derivedImmutableFrameData", 1) else { + if connector_has_saved_label(v) { + self.warn("connector label was skipped: derived text bounds are missing"); + } + return children; + }; + let Some(background) = generated_record(v, "derivedImmutableFrameData", 2) else { + if connector_has_saved_label(v) || valid_size(text_frame.get("size")).is_some_and(|(w, h)| w > 0.0 && h > 0.0) { + self.warn("connector label was skipped: derived background bounds are missing"); + } + return children; + }; + let Some(text_style) = generated_record(v, "nodeGenerationData", 1) else { + if valid_size(text_frame.get("size")).is_some_and(|(w, h)| w > 0.0 && h > 0.0) { + self.warn("connector label was skipped: text style data is missing"); + } + return children; + }; + let text_view = View::of(text_frame).with([(text_style, 1.0)]); + let Some(characters) = text_view.get("textData").and_then(|d| d.get("characters")).and_then(Kv::str).filter(|s| !s.trim().is_empty()) else { + return children; + }; + let Some(text_transform) = text_frame.get("transform").and_then(matrix) else { + self.warn("connector label was skipped: text transform is invalid"); + return children; + }; + let Some(background_transform) = background.get("transform").and_then(matrix) else { + self.warn("connector label was skipped: background transform is invalid"); + return children; + }; + let Some(text_size) = valid_size(text_frame.get("size")) else { + self.warn("connector label was skipped: text bounds are invalid"); + return children; + }; + let Some(background_size) = valid_size(background.get("size")) else { + self.warn("connector label was skipped: background bounds are invalid"); + return children; + }; + let composed_text_transform = background_transform * text_transform; + if !composed_text_transform.as_coeffs().iter().all(|coefficient| coefficient.is_finite()) { + self.warn("connector label was skipped: composed text transform is invalid"); + return children; + } + + let Some(background_style) = generated_record(v, "nodeGenerationData", 2) else { + self.warn("connector label background was skipped: style data is missing"); + self.add_connector_text(&mut children, rest_id, characters, text_size, composed_text_transform, &text_view); + return children; + }; + let background_view = View::of(background).with([(background_style, 1.0)]); + if !background_view.list("fillPaints").is_empty() { + if self.count >= self.budget() { + self.warn(format!("the file has more than {} layers; connector label background was skipped", self.max_nodes)); + } else { + let mut rect = Obj::new(); + rect.insert("id".into(), json!(format!("{rest_id};label-background"))); + rect.insert("type".into(), json!("RECTANGLE")); + rect.insert("name".into(), json!("connector label background")); + self.common(&mut rect, &background_view, "RECTANGLE", Affine::IDENTITY, None); + rect.insert("size".into(), json!({"x": background_size.0, "y": background_size.1})); + rect.insert("relativeTransform".into(), rows(background_transform)); + rect.insert("clipsContent".into(), json!(false)); + children.push(Value::Object(rect)); + self.count += 1; + } + } + self.add_connector_text(&mut children, rest_id, characters, text_size, composed_text_transform, &text_view); + children + } + + fn add_connector_text( + &mut self, + children: &mut Vec, + rest_id: &str, + characters: &str, + size: (f64, f64), + transform: Affine, + view: &View<'a>, + ) { + if self.count >= self.budget() { + self.warn(format!("the file has more than {} layers; connector label was skipped", self.max_nodes)); + return; + } + let mut text = Obj::new(); + text.insert("id".into(), json!(format!("{rest_id};label-text"))); + text.insert("type".into(), json!("TEXT")); + text.insert("name".into(), json!(characters)); + text.insert("size".into(), json!({"x": size.0, "y": size.1})); + self.common(&mut text, view, "TEXT", Affine::IDENTITY, None); + text.insert("relativeTransform".into(), rows(transform)); + self.text(&mut text, view); + children.push(Value::Object(text)); + self.count += 1; + } +} + +pub(super) fn connector_has_saved_label(v: &View<'_>) -> bool { + own_text(v).is_some() + || generated_record(v, "nodeGenerationData", 1) + .and_then(|r| r.get("textData")) + .and_then(|d| d.get("characters")) + .and_then(Kv::str) + .is_some_and(|s| !s.trim().is_empty()) +} + +/// The path a FigJam elbow connector was saved with: its `connectorControlPoints`, one per run +/// between the first and the last, each a point on its run (in the connector's parent's +/// coordinates, like its ends) with the run's axis; the first run leaves the start's side and the +/// last arrives at the end's, so neither is saved. `None` without any, or when they don't make +/// such a path: a point off both axes, two runs in a row the same way, a first or last run that +/// would leave or arrive sideways to its side, or more runs than a hand could make here. +fn saved_runs(geometry: &dyn super::Geometry, v: &View<'_>, from: RouteEnd, to: RouteEnd) -> Option { + let saved = v.list("connectorControlPoints"); + if saved.is_empty() || saved.len() + 2 > MAX_RUNS { + return None; + } + let ((p0, s0), (p1, s1)) = geometry.attach(from, to); + let mid = p0.midpoint(p1); + let mut at = Vec::with_capacity(saved.len()); + let (mut first, mut last) = (None, None); + for p in saved { + let pos = p.get("position").and_then(|q| Some(Point::new(num(q, "x")?, num(q, "y")?)))?; + let (ax, ay) = p.get("axis").and_then(|q| Some((num(q, "x")?, num(q, "y")?)))?; + let sideways = match (ax != 0.0, ay != 0.0) { + (true, false) => true, + (false, true) => false, + _ => return None, + }; + if last == Some(sideways) { + return None; + } + first.get_or_insert(!sideways); + last = Some(sideways); + at.push(if sideways { pos.y - mid.y } else { pos.x - mid.x }); + } + // The run through each end goes the way its side faces, out of the object: the first leaves + // the start's side for the first saved run, the last arrives at the end's from the last saved + // run. One along the side, or back into the object, doesn't. + let (across, last) = (first?, last?); + let (&a0, &a1) = (at.first()?, at.last()?); + // Where the run from `p` (`sideways`: going across) meets the saved run at `v`. + let onto = |sideways: bool, p: Point, v: f64| if sideways { Point::new(mid.x + v, p.y) } else { Point::new(p.x, mid.y + v) }; + let out_of = |s: Option, p: Point, q: Point| s.is_none_or(|s| (q - p).dot(s.normal()) > 0.0); + let fits = out_of(s0, p0, onto(across, p0, a0)) && out_of(s1, p1, onto(!last, p1, a1)); + fits.then_some(SavedRuns { across, at }) +} + +/// The side of its layer a connector end leaves from: the `magnet` the file names (`AUTO` and +/// `CENTER`: the side facing the other end, whatever `relativePosition` says; #1909), else the edge +/// its point on the layer (`relativePosition`, 0–1) lies on, else the side facing the other end. +fn side(e: &Kv<'_>, at: Option<(f64, f64)>) -> Side { + match e.get("magnet").and_then(Kv::str) { + Some("TOP") => Side::Top, + Some("RIGHT") => Side::Right, + Some("BOTTOM") => Side::Bottom, + Some("LEFT") => Side::Left, + Some("AUTO" | "CENTER") => Side::Auto, + _ => match at { + Some((x, _)) if x <= 0.0 => Side::Left, + Some((x, _)) if x >= 1.0 => Side::Right, + Some((_, y)) if y <= 0.0 => Side::Top, + Some((_, y)) if y >= 1.0 => Side::Bottom, + _ => Side::Auto, + }, + } +} + +/// What a connector's line carries, moved to the line child when the connector becomes a frame. +const LINE_KEYS: &[&str] = &[ + "vectorNetwork", + "vectorNetworkRef", + "fillGeometry", + "strokeGeometry", + "fills", + "strokes", + "strokeWeight", + "strokeAlign", + "strokeJoin", + "strokeDashes", + "strokeCap", + "strokeCapStart", + "strokeCapEnd", + "strokeWidthProfile", + "styles", +]; + +/// How wide a label without a saved snapshot is laid out, centred on its connector. +const LABEL_WIDTH: f64 = 200.0; + +/// The point halfway along `path` by length. +fn middle(path: &BezPath) -> Option { + let mut pts: Vec = Vec::new(); + kurbo::flatten(path.iter(), 0.25, |el| { + if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el { + pts.push(p); + } + }); + let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum(); + let mut left = total / 2.0; + for s in pts.windows(2) { + let l = (s[1] - s[0]).hypot(); + if l >= left && l > 0.0 { + return Some(s[0].lerp(s[1], left / l)); + } + left -= l; + } + pts.first().copied() +} diff --git a/crates/fig/src/rest/connector_snapshot_budget_tests.rs b/crates/fig/src/rest/connector_snapshot_budget_tests.rs new file mode 100644 index 000000000..4b8212b58 --- /dev/null +++ b/crates/fig/src/rest/connector_snapshot_budget_tests.rs @@ -0,0 +1,179 @@ +//! Connector snapshot flattening stays within the node and depth budgets. + +#![allow(clippy::unwrap_used, clippy::panic)] + +use super::{MAX_DEPTH, MAX_NODES, Mapper, Options, View}; +use crate::kiwi::Kv; +use kurbo::Affine; + +fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn guid(local_id: u64) -> Kv<'static> { + obj(vec![("sessionID", Kv::Uint(40_000_000)), ("localID", Kv::Uint(local_id))]) +} + +fn path_blob() -> Vec { + let mut bytes = vec![1]; + bytes.extend(0.0f32.to_le_bytes()); + bytes.extend(0.0f32.to_le_bytes()); + bytes.push(2); + bytes.extend(20.0f32.to_le_bytes()); + bytes.extend(10.0f32.to_le_bytes()); + bytes +} + +fn record(local_id: u64, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + fields.push(("guidPath", obj(vec![("guids", Kv::Array(vec![guid(local_id)]))]))); + obj(fields) +} + +fn message() -> Kv<'static> { + let line = record( + 0, + vec![ + ( + "transform", + obj(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(0.0)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]), + ), + ("size", obj(vec![("x", Kv::Float(20.0)), ("y", Kv::Float(10.0))])), + ("fillGeometry", Kv::Array(vec![obj(vec![("commandsBlob", Kv::Uint(0))])])), + ], + ); + let text = record( + 1, + vec![ + ( + "transform", + obj(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(0.0)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]), + ), + ("size", obj(vec![("x", Kv::Float(50.0)), ("y", Kv::Float(20.0))])), + ], + ); + let background = record( + 2, + vec![ + ( + "transform", + obj(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(0.0)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]), + ), + ("size", obj(vec![("x", Kv::Float(60.0)), ("y", Kv::Float(30.0))])), + ], + ); + let line_style = record(0, vec![]); + let text_style = record(1, vec![("textData", obj(vec![("characters", Kv::Str("budget label".into()))]))]); + let background_style = record(2, vec![("fillPaints", Kv::Array(vec![]))]); + obj(vec![ + ( + "nodeChanges", + Kv::Array(vec![obj(vec![ + ("guid", obj(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(123))])), + ("type", Kv::Enum("CONNECTOR")), + ("name", Kv::Str("budget connector".into())), + ("connectorType", Kv::Enum("MANUAL")), + ("connectorVersion", Kv::Float(1.0)), + ("connectorLineStyle", Kv::Enum("STRAIGHT")), + ("derivedImmutableFrameData", obj(vec![("version", Kv::Float(1.0)), ("overrides", Kv::Array(vec![line, text, background]))])), + ("nodeGenerationData", obj(vec![("overrides", Kv::Array(vec![line_style, text_style, background_style]))])), + ])]), + ), + ("blobs", Kv::Array(vec![obj(vec![("bytes", Kv::Bytes(path_blob()))])])), + ]) +} + +#[test] +fn connector_snapshot_respects_last_and_zero_child_slots() { + let message = message(); + let node = message.get("nodeChanges").unwrap().list().first().unwrap(); + let view = View::of(node); + let mut mapper = Mapper::new(&message, &Options::default()); + mapper.count = MAX_NODES - 1; + let children = mapper.connector_snapshot_children(&view, "1:123"); + assert_eq!(children.len(), 1, "the final available slot preserves the saved vector"); + assert_eq!(children[0]["type"], "VECTOR"); + assert!(!children[0]["fillGeometry"].as_array().unwrap().is_empty(), "saved outline survives the budget limit"); + assert_eq!(children[0]["fillGeometry"][0]["path"], "M 0 0 L 20 10"); + assert_eq!(mapper.count, MAX_NODES); + assert!(mapper.warnings.seen().any(|warning| warning == format!("the file has more than {MAX_NODES} layers; connector label was skipped"))); + + let mut mapper = Mapper::new(&message, &Options::default()); + mapper.count = MAX_NODES; + let children = mapper.connector_snapshot_children(&view, "1:123"); + assert!(children.is_empty()); + assert_eq!(mapper.count, MAX_NODES, "the node budget is never exceeded"); + assert!(mapper.warnings.seen().any(|warning| warning.contains("connector snapshot children were skipped"))); +} + +#[test] +fn connector_snapshot_flattens_vector_at_depth_and_node_limits() { + let message = message(); + let mut mapper = Mapper::new(&message, &Options::default()); + let depth_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); + assert_eq!(depth_limited["type"], "VECTOR"); + assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty()); + assert!(depth_limited.get("children").is_none()); + assert!(mapper.warnings.seen().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children")); + + let mut mapper = Mapper::new(&message, &Options::default()); + mapper.count = MAX_NODES - 1; + let budget_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, 0).expect("last allowed connector"); + assert_eq!(budget_limited["type"], "VECTOR"); + assert!(!budget_limited["fillGeometry"].as_array().unwrap().is_empty()); + assert!(budget_limited.get("children").is_none()); + assert_eq!(mapper.count, MAX_NODES); + assert!(mapper.warnings.seen().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children")); +} + +/// A mapper note is met once per layer, so the kinds were named once each with no sign of how much +/// of the file they covered: a file whose every layer lost a setting read the same as one where a +/// single layer did (#467). They are counted now, with the `(×n)` the REST importer already uses +/// and the import report already reads back off before translating a note. +#[test] +fn a_note_met_more_than_once_carries_its_count() { + let message = message(); + + let mut mapper = Mapper::new(&message, &Options::default()); + for _ in 0..2 { + mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); + } + let notes = std::mem::take(&mut mapper.warnings).finish(); + let counted: Vec<&String> = notes.iter().filter(|n| n.contains("(×")).collect(); + assert!( + counted.iter().any(|n| n.ends_with("(×2)") && n.contains("connector label was skipped: depth limit")), + "the note met twice says so: {notes:?}" + ); + assert!(notes.iter().all(|n| !n.ends_with("(×1)")), "a count of one is left off: {notes:?}"); + + // One layer, and the same notes have no count at all. + let mut mapper = Mapper::new(&message, &Options::default()); + mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); + let once = std::mem::take(&mut mapper.warnings).finish(); + assert!(once.iter().all(|n| !n.contains("(×")), "{once:?}"); + assert_eq!( + once.len(), + counted.len() + notes.iter().filter(|n| !n.contains("(×")).count(), + "the same kinds, counted or not: {once:?} vs {notes:?}" + ); +} diff --git a/crates/fig/src/rest/document.rs b/crates/fig/src/rest/document.rs new file mode 100644 index 000000000..752d7c9f0 --- /dev/null +++ b/crates/fig/src/rest/document.rs @@ -0,0 +1,333 @@ +use super::component::{assigned, prop_guid}; +use super::paint::color; +use super::variables::variable_value; +use super::{Affine, BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, rows, swap_guid}; + +impl<'a> Mapper<'a> { + /// The DOCUMENT with its pages. + pub(super) fn document(&mut self) -> Value { + let doc_id = self.nodes.iter().find(|(_, n)| n.get("type").and_then(Kv::str) == Some("DOCUMENT")).map(|(id, _)| id.clone()); + let pages: Vec = match &doc_id { + Some(d) => self.children(d), + None => self.nodes.iter().filter(|(_, n)| n.get("type").and_then(Kv::str) == Some("CANVAS")).map(|(id, _)| id.clone()).collect(), + }; + let mut internal = Vec::new(); + let mut visible = Vec::new(); + for p in pages { + let Some(&n) = self.nodes.get(&p) else { continue }; + if n.get("type").and_then(Kv::str) != Some("CANVAS") { + self.warn("document children other than pages were skipped"); + continue; + } + // The hidden page that holds styles, variables and library copies. + if n.get("internalOnly").and_then(Kv::bool) == Some(true) { + internal.push((p, n)); + } else { + visible.push((p, n)); + } + } + // The library copies the pages use come first: in a file past the layer cap, the pages' + // last layers are skipped rather than the mains every instance of them needs (#981). + let visible_pages: Vec = visible.iter().map(|(p, _)| p.clone()).collect(); + let required_mains = self.referenced_component_mains(&visible_pages); + // The pages and their top-level layers, up to half the cap, stay for the pages. + let top: usize = visible_pages.iter().map(|p| 1 + self.children(p).len()).sum(); + self.reserved = top.min(self.max_nodes / 2); + for (id, canvas) in internal { + if let Some(context) = self.library_context(&id, canvas, &required_mains) { + self.library_contexts.push(context); + } + } + self.reserved = 0; + let out: Vec = visible.into_iter().map(|(p, n)| self.page(&p, n)).collect(); + if out.is_empty() { + self.warn("the file has no pages"); + } + let mut doc = json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT"}); + doc["children"] = Value::Array(out); + doc + } + + /// The ids of the components the visible pages use, directly or through the components they + /// use in turn (nested instances, swap targets, overrides naming another main). + fn referenced_component_mains(&mut self, visible_pages: &[String]) -> BTreeSet { + let mut mains = BTreeSet::new(); + let mut visited = BTreeSet::new(); + let mut pending: Vec<(String, usize)> = visible_pages.iter().flat_map(|page| self.children(page)).map(|id| (id, 1)).collect(); + while let Some((id, depth)) = pending.pop() { + if depth > MAX_DEPTH { + self.warn(format!("component references below {MAX_DEPTH} layer levels were skipped")); + continue; + } + if !visited.insert(id.clone()) { + continue; + } + if visited.len() > self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the component reference scan reached the {max}-layer limit")); + break; + } + let Some(node) = self.nodes.get(&id) else { continue }; + for target in self.component_main_targets(node) { + if mains.insert(target.clone()) { + pending.push((target, 1)); + } + } + pending.extend(self.children(&id).into_iter().map(|child| (child, depth + 1))); + } + mains + } + + /// The mains a layer names: an instance's own, the ones its overrides swap in, the instance-swap + /// defaults and values of component properties. + fn component_main_targets(&self, node: &Kv<'a>) -> Vec { + let mut targets: Vec = node + .get("componentPropDefs") + .map(Kv::list) + .unwrap_or_default() + .iter() + .filter_map(|definition| { + self.is_instance_swap(definition.get("id")?)?; + definition.get("initialValue")?.get("guidValue").and_then(guid) + }) + .collect(); + if node.get("type").and_then(Kv::str) != Some("INSTANCE") { + return targets; + } + // The instance and the override rows below it (rows carry the same fields, nested). + let mut pending: Vec<(&Kv<'a>, usize)> = vec![(node, 0)]; + let mut rows = 0usize; + while let Some((row, depth)) = pending.pop() { + rows += 1; + if depth > MAX_DEPTH || rows > self.max_nodes { + break; + } + let data = row.get("symbolData"); + targets.extend(row.get("overriddenSymbolID").and_then(swap_guid)); + targets.extend(data.and_then(|d| d.get("symbolID")).and_then(guid)); + for assignment in row.get("componentPropAssignments").map(Kv::list).unwrap_or_default() { + if assignment.get("defID").is_some_and(|def| self.is_instance_swap(def).is_some()) { + targets.extend(assigned(assignment).and_then(prop_guid)); + } + } + let overrides = data.and_then(|d| d.get("symbolOverrides")).map(Kv::list).unwrap_or_default(); + let derived = row.get("derivedSymbolData").map(Kv::list).unwrap_or_default(); + pending.extend(overrides.iter().chain(derived).map(|row| (row, depth + 1))); + } + targets + } + + /// The branches of the internal canvas `id` that hold the `required_mains` (with the frames + /// they sit in), as a frame of its own: the cached definitions of components that come from a + /// library. `None` when it holds none. + fn library_context(&mut self, id: &str, canvas: &'a Kv<'a>, required_mains: &BTreeSet) -> Option { + let mut included = BTreeSet::new(); + let mut parents: BTreeMap = BTreeMap::new(); + // The canvas itself is never a layer of the walk, whatever the parent links say. + let mut visited = BTreeSet::from([id.to_string()]); + // Each entry: layer, depth, inside a required definition. + let mut pending: Vec<(String, usize, bool)> = self.children(id).into_iter().map(|child| (child, 1, false)).collect(); + while let Some((node_id, depth, in_definition)) = pending.pop() { + if depth > MAX_DEPTH { + self.warn(format!("internal library branches deeper than {MAX_DEPTH} levels were skipped")); + continue; + } + if !visited.insert(node_id.clone()) { + continue; + } + self.library_scan_count += 1; + if self.library_scan_count > self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the internal library scan reached the {max}-layer limit; the remaining branches were skipped")); + break; + } + let Some(node) = self.nodes.get(&node_id) else { continue }; + let kind = node.get("type").and_then(Kv::str); + let definition = (kind == Some("SYMBOL") || (kind == Some("FRAME") && node.get("isStateGroup").and_then(Kv::bool) == Some(true))) + && required_mains.contains(&node_id); + let in_definition = in_definition || definition; + if in_definition { + included.insert(node_id.clone()); + } + if definition { + // The frames around it keep the path its layers are addressed by. + let mut ancestor = parents.get(&node_id); + for _ in 0..=MAX_DEPTH { + let Some(parent) = ancestor else { break }; + if !included.insert(parent.clone()) { + break; + } + ancestor = parents.get(parent); + } + } + for child in self.children(&node_id).into_iter().rev() { + // A layer reached again keeps the parent it was first reached by, so the parents + // lead back to the canvas. + if visited.contains(&child) { + continue; + } + parents.entry(child.clone()).or_insert_with(|| node_id.clone()); + pending.push((child, depth + 1, in_definition)); + } + } + if included.is_empty() { + return None; + } + let mut context = Obj::new(); + context.insert("id".into(), json!(id)); + context.insert("type".into(), json!("FRAME")); + copy_str(&mut context, &View::of(canvas), "name", "name"); + context.insert("size".into(), json!({"x": 0.0, "y": 0.0})); + context.insert("relativeTransform".into(), rows(Affine::IDENTITY)); + context.insert(super::FIG_SOURCE_FIELD.into(), json!({"origin": id, "expanded": false, "wireFrame": true, "wireInstance": false})); + self.variable_bindings(&mut context, &View::of(canvas)); + let mut children = Vec::new(); + for child in self.children(id) { + children.extend(self.layer_filtered(&child, None, Affine::IDENTITY, None, None, 0, Some(&included))); + } + if !children.is_empty() { + context.insert("children".into(), Value::Array(children)); + } + Some(Value::Object(context)) + } + + fn page(&mut self, id: &str, n: &'a Kv<'a>) -> Value { + let v = View::of(n); + let mut o = Obj::new(); + o.insert("id".into(), json!(id)); + o.insert("type".into(), json!("CANVAS")); + copy_str(&mut o, &v, "name", "name"); + if let Some(c) = v.get("backgroundColor") { + o.insert("backgroundColor".into(), color(c)); + } + if let Some(c) = v.get("prototypeBackgroundColor") { + o.insert("prototypeBackgrounds".into(), json!([color(c)])); + } + self.variable_bindings(&mut o, &v); + let mut flows: Vec<(&str, Value)> = Vec::new(); + let mut kids = Vec::new(); + for c in self.children(id) { + if let Some(sp) = self.nodes.get(&c).and_then(|k| k.get("prototypeStartingPoint")) { + let name = sp.get("name").and_then(Kv::str).filter(|s| !s.is_empty()).unwrap_or("Flow"); + flows.push((sp.get("position").and_then(Kv::str).unwrap_or(""), json!({"nodeId": c, "name": name}))); + } + kids.extend(self.layer(&c, None, Affine::IDENTITY, None, None, 0)); + } + flows.sort_by(|a, b| a.0.cmp(b.0)); + if !flows.is_empty() { + o.insert("flowStartingPoints".into(), Value::Array(flows.into_iter().map(|f| f.1).collect())); + } + o.insert("children".into(), Value::Array(kids)); + Value::Object(o) + } + + /// The shared styles the file defines, in their order: each with the sample layer that holds + /// its values (`styleDefinitions`, our extension of the file JSON; the REST importer reads it). + pub(super) fn style_definitions(&mut self) -> Vec { + let mut found: Vec<(&'a str, String, &'a Kv<'a>)> = Vec::new(); + for (id, n) in &self.nodes { + let live = n.get("isSoftDeleted").and_then(Kv::bool) != Some(true) && n.get("isSoftDeletedStyle").and_then(Kv::bool) != Some(true); + if live && n.get("styleType").and_then(Kv::str).is_some_and(|t| matches!(t, "FILL" | "TEXT" | "EFFECT" | "GRID")) { + found.push((n.get("sortPosition").and_then(Kv::str).unwrap_or(""), id.clone(), n)); + } + } + found.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); + let mut out = Vec::new(); + for (_, id, n) in found { + let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, None, 0) else { break }; + let name = n.get("name").and_then(Kv::str).unwrap_or("Style"); + let desc = n.get("styleDescription").or_else(|| n.get("description")).and_then(Kv::str).unwrap_or(""); + let ty = n.get("styleType").and_then(Kv::str).unwrap_or(""); + out.push(json!({"id": id, "name": name, "description": desc, "styleType": ty, "node": node})); + } + out + } + + /// Collections and variables, as the local variables response's `meta`, with their ids in the + /// file's order (`collectionOrder` and `variableOrder`, our extensions). + pub(super) fn variables(&self) -> Option { + let of_type = |t: &'static str, key: &'static str| { + let live = move |n: &Kv| n.get("type").and_then(Kv::str) == Some(t) && n.get("isSoftDeleted").and_then(Kv::bool) != Some(true); + let mut list: Vec<(&str, &String, &'a Kv<'a>)> = self + .nodes + .iter() + .filter(|(_, n)| live(n)) + .map(|(id, n)| (n.get(key).and_then(|p| p.get("position").or(Some(p))).and_then(Kv::str).unwrap_or(""), id, *n)) + .collect(); + list.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1))); + list.into_iter().map(|(_, id, n)| (id, n)) + }; + let mut order = Vec::new(); + let mut collections = Obj::new(); + // Copies of one library collection (one per version the file met) become one: the newest + // keeps its modes in its order, the others add the modes it no longer has. + let mut extra: BTreeMap> = BTreeMap::new(); + for (id, n) in of_type("VARIABLE_SET", "parentIndex") { + let modes = sorted_modes(n); + match self.canonical_collection(id).filter(|c| c != id) { + Some(canonical) => extra.entry(canonical).or_default().extend(modes), + None => { + order.push(json!(id)); + let default = modes.first().map_or(Value::Null, |m| m.1["modeId"].clone()); + let name = n.get("name").and_then(Kv::str).unwrap_or("Collection"); + let modes: Vec = modes.into_iter().map(|m| m.1).collect(); + collections.insert(id.clone(), json!({"id": id, "name": name, "modes": modes, "defaultModeId": default})); + } + } + } + for (canonical, more) in extra { + let Some(modes) = collections.get_mut(&canonical).and_then(|c| c.get_mut("modes")).and_then(Value::as_array_mut) else { continue }; + for (_, m) in more { + if !modes.iter().any(|x| x["modeId"] == m["modeId"]) { + modes.push(m); + } + } + } + let mut variable_order = Vec::new(); + let mut variables = Obj::new(); + for (id, n) in of_type("VARIABLE", "sortPosition") { + if super::VarCanon::superseded(id) { + continue; + } + let Some(set) = n.get("variableSetID").and_then(|s| self.collection_id(s)).filter(|s| collections.contains_key(s)) else { continue }; + let ty = n.get("variableResolvedType").and_then(Kv::str).unwrap_or(""); + if !matches!(ty, "BOOLEAN" | "FLOAT" | "STRING" | "COLOR") { + continue; + } + let mut values = Obj::new(); + for e in n.get("variableDataValues").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() { + if let (Some(mode), Some(val)) = (e.get("modeID").and_then(guid), e.get("variableData").and_then(variable_value)) { + values.insert(mode, val); + } + } + let name = n.get("name").and_then(Kv::str).unwrap_or("Variable"); + let desc = n.get("description").and_then(Kv::str).unwrap_or(""); + let mut var = + json!({"id": id, "name": name, "variableCollectionId": set, "resolvedType": ty, "valuesByMode": values, "description": desc}); + // The properties pickers offer the variable for, under the REST names. + if let Some(scopes) = n.get("variableScopes") { + var["scopes"] = scopes.list().iter().filter_map(Kv::str).map(|s| if s == "STROKE" { "STROKE_COLOR" } else { s }).collect(); + } + variable_order.push(json!(id)); + variables.insert(id.clone(), var); + } + (!collections.is_empty()) + .then(|| json!({"variableCollections": collections, "variables": variables, "collectionOrder": order, "variableOrder": variable_order})) + } +} + +/// A collection node's modes in their order (`sortPosition`), as `(position, {modeId, name})`. +fn sorted_modes(n: &Kv) -> Vec<(String, Value)> { + let mut modes: Vec<(String, Value)> = n + .get("variableSetModes") + .map(Kv::list) + .unwrap_or_default() + .iter() + .filter_map(|m| { + let mode = json!({"modeId": m.get("id").and_then(guid)?, "name": m.get("name").and_then(Kv::str).unwrap_or("Mode")}); + Some((m.get("sortPosition").and_then(Kv::str).unwrap_or("").to_string(), mode)) + }) + .collect(); + modes.sort_by(|a, b| a.0.cmp(&b.0)); + modes +} diff --git a/crates/fig/src/rest/geometry.rs b/crates/fig/src/rest/geometry.rs new file mode 100644 index 000000000..8091a5de0 --- /dev/null +++ b/crates/fig/src/rest/geometry.rs @@ -0,0 +1,107 @@ +//! Geometry the caller owns: how a connector is routed between its ends, and whether a text path +//! can be measured by the caller's text layout. The mapping reads where a connector's ends are and +//! how the file says it runs; the route between them is the caller's (an editor routes its own +//! connectors the same way, so an imported one looks like one drawn there). + +use kurbo::{Point, Rect, Vec2}; + +/// Most runs of a saved elbow path ([`SavedRuns`]) the mapping reads. +pub const MAX_RUNS: usize = 64; + +/// How a connector runs between its ends (`connectorLineStyle`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Routing { + #[default] + Straight, + /// Right angles only (`ELBOWED`). + Elbow, + /// One smooth curve (`CURVED`). + Curved, +} + +/// The side of a layer a connector end attaches to (at its middle). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Side { + /// The side facing the other end. + #[default] + Auto, + Top, + Right, + Bottom, + Left, +} + +impl Side { + /// The outward normal of the side (zero for [`Side::Auto`]). + pub fn normal(self) -> Vec2 { + match self { + Side::Auto => Vec2::ZERO, + Side::Top => Vec2::new(0.0, -1.0), + Side::Right => Vec2::new(1.0, 0.0), + Side::Bottom => Vec2::new(0.0, 1.0), + Side::Left => Vec2::new(-1.0, 0.0), + } + } +} + +/// A connector end, in the coordinates of the connector's parent. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum RouteEnd { + /// A free point, or a point on a layer the line goes straight to. + Point(Point), + /// A layer's bounding box and the side the end leaves from. + Box(Rect, Side), +} + +/// The path a FigJam elbow connector was saved with: the runs between the first and the last, +/// alternating direction. `across`: the first saved run goes across (horizontally); `at`: each +/// saved run's offset from the middle of the two attach points (y for a run across, x for one +/// down). +#[derive(Clone, Debug, PartialEq)] +pub struct SavedRuns { + pub across: bool, + pub at: Vec, +} + +/// The caller's geometry. +pub trait Geometry { + /// The points of the line from `from` to `to`: a polyline, or for [`Routing::Curved`] the + /// start, two control points and the end. `saved`: an elbow's saved path, when it has one + /// that fits. `None` when the ends can't be joined. + fn route(&self, from: RouteEnd, to: RouteEnd, routing: Routing, saved: Option<&SavedRuns>) -> Option>; + + /// Where the line leaves `from` and arrives at `to`, and the sides it attaches to (`None` for + /// a free point). + fn attach(&self, from: RouteEnd, to: RouteEnd) -> ((Point, Option), (Point, Option)); + + /// Whether the caller's text layout can measure text along the SVG path `d`. A text path it + /// can't is left out with a warning. + fn text_path_measurable(&self, _d: &str) -> bool { + true + } +} + +/// Geometry without routing of its own: straight lines between the ends (a box's end at the +/// middle of its side, or at its centre for [`Side::Auto`]), elbows and curves included, and +/// every text path accepted. +#[derive(Clone, Copy, Debug, Default)] +pub struct Straight; + +impl Geometry for Straight { + fn route(&self, from: RouteEnd, to: RouteEnd, _: Routing, _: Option<&SavedRuns>) -> Option> { + let ((a, _), (b, _)) = self.attach(from, to); + Some(vec![a, b]) + } + + fn attach(&self, from: RouteEnd, to: RouteEnd) -> ((Point, Option), (Point, Option)) { + let at = |e: RouteEnd| match e { + RouteEnd::Point(p) => (p, None), + RouteEnd::Box(b, Side::Auto) => (b.center(), Some(Side::Auto)), + RouteEnd::Box(b, s) => { + let n = s.normal(); + (b.center() + Vec2::new(n.x * b.width() / 2.0, n.y * b.height() / 2.0), Some(s)) + } + }; + (at(from), at(to)) + } +} diff --git a/crates/fig/src/rest/image_filter_tests.rs b/crates/fig/src/rest/image_filter_tests.rs new file mode 100644 index 000000000..b7e53c8b0 --- /dev/null +++ b/crates/fig/src/rest/image_filter_tests.rs @@ -0,0 +1,39 @@ +//! Malformed image paint filters are skipped with a warning, and image hashes too long to name +//! an image aren't spelled out. + +#![allow(clippy::unwrap_used, clippy::panic)] + +use super::*; + +#[test] +fn malformed_non_finite_filter_is_skipped_with_warning() { + let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![("exposure", Kv::Float(f32::NAN))]))]); + let mut warnings = Vec::new(); + let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap(); + assert!(value.get("filters").is_none(), "{value}"); + assert!(warnings.iter().any(|warning| warning.contains("malformed image filter `exposure`")), "{warnings:?}"); +} + +#[test] +fn boolean_filter_values_are_malformed_even_when_false() { + for (field, value, expected_warning) in [ + ("exposure", Kv::Bool(true), "malformed image filter `exposure`"), + ("exposure", Kv::Bool(false), "malformed image filter `exposure`"), + ("mystery", Kv::Bool(false), "malformed unsupported image filter `mystery`"), + ] { + let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![(field, value)]))]); + let mut warnings = Vec::new(); + let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap(); + assert!(value.get("filters").is_none(), "{value}"); + assert!(warnings.iter().any(|warning| warning.contains(expected_warning)), "{warnings:?}"); + } +} + +#[test] +fn an_oversized_image_hash_is_not_spelled_out() { + let paint = |len: usize| Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("image", Kv::Object(vec![("hash", Kv::Bytes(vec![0xab; len]))]))]); + let image_ref = |len: usize| super::paint(&paint(len), &mut |_| {}).and_then(|p| p.get("imageRef").cloned()); + assert_eq!(image_ref(20), Some(Value::String("ab".repeat(20)))); + assert_eq!(image_ref(crate::MAX_IMAGE_HASH), Some(Value::String("ab".repeat(crate::MAX_IMAGE_HASH)))); + assert_eq!(image_ref(crate::MAX_IMAGE_HASH + 1), Some(Value::String(String::new()))); +} diff --git a/crates/fig/src/rest/layer.rs b/crates/fig/src/rest/layer.rs new file mode 100644 index 000000000..370e5017b --- /dev/null +++ b/crates/fig/src/rest/layer.rs @@ -0,0 +1,938 @@ +use std::borrow::Cow; + +use super::component::{Entry, Inst, OverrideRow, prop_ref}; +use super::connector::{ConnectorShape, connector_has_saved_label}; +use super::layout::{layout_grid, tracks}; +use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke}; +use super::{ + Affine, BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Value, View, copy_len, copy_num, copy_str, generated_record, guid, json, matrix, network, + num, rows, scaled, size, +}; + +/// How the layers of a resized container follow it when the file gives no derived layout for +/// them: a frame's by their constraints, from its main's size to its own; a group's scale with it. +#[derive(Clone, Copy)] +pub(super) enum Fit { + Frame { from: (f64, f64), to: (f64, f64) }, + Scale(f64, f64), +} + +impl Fit { + fn frame(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option { + let (from, to) = (from?, to?); + ((from.0 - to.0).abs() > 1e-9 || (from.1 - to.1).abs() > 1e-9).then_some(Fit::Frame { from, to }) + } + + fn scale(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option { + let (from, to) = (from?, to?); + let k = |a: f64, b: f64| if a > 1e-9 { b / a } else { 1.0 }; + let (kx, ky) = (k(from.0, to.0), k(from.1, to.1)); + ((kx - 1.0).abs() > 1e-9 || (ky - 1.0).abs() > 1e-9).then_some(Fit::Scale(kx, ky)) + } + + /// The size and transform (in the parent's coordinates) of a `w` × `h` layer at `t` with + /// constraints `h_con`, `v_con`. Like the editor's constraint solver, a layer keeps its + /// rotation and flip and stretches along its own axes. + fn apply(self, w: f64, h: f64, t: Affine, h_con: Option<&str>, v_con: Option<&str>) -> ((f64, f64), Affine) { + let [a, b, c, d, _, _] = t.as_coeffs(); + let lin = Affine::new([a, b, c, d, 0.0, 0.0]); + let bb = super::bbox(t, kurbo::Rect::new(0.0, 0.0, w, h)); + // Where the new box goes: its top-left (constraints) or its centre (a group's scale). + let (kx, ky, x0, y0, centred) = match self { + Fit::Frame { from, to } => { + let axis = |con: Option<&str>, lo: f64, hi: f64, old: f64, new: f64| match con { + Some("MAX" | "FIXED_MAX") => (lo + new - old, hi + new - old), + Some("STRETCH") => (lo, hi + new - old), + Some("CENTER") => (lo + (new - old) / 2.0, hi + (new - old) / 2.0), + Some("SCALE") if old > 1e-9 => (lo * new / old, hi * new / old), + _ => (lo, hi), + }; + let (x0, x1) = axis(h_con, bb.x0, bb.x1, from.0, to.0); + let (y0, y1) = axis(v_con, bb.y0, bb.y1, from.1, to.1); + let k = |new: f64, old: f64| if old > 1e-9 { (new / old).max(0.0) } else { 1.0 }; + (k(x1 - x0, bb.width()), k(y1 - y0, bb.height()), x0, y0, false) + } + Fit::Scale(kx, ky) => (kx, ky, bb.center().x * kx, bb.center().y * ky, true), + }; + let along = |x: f64, y: f64, fallback: f64| { + let len = x.hypot(y); + let k = (kx * x).hypot(ky * y) / len; + if len > 1e-12 && k.is_finite() { k } else { fallback } + }; + let size = (w * along(a, b, kx), h * along(c, d, ky)); + let nb = super::bbox(lin, kurbo::Rect::new(0.0, 0.0, size.0, size.1)); + let at = if centred { nb.center() } else { kurbo::Point::new(nb.x0, nb.y0) }; + (size, Affine::translate((x0 - at.x, y0 - at.y)) * lin) + } +} + +/// What a layer's children are mapped from: the ids of the layers, the instance they are +/// expanded in, the view they see as their parent (`None`: the layer's own), their space and fit, +/// and whether the caller's filter applies to them. +struct Plan<'i, 'a> { + ids: Vec, + inst: Option>>, + view: Option>, + space: Affine, + fit: Option, + filtered: bool, +} + +/// A layer mapped but for its children ([`Mapper::layer_head`]). +struct Head<'i, 'a> { + o: Obj, + view: View<'a>, + own: &'a str, + rest_id: String, + stand: Option<(&'static str, &'static str)>, + /// Children made before the mapped ones (a connector snapshot's). + kids: Vec, + /// Children that go before all others (a connector's line and label). + connector_kids: Vec, + plan: Option>>, +} + +impl<'a> Mapper<'a> { + /// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the + /// parent's coordinates to the enclosing frame's (groups and booleans place their children in + /// the frame's space, like the REST JSON). `parent`: the parent's view (grid tracks). + pub(crate) fn layer( + &mut self, + id: &str, + inst: Option<&Inst<'a>>, + space: Affine, + parent: Option<&View<'a>>, + fit: Option, + depth: usize, + ) -> Option { + self.layer_filtered(id, inst, space, parent, fit, depth, None) + } + + /// [`Self::layer`], keeping only the layers in `included` (all when `None`) below the + /// top-level one: the cached library definitions of an internal canvas. + /// + /// The recursion through the layer tree runs through this function, which keeps its frame + /// small: the work on one layer is done in [`Self::layer_head`] and [`Self::layer_tail`], which + /// return before and after its children are mapped, so a deep file needs little stack per level. + #[allow(clippy::too_many_arguments)] // as many as `layer` takes, plus the filter + pub(super) fn layer_filtered( + &mut self, + id: &str, + inst: Option<&Inst<'a>>, + space: Affine, + parent: Option<&View<'a>>, + fit: Option, + depth: usize, + included: Option<&BTreeSet>, + ) -> Option { + let mut head = self.layer_head(id, inst, space, parent, fit, depth, included)?; + if let Some(plan) = head.plan.take() { + let view = plan.view.as_ref().unwrap_or(&head.view); + for c in &plan.ids { + let filter = if plan.filtered { included } else { None }; + let kid = self.layer_filtered(c, plan.inst.as_deref(), plan.space, Some(view), plan.fit, depth + 1, filter); + head.kids.extend(kid); + } + } + Some(self.layer_tail(head, depth)) + } + + /// A layer's own fields, and the plan its children are mapped by. + #[inline(never)] + #[allow(clippy::too_many_arguments)] // as `layer_filtered` + fn layer_head<'i>( + &mut self, + id: &str, + inst: Option<&'i Inst<'a>>, + space: Affine, + parent: Option<&View<'a>>, + fit: Option, + depth: usize, + included: Option<&BTreeSet>, + ) -> Option>> { + if included.is_some_and(|ids| !ids.contains(id)) { + return None; + } + if depth > MAX_DEPTH { + self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); + return None; + } + if self.count >= self.budget() { + let max = self.max_nodes; + self.warn(format!("the file has more than {max} layers; the rest were skipped")); + return None; + } + let base = *self.nodes.get(id)?; + let path = inst.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()); + let entries: &[OverrideRow<'a>] = match (inst, &path) { + (Some(i), Some(p)) => i.overrides.get(p).map(Vec::as_slice).unwrap_or_default(), + _ => &[], + }; + // Inside a scaled instance the main's lengths are the factor times as long; the rows of + // the instance are at its scale already (#2091). + let scale = inst.map_or(1.0, |i| i.scale); + let mut view = View::of_scaled(base, scale).with(entries.iter().map(|e| (e.row, e.scale))); + // A layer of a resized instance the file gives no derived layout for follows its + // constraints from the main's geometry, then takes the size and place overridden on it + // (auto layout places the others, as in the editor). + let derived = entries.iter().any(|e| e.entry == Entry::Derived && (e.row.get("size").is_some() || e.row.get("transform").is_some())); + let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID")) + && view.str("stackPositioning") != Some("ABSOLUTE"); + let main = View::of_scaled(base, scale); + // The size and place a row gives, at the scale of the row (a nested instance's own rows + // are at its enclosing main's scale). + let set = |k: &str| entries.iter().rev().filter(|e| e.entry != Entry::Stale).find_map(|e| e.row.get(k).map(|r| (r, e.scale))); + let set_size = || { + let (r, k) = set("size")?; + let (x, y) = size(r)?; + Some((scaled(x, k)?, scaled(y, k)?)) + }; + let set_transform = || { + let (r, k) = set("transform")?; + let m = matrix(r)?; + let [a, b, c, d, e, f] = m.as_coeffs(); + Some(Affine::new([a, b, c, d, scaled(e, k)?, scaled(f, k)?])) + }; + if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) { + let t = main.transform().unwrap_or_default(); + let (fw, ft) = f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint")); + view.fitted = Some((set_size().unwrap_or(fw), set_transform().unwrap_or(ft))); + } else if entries.iter().any(|e| e.entry == Entry::Stale && (e.row.get("size").is_some() || e.row.get("transform").is_some())) + && let Some(s) = set_size().or_else(|| main.size()) + { + view.fitted = Some((s, set_transform().or_else(|| main.transform()).unwrap_or_default())); + } + let rest_id = match (inst, &path) { + (Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")), + _ => id.to_string(), + }; + let own = view.str("type").unwrap_or(""); + let connector_snapshot = own == "CONNECTOR" && self.connector_snapshot_supported(&view); + // Only a connector with a saved label needs the frame wrapper; an unlabelled one stays a single vector. + let connector_terminal = + connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget()); + // A supported connector snapshot has its own note below instead of the generic stand-in one. + let stand = stand_in(own).filter(|_| !connector_snapshot); + // A label without a snapshot: the connector becomes a frame holding its line and its label. + let connector_label = own == "CONNECTOR" && !connector_snapshot && self.connector_label_fits(&view, depth); + let ty = if connector_label { "FRAME" } else { Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)? }; + if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") { + self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); + } + if connector_snapshot { + self.warn("connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained"); + } + self.count += 1; + if own == "SYMBOL" && inst.is_none() { + self.mapped_mains.insert(id.to_string()); + } + let mut o = Obj::new(); + o.insert("id".into(), json!(rest_id)); + // Private provenance for the .fig importer; ordinary REST import ignores it. + o.insert( + super::FIG_SOURCE_FIELD.into(), + json!({ + "origin": id, + "expanded": inst.is_some(), + "wireFrame": own == "FRAME", + "wireInstance": own == "INSTANCE", + "slotContent": inst.is_some_and(|i| i.slot_content), + // A size the instance gives this layer itself (kept when the instance relays out). + "sizeOverride": entries.iter().any(|e| e.entry == Entry::Override && e.row.get("size").is_some()), + }), + ); + o.insert("type".into(), json!(ty)); + copy_str(&mut o, &view, "name", "name"); + self.common(&mut o, &view, ty, space, parent); + match ty { + "TEXT" => { + self.text(&mut o, &view); + if own == "TEXT_PATH" { + self.text_path(&mut o, &view); + } + } + "VECTOR" | "STAR" | "REGULAR_POLYGON" => self.geometry(&mut o, &view), + "ELLIPSE" => { + if let Some(a) = view.get("arcData") { + let arc: Obj = + ["startingAngle", "endingAngle", "innerRadius"].iter().filter_map(|k| Some((k.to_string(), json!(num(a, k)?)))).collect(); + o.insert("arcData".into(), Value::Object(arc)); + } + } + "BOOLEAN_OPERATION" => { + if let Some(op) = view.str("booleanOperation") { + o.insert("booleanOperation".into(), json!(if op == "XOR" { "EXCLUDE" } else { op })); + } + } + _ => {} + } + let connector_kids = if own == "CONNECTOR" { + let shape = ConnectorShape { snapshot: connector_snapshot, terminal: connector_terminal, label: connector_label }; + self.connector_layer(&mut o, &view, &rest_id, space, shape) + } else { + Vec::new() + }; + if ty == "COMPONENT" || ty == "COMPONENT_SET" { + self.component(&mut o, &view, &rest_id); + } + self.prop_refs(&mut o, &view, inst); + let slot_content = if own == "FRAME" { + prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&self.prop_root(&def)).cloned())) + } else { + None + }; + if slot_content.is_some() + && let Some(source) = o.get_mut(super::FIG_SOURCE_FIELD).and_then(Value::as_object_mut) + { + source.insert("slotOverride".into(), json!(true)); + } + let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY }; + let kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(&view, &rest_id) } else { Vec::new() }; + let plan = if connector_terminal { + // The saved vector is flattened into the connector node when no child slot remains. + None + } else if ty == "INSTANCE" { + self.instance(&mut o, &view, id, inst, &rest_id).map(|(sub, sym)| { + let was = base.get("symbolData").and_then(|s| s.get("symbolID")).and_then(guid); + // A layer that records no main (a frame turned into an instance) has no old look to replace. + if let Some(was) = was.filter(|w| *w != sym) + && let Some(new_main) = self.nodes.get(&sym).copied() + { + let old = self.nodes.get(&was).copied(); + self.swapped_look(&mut o, &view, base, old, new_main, sub.scale, ty); + } + let main = self.nodes.get(&sym).map(|m| View::of_scaled(m, sub.scale)); + // A scaled main's size is the factor's product, rounded to a float: it matches an + // instance of that size without a fit that would round the layers again. + let near = |a: f64, b: f64| (a - b).abs() <= 1e-4 * a.abs().max(b.abs()).max(1.0); + let fit = match (main.as_ref().and_then(View::size), view.size()) { + (Some(a), Some(b)) if sub.scale != 1.0 && near(a.0, b.0) && near(a.1, b.1) => None, + (a, b) => Fit::frame(a, b), + }; + // An instance that records no auto layout of its own has its main's. + let child_view = match main { + Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => { + let layered = m.over(&view); + self.auto_layout(&mut o, &layered); + Some(layered) + } + _ => None, + }; + // The main's branch can live outside the cached context being mapped. + Box::new(Plan { ids: self.children(&sym), inst: Some(Cow::Owned(sub)), view: child_view, space: child_space, fit, filtered: false }) + }) + } else if let Some(content) = slot_content { + let mut content_inst = inst.cloned(); + if let Some(content_inst) = &mut content_inst { + content_inst.slot_content = true; + } + Some(Box::new(Plan { + ids: self.children(&content), + inst: content_inst.map(Cow::Owned), + view: None, + space: child_space, + fit: None, + filtered: true, + })) + } else { + // Inside an instance a layer's children are its main's: they follow it from the main's + // size to its own. + let fit = match (inst, ty) { + (None, _) => None, + (Some(i), "GROUP" | "BOOLEAN_OPERATION") => Fit::scale(View::of_scaled(base, i.scale).size(), view.size()), + (Some(i), _) => Fit::frame(View::of_scaled(base, i.scale).size(), view.size()), + }; + Some(Box::new(Plan { ids: self.children(id), inst: inst.map(Cow::Borrowed), view: None, space: child_space, fit, filtered: true })) + }; + Some(Box::new(Head { o, view, own, rest_id, stand, kids, connector_kids, plan })) + } + + /// A layer with its mapped children ([`Self::layer_head`]). + #[inline(never)] + fn layer_tail(&mut self, head: Box>, depth: usize) -> Value { + let Head { mut o, view, own, rest_id, stand, mut kids, connector_kids, .. } = *head; + kids.splice(0..0, connector_kids); + if stand.is_some_and(|s| s.0 == "FRAME") { + if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) { + kids.splice(0..0, snapshot); + } else { + kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id)); + kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY")); + } + } + if !kids.is_empty() { + o.insert("children".into(), Value::Array(kids)); + } + Value::Object(o) + } + + fn mapped_type<'s>( + own: &'s str, + view: &View<'a>, + connector_snapshot: bool, + connector_terminal: bool, + stand: Option<(&'static str, &'static str)>, + ) -> Option<&'s str> { + match own { + "ROUNDED_RECTANGLE" => Some("RECTANGLE"), + "SYMBOL" => Some("COMPONENT"), + "FRAME" if view.flag("isStateGroup") == Some(true) => Some("COMPONENT_SET"), + "FRAME" if view.flag("resizeToFit") == Some(true) => Some("GROUP"), + "VARIABLE" | "VARIABLE_SET" => None, + "CONNECTOR" if connector_snapshot && connector_terminal => Some("VECTOR"), + "CONNECTOR" if connector_snapshot => Some("FRAME"), + t => Some(stand.map_or(t, |s| s.0)), + } + } + + /// FigJam notes and shapes keep their paints and text on generated template children, + /// not on the outer node. Keep the saved layout as ordinary editable layers (including a + /// note's author), without applying the background's opacity or transform to the wrapper. + /// When no generated body survives, retain usable outer text alongside successful children. + fn board_snapshot(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str, depth: usize) -> Option> { + if !matches!(own, "STICKY" | "SHAPE_WITH_TEXT") || v.get("nodeGenerationData").is_none() { + return None; + } + if v.get("derivedImmutableFrameData").and_then(|d| num(d, "version")) != Some(1.0) { + self.warn("note or shape generated layout is missing or unsupported; using its outer frame"); + return None; + } + let mut children = Vec::new(); + let mut has_body = false; + for local_id in 0..if own == "STICKY" { 3 } else { 2 } { + let style = generated_record(v, "nodeGenerationData", local_id); + let bounds = generated_record(v, "derivedImmutableFrameData", local_id); + let (Some(style), Some(bounds)) = (style, bounds) else { + if style.is_some() || bounds.is_some() { + self.warn("note or shape generated child is missing its style or bounds; child skipped"); + } + continue; + }; + let child = View::of(style).with([(bounds, 1.0)]); + let chars = own_text(&child); + if local_id != 0 && chars.is_none() { + continue; + } + if valid_size(child.get("size")).is_none() || child.transform().is_none() { + self.warn("note or shape generated child has invalid bounds; child skipped"); + continue; + } + if self.count >= self.budget() || depth >= MAX_DEPTH { + self.warn("note or shape generated children exceed the layer or nesting limit; remaining children skipped"); + break; + } + let mut layer = Obj::new(); + let ty = if local_id == 0 { "RECTANGLE" } else { "TEXT" }; + layer.insert("id".into(), json!(format!("{rest_id};generated{local_id}"))); + layer.insert("type".into(), json!(ty)); + layer.insert("name".into(), json!(chars.map_or_else(|| "Background".into(), |c| c.chars().take(40).collect::()))); + self.common(&mut layer, &child, ty, Affine::IDENTITY, None); + if local_id == 0 { + // Network decoding is only enabled for vectors; choose that type before + // geometry sees a saved network, then restore the rectangle if it is unusable. + if child.get("vectorData").is_some() { + layer.insert("type".into(), json!("VECTOR")); + } + self.geometry(&mut layer, &child); + let outlined = |layer: &Obj| ["fillGeometry", "strokeGeometry", "vectorNetworkRef"].iter().any(|key| layer.contains_key(*key)); + if own == "SHAPE_WITH_TEXT" && !outlined(&layer) { + self.geometry(&mut layer, v); + } + if outlined(&layer) { + layer.insert("type".into(), json!("VECTOR")); + } else { + layer.insert("type".into(), json!("RECTANGLE")); + if own == "SHAPE_WITH_TEXT" && !matches!(v.str("shapeWithTextType"), None | Some("SQUARE" | "ROUNDED_RECTANGLE")) { + self.warn("shape generated outline is missing or damaged; using its rectangle"); + } + } + o.insert("fills".into(), json!([])); + o.insert("strokes".into(), json!([])); + } else { + self.text(&mut layer, &child); + if let Some(Value::Object(style)) = layer.get_mut("style") { + style.insert("textAutoResize".into(), json!("NONE")); + } + } + self.count += 1; + has_body |= local_id == 1; + children.push(Value::Object(layer)); + } + if depth < MAX_DEPTH && !has_body { + children.extend(self.inner_text(o, v, rest_id, own == "STICKY")); + } + Some(children) + } + + /// A non-rectangular shape-with-text's shape, as a vector under its frame (which gives up its + /// paints to it). + fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option { + let shape = v.str("shapeWithTextType").unwrap_or("SQUARE"); + let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty(); + if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.budget() { + return None; + } + let mut s = Obj::new(); + s.insert("id".into(), json!(format!("{rest_id};shape"))); + s.insert("type".into(), json!("VECTOR")); + s.insert("name".into(), json!(shape.to_ascii_lowercase().replace('_', " "))); + for k in ["size", "strokeWeight", "strokeAlign", "strokeJoin", "strokeDashes"] { + if let Some(x) = o.get(k) { + s.insert(k.into(), x.clone()); + } + } + for k in ["fills", "strokes"] { + s.insert(k.into(), o.insert(k.into(), json!([])).unwrap_or_else(|| json!([]))); + } + s.insert("relativeTransform".into(), rows(Affine::IDENTITY)); + self.geometry(&mut s, v); + if !s.contains_key("fillGeometry") && !s.contains_key("strokeGeometry") { + // Damaged outlines: the frame keeps its paints and box. + for k in ["fills", "strokes"] { + o.insert(k.into(), s.remove(k).unwrap_or_else(|| json!([]))); + } + return None; + } + self.count += 1; + Some(Value::Object(s)) + } + + /// The text a stand-in frame holds (a sticky note's, a shape's, a table cell's) as a text + /// layer filling it, inset a little; centred unless it's a note. + fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option { + let chars = own_text(v)?; + if self.count >= self.budget() { + return None; + } + self.count += 1; + let size = |k: &str| o.get("size").and_then(|s| s.get(k)).and_then(Value::as_f64).unwrap_or(0.0); + let (w, h) = (size("x"), size("y")); + let pad = (w.min(h) / 4.0).clamp(0.0, 16.0); + let mut t = Obj::new(); + t.insert("id".into(), json!(format!("{rest_id};text"))); + t.insert("type".into(), json!("TEXT")); + t.insert("name".into(), json!(chars.chars().take(40).collect::())); + t.insert("size".into(), json!({"x": (w - 2.0 * pad).max(1.0), "y": (h - 2.0 * pad).max(1.0)})); + t.insert("relativeTransform".into(), rows(Affine::translate((pad, pad)))); + self.text(&mut t, v); + if let Some(Value::Object(st)) = t.get_mut("style") { + let (hor, ver) = if note { ("LEFT", "TOP") } else { ("CENTER", "CENTER") }; + st.entry("textAlignHorizontal").or_insert_with(|| json!(hor)); + st.entry("textAlignVertical").or_insert_with(|| json!(ver)); + st.insert("textAutoResize".into(), json!("NONE")); + } + Some(Value::Object(t)) + } + + /// The look a layer draws: paints with their styles, effects, grids, stroke and corners. + pub(super) fn look(&mut self, o: &mut Obj, v: &View<'a>, ty: &str) { + // Paints, from the shared style when one is linked (the layer's own copy can be stale), + // unless the layer's own paints are bound to variables: those bindings must not be lost. + let mut styles = Obj::new(); + let own = |k: &str| Some(v.list(k)).filter(|l| l.iter().any(paint_bound)); + let fill_style = self.style(v, "styleIdForFill", "inheritFillStyleID", "fill", &mut styles); + let fills = own("fillPaints") + .unwrap_or_else(|| fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); + o.insert("fills".into(), Value::Array(fills.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect())); + let stroke_style = self.style(v, "styleIdForStrokeFill", "inheritFillStyleIDForStroke", "stroke", &mut styles); + let strokes = own("strokePaints") + .unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); + o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect())); + let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles); + let (effects, effects_scale) = + effect_style.map_or_else(|| v.list_scaled("effects"), |s| v.styled_list(s, "effects", "styleIdForEffect", "inheritEffectStyleID")); + if !effects.is_empty() { + o.insert("effects".into(), Value::Array(effects.iter().filter_map(|e| effect(e, effects_scale)).collect())); + } + let grid_style = self.style(v, "styleIdForGrid", "inheritGridStyleID", "grid", &mut styles); + let (grids, grids_scale) = + grid_style.map_or_else(|| v.list_scaled("layoutGrids"), |s| v.styled_list(s, "layoutGrids", "styleIdForGrid", "inheritGridStyleID")); + if !grids.is_empty() { + o.insert("layoutGrids".into(), Value::Array(grids.iter().map(|g| layout_grid(g, grids_scale)).collect())); + } + if ty == "TEXT" { + self.style(v, "styleIdForText", "inheritTextStyleID", "text", &mut styles); + } + if !styles.is_empty() { + o.insert("styles".into(), Value::Object(styles)); + } + stroke(o, v, self.outlined_stroke); + copy_len(o, v, "cornerRadius", "cornerRadius"); + copy_num(o, v, "cornerSmoothing", "cornerSmoothing"); + if v.flag("rectangleCornerRadiiIndependent") == Some(true) { + let r = |k: &str| v.len(k).unwrap_or(0.0); + o.insert( + "rectangleCornerRadii".into(), + json!([ + r("rectangleTopLeftCornerRadius"), + r("rectangleTopRightCornerRadius"), + r("rectangleBottomRightCornerRadius"), + r("rectangleBottomLeftCornerRadius") + ]), + ); + } + } + + /// Fields every layer type shares. + pub(super) fn common(&mut self, o: &mut Obj, v: &View<'a>, ty: &str, space: Affine, parent: Option<&View<'a>>) { + for (from, to) in [("visible", "visible"), ("locked", "locked"), ("mask", "isMask")] { + if let Some(b) = v.flag(from) { + o.insert(to.into(), json!(b)); + } + } + if let Some(m) = v.str("maskType") { + o.insert("maskType".into(), json!(if m == "OUTLINE" { "VECTOR" } else { m })); + } + copy_num(o, v, "opacity", "opacity"); + if let Some(b) = v.str("blendMode").filter(|b| *b != "UNSET") { + o.insert("blendMode".into(), json!(b)); + } + if let Some((x, y)) = v.size() { + o.insert("size".into(), json!({"x": x, "y": y})); + } + let t = v.transform().unwrap_or_default(); + o.insert("relativeTransform".into(), rows(space * t)); + self.look(o, v, ty); + let constraint = |k: &str, min: &'static str, max: &'static str, both: &'static str| match v.str(k) { + Some("CENTER") => "CENTER", + Some("MAX" | "FIXED_MAX") => max, + Some("STRETCH") => both, + Some("SCALE") => "SCALE", + _ => min, + }; + o.insert( + "constraints".into(), + json!({ + "horizontal": constraint("horizontalConstraint", "LEFT", "RIGHT", "LEFT_RIGHT"), + "vertical": constraint("verticalConstraint", "TOP", "BOTTOM", "TOP_BOTTOM"), + }), + ); + if matches!(ty, "FRAME" | "COMPONENT" | "COMPONENT_SET" | "INSTANCE" | "SECTION") { + o.insert("clipsContent".into(), json!(!v.flag("frameMaskDisabled").unwrap_or(false))); + self.auto_layout(o, v); + } + child_layout(o, v, parent); + let exports: Vec = v.list("exportSettings").iter().map(export_setting).collect(); + if !exports.is_empty() { + o.insert("exportSettings".into(), Value::Array(exports)); + } + if let Some(b) = v.flag("sectionContentsHidden") { + o.insert("sectionContentsHidden".into(), json!(b)); + } + self.prototype(o, v); + self.variable_bindings(o, v); + } + + /// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters. + pub(super) fn geometry(&mut self, o: &mut Obj, v: &View<'a>) { + for key in ["fillGeometry", "strokeGeometry"] { + let mut out = Vec::new(); + for p in v.list(key) { + let Some(blob) = num(p, "commandsBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { continue }; + let Some(d) = svg_path(blob) else { + self.warn("damaged vector geometry was skipped"); + continue; + }; + if d.is_empty() { + continue; + } + let winding = if p.get("windingRule").and_then(Kv::str) == Some("ODD") { "EVENODD" } else { "NONZERO" }; + out.push(json!({"path": d, "windingRule": winding})); + } + if !out.is_empty() { + o.insert(key.into(), Value::Array(out)); + } + } + let vector = o.get("type").and_then(Value::as_str) == Some("VECTOR"); + if vector && !self.network(o, v) && !o.contains_key("fillGeometry") && !o.contains_key("strokeGeometry") && v.str("type") != Some("CONNECTOR") + { + self.warn("vectors without geometry are empty"); + } + if !vector { + copy_num(o, v, "count", "pointCount"); + copy_num(o, v, "starInnerScale", "innerRadius"); + } + } + + /// The editable network of `vectorData` as `vectorNetwork` (the REST importer's extension), + /// scaled to the layer's size, with per-point corner radii, handle mirroring and end caps. + /// False when there's none or it's damaged (the outlines stand in). + fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { + let Some((vd, radius_scale)) = v.find("vectorData") else { return false }; + let Some(blob_id) = num(vd, "vectorNetworkBlob").map(|i| i as usize) else { return false }; + let Some(blob) = self.blobs.get(blob_id).copied() else { return false }; + let Some(net) = network::decode(blob) else { + self.warn("damaged vector networks import as outlines"); + return false; + }; + let styles: BTreeMap = + vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); + let mut has_uninterpreted = false; + let mut has_missing = false; + for segment in &net.segments { + if segment.style == 0 { + continue; + } + if styles.contains_key(&u64::from(segment.style)) { + has_uninterpreted = true; + } else { + has_missing = true; + } + } + if has_uninterpreted { + self.warn("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + } + if has_missing { + self.warn("some vector segments refer to style settings missing from the file; they use the layer's stroke"); + } + // An open path shows its fill, which regions can't hold: the outline keeps the look. + let fills_shown = + o.get("fills").and_then(Value::as_array).is_some_and(|f| f.iter().any(|p| p.get("visible").and_then(Value::as_bool) != Some(false))); + if net.segments.is_empty() || (net.regions.is_empty() && fills_shown && o.contains_key("fillGeometry")) { + return false; + } + let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0); + let scale = |k: &str| { + let size = v.size().map(|s| if k == "x" { s.0 } else { s.1 }); + let norm = vd.get("normalizedSize").and_then(|s| num(s, k)); + size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n) + }; + let (sx, sy) = (scale("x"), scale("y")); + // A point whose style doesn't set a radius takes the layer's, as the editor draws it. + let layer_radius = v.len("cornerRadius"); + let vertices: Vec = net + .vertices + .iter() + .map(|p| { + let mut out = json!({"x": p.x * sx, "y": p.y * sy}); + let st = style(p.style); + // Point radii are lengths in this row's space, unlike the normalized positions. + // An override/derived row already carries the instance's scale. + if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).and_then(|r| scaled(r, radius_scale)).or(layer_radius).filter(|r| *r > 0.0) { + out["radius"] = json!(r); + } + match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) { + Some("ANGLE") => out["mirror"] = json!("angle"), + Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"), + Some("NONE") => out["mirror"] = json!("none"), + _ => {} + } + if let Some(join @ ("MITER" | "BEVEL" | "ROUND")) = st.and_then(|s| s.get("strokeJoin")).and_then(Kv::str) { + out["join"] = json!(join.to_ascii_lowercase()); + } + out + }) + .collect(); + let segments: Vec = + net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect(); + let regions: Vec = net + .regions + .iter() + .map(|r| { + let mut region = json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }}); + if r.style != 0 { + match style(r.style) { + Some(s) => { + let fills = s.get("fillPaints").and_then(|own_fills| { + if own_fills.list().iter().any(paint_bound) { + return Some(own_fills); + } + let linked_style = self + .style_link(&View::of(s), "styleIdForFill", "inheritFillStyleID") + .and_then(|id| self.nodes.get(&id).copied()) + .filter(|style| style.get("styleType").and_then(Kv::str) == Some("FILL")); + match linked_style { + Some(style) => style.get("fillPaints"), + None => Some(own_fills), + } + }); + if let Some(fills) = fills { + if let Kv::Array(source_paints) = fills { + let paint_cost = source_paints.iter().fold(source_paints.len(), |cost, paint| { + cost.saturating_add(paint.get("stops").map(Kv::list).unwrap_or_default().len()) + }); + let oversized_stops = + source_paints.iter().any(|paint| paint.get("stops").map(Kv::list).unwrap_or_default().len() > 256); + if source_paints.len() > 256 || oversized_stops || self.region_paint_elements.saturating_add(paint_cost) > 200_000 + { + self.warn("vector region paint data exceeded import limits and was skipped"); + return region; + } + self.region_paint_elements += paint_cost; + let paints: Vec = source_paints.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect(); + if paints.len() != source_paints.len() { + self.warn("invalid vector region paints were skipped"); + } + if paints.iter().any(has_bound_variables) { + self.warn("variable-bound vector region paints keep resolved appearance; live bindings were left out"); + } + if s.get("styleIdForFill").is_some() { + self.warn("linked vector region fill styles keep resolved appearance; live style links were left out"); + } + region["_figRegionFills"] = Value::Array(paints); + } else { + self.warn("invalid vector region fill paint list; layer fill used"); + } + } + } + None => self.warn("vector region references a missing fill style; layer fill used"), + } + } + region + }) + .collect(); + // An absent layer join is the source default. + let layer_join = v.str("strokeJoin").unwrap_or("MITER"); + let point_join = |p: &network::Vertex| style(p.style).and_then(|s| s.get("strokeJoin")); + let known = |j: &str| matches!(j, "MITER" | "BEVEL" | "ROUND"); + let unresolved_style = net.vertices.iter().any(|p| p.style != 0 && style(p.style).is_none()); + let unknown_join = net.vertices.iter().any(|p| point_join(p).is_some_and(|j| !j.str().is_some_and(known))); + let unsupported_join = net.vertices.iter().any(|p| point_join(p).and_then(Kv::str).is_some_and(|j| known(j) && j != layer_join)); + if unresolved_style { + self.warn("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"); + } + if unknown_join { + self.warn("vector points with unknown stroke joins use the layer join"); + } + if unsupported_join && (o.contains_key("strokeDashes") || o.contains_key("strokeWidthProfile")) { + self.warn("point stroke joins were preserved; dashed or variable-width strokes render with the layer join"); + } + // The stroke starts at the first end point and stops at the last (as the editor draws it). + let mut degree = vec![0usize; net.vertices.len()]; + for s in &net.segments { + for i in [s.a, s.b] { + if let Some(d) = degree.get_mut(i) { + *d += 1; + } + } + } + let ends: Vec = (0..degree.len()).filter(|i| degree.get(*i) == Some(&1)).collect(); + let cap = + |i: Option<&usize>| i.and_then(|i| net.vertices.get(*i)).and_then(|p| style(p.style)).and_then(|s| s.get("strokeCap")).and_then(Kv::str); + let (first, last) = (cap(ends.first()), cap(ends.last())); + if first.is_some() || last.is_some() { + let node = v.str("strokeCap").unwrap_or("NONE"); + o.insert("strokeCapStart".into(), json!(cap_name(first.unwrap_or(node)))); + o.insert("strokeCapEnd".into(), json!(cap_name(last.unwrap_or(node)))); + } + // Every instance of a main shows the same network: it is written once and shared. + let mut entry = json!({}); + entry["vertices"] = Value::Array(vertices); + entry["segments"] = Value::Array(segments); + entry["regions"] = Value::Array(regions.clone()); + if regions.iter().any(|r| r.get("_figRegionFills").is_some()) { + o.insert("vectorNetwork".into(), entry); + return true; + } + // A resized instance and a K-scaled one can have the same box but different radii. + let key = + (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize, radius_scale.to_bits(), layer_radius.unwrap_or(0.0).to_bits()); + let id = match self.network_ids.get(&key) { + Some(id) => *id, + None => { + self.networks.push(serde_json::from_value(entry).ok()); + self.network_ids.insert(key, self.networks.len() - 1); + self.networks.len() - 1 + } + }; + o.insert("vectorNetworkRef".into(), json!(id)); + true + } +} + +/// The text a layer that isn't a text layer carries (a note's, a shape's), unless blank. +pub(super) fn own_text<'a>(v: &View<'a>) -> Option<&'a str> { + v.get("textData").and_then(|t| t.get("characters")).and_then(Kv::str).filter(|c| !c.trim().is_empty()) +} + +pub(super) fn valid_size(value: Option<&Kv<'_>>) -> Option<(f64, f64)> { + let x = num(value?, "x")?; + let y = num(value?, "y")?; + (x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0).then_some((x, y)) +} + +/// The REST name of a stroke cap. +pub(super) fn cap_name(cap: &str) -> &str { + match cap { + "ARROW_LINES" => "LINE_ARROW", + "ARROW_EQUILATERAL" => "TRIANGLE_ARROW", + c => c, + } +} + +/// The layer kind (and how the warning names it) that a layer the editor has no counterpart for +/// imports as: frames for notes, shapes, tables, slides and the like (their text as a text layer), +/// vectors for connectors and marker strokes, plain text for text on a path. +fn stand_in(ty: &str) -> Option<(&'static str, &'static str)> { + Some(match ty { + "STICKY" + | "SHAPE_WITH_TEXT" + | "CODE_BLOCK" + | "TABLE" + | "TABLE_CELL" + | "STAMP" + | "MEDIA" + | "WIDGET" + | "SLIDE" + | "SLIDE_GRID" + | "SLIDE_ROW" + | "INTERACTIVE_SLIDE_ELEMENT" + | "MODULE" => ("FRAME", "frames with their text"), + "CONNECTOR" | "HIGHLIGHT" | "WASHI_TAPE" => ("VECTOR", "vectors"), + "TEXT_PATH" => ("TEXT", "text off its path"), + _ => return None, + }) +} + +/// How a layer sizes and sits in an auto layout parent. +fn child_layout(o: &mut Obj, v: &View, parent: Option<&View>) { + copy_num(o, v, "stackChildPrimaryGrow", "layoutGrow"); + if v.str("stackChildAlignSelf") == Some("STRETCH") { + o.insert("layoutAlign".into(), json!("STRETCH")); + } + if v.str("stackPositioning") == Some("ABSOLUTE") { + o.insert("layoutPositioning".into(), json!("ABSOLUTE")); + } + for (k, w, h) in [("minSize", "minWidth", "minHeight"), ("maxSize", "maxWidth", "maxHeight")] { + if let Some((val, scale)) = v.find(k).and_then(|(s, scale)| Some((s.get("value")?, scale))) { + for (axis, to) in [("x", w), ("y", h)] { + if let Some(x) = num(val, axis).and_then(|x| scaled(x, scale)).filter(|x| *x > 0.0 && *x < 1e9) { + o.insert(to.into(), json!(x)); + } + } + } + } + if let Some(p) = parent.filter(|p| p.str("stackMode") == Some("GRID")) { + for (anchor, list, to) in [("gridColumnAnchor", "gridColumns", "gridColumnAnchorIndex"), ("gridRowAnchor", "gridRows", "gridRowAnchorIndex")] + { + if let Some(i) = v.get(anchor).and_then(guid).and_then(|g| tracks(p, list).iter().position(|t| *t == g)) { + o.insert(to.into(), json!(i)); + } + } + copy_num(o, v, "gridColumnSpan", "gridColumnSpan"); + copy_num(o, v, "gridRowSpan", "gridRowSpan"); + } +} + +/// SVG path data of a geometry blob ([`crate::path`]). `None` when damaged. +pub(super) fn svg_path(b: &[u8]) -> Option { + use crate::path::Command; + let mut out = String::new(); + for c in crate::path::decode(b)? { + let (letter, points) = match c { + Command::Close => ('Z', vec![]), + Command::Move(p) => ('M', vec![p]), + Command::Line(p) => ('L', vec![p]), + Command::Quad(a, b) => ('Q', vec![a, b]), + Command::Cubic(a, b, c) => ('C', vec![a, b, c]), + }; + if !out.is_empty() { + out.push(' '); + } + out.push(letter); + for [x, y] in points { + out.push_str(&format!(" {x} {y}")); + } + } + Some(out) +} diff --git a/crates/fig/src/rest/layout.rs b/crates/fig/src/rest/layout.rs new file mode 100644 index 000000000..2012557c9 --- /dev/null +++ b/crates/fig/src/rest/layout.rs @@ -0,0 +1,190 @@ +use super::paint::{bound, color}; +use super::{BTreeMap, BTreeSet, Kv, MAX_GRID_TRACK_VALUE, MAX_GRID_TRACKS, Mapper, Obj, Value, View, copy_len, guid, json, num, scaled}; + +impl<'a> Mapper<'a> { + pub(super) fn auto_layout(&mut self, o: &mut Obj, v: &View<'a>) { + let Some(mode) = v.str("stackMode").filter(|m| matches!(*m, "HORIZONTAL" | "VERTICAL" | "GRID")) else { return }; + o.insert("layoutMode".into(), json!(mode)); + copy_len(o, v, "stackSpacing", "itemSpacing"); + copy_len(o, v, "stackCounterSpacing", "counterAxisSpacing"); + if v.str("stackWrap") == Some("WRAP") { + o.insert("layoutWrap".into(), json!("WRAP")); + } + if v.str("stackCounterAlignContent") == Some("SPACE_BETWEEN") { + o.insert("counterAxisAlignContent".into(), json!("SPACE_BETWEEN")); + } + let (h, vert) = (v.len("stackHorizontalPadding").unwrap_or(0.0), v.len("stackVerticalPadding").unwrap_or(0.0)); + o.insert("paddingLeft".into(), json!(h)); + o.insert("paddingTop".into(), json!(vert)); + o.insert("paddingRight".into(), json!(v.len("stackPaddingRight").unwrap_or(h))); + o.insert("paddingBottom".into(), json!(v.len("stackPaddingBottom").unwrap_or(vert))); + let main = match v.str("stackPrimaryAlignItems").or_else(|| v.str("stackJustify")) { + Some("CENTER") => "CENTER", + Some("MAX") => "MAX", + Some("SPACE_AROUND") => "SPACE_AROUND", + // `SPACE_EVENLY` is the format's older name for equal gaps without edge space. + Some("SPACE_EVENLY_CSS") => "SPACE_EVENLY", + Some("SPACE_BETWEEN" | "SPACE_EVENLY") => "SPACE_BETWEEN", + _ => "MIN", + }; + o.insert("primaryAxisAlignItems".into(), json!(main)); + let cross = match v.str("stackCounterAlignItems").or_else(|| v.str("stackAlign")) { + Some("CENTER") => "CENTER", + Some("MAX") => "MAX", + Some("BASELINE") => "BASELINE", + _ => "MIN", + }; + o.insert("counterAxisAlignItems".into(), json!(cross)); + let sizing = |k: &str| if v.str(k).is_some_and(|s| s.starts_with("RESIZE_TO_FIT")) { "AUTO" } else { "FIXED" }; + // A flow that records no sizing for its primary axis sizes it by its content: auto layout + // did before that axis could be fixed. + let primary = if v.get("stackPrimarySizing").is_none() && mode != "GRID" { "AUTO" } else { sizing("stackPrimarySizing") }; + o.insert("primaryAxisSizingMode".into(), json!(primary)); + o.insert("counterAxisSizingMode".into(), json!(sizing("stackCounterSizing"))); + if let Some(b) = v.flag("bordersTakeSpace") { + o.insert("strokesIncludedInLayout".into(), json!(b)); + } + if let Some(b) = v.flag("stackReverseZIndex") { + o.insert("itemReverseZIndex".into(), json!(b)); + } + if mode == "GRID" { + copy_len(o, v, "gridColumnGap", "gridColumnGap"); + copy_len(o, v, "gridRowGap", "gridRowGap"); + o.insert("gridColumnCount".into(), json!(tracks(v, "gridColumns").len())); + o.insert("gridRowCount".into(), json!(tracks(v, "gridRows").len())); + let track_data = [("columnTracks", "gridColumns", "gridColumnsSizing"), ("rowTracks", "gridRows", "gridRowsSizing")] + .into_iter() + .filter_map(|(out_key, order_key, sizing_key)| { + self.map_grid_tracks(v, order_key, sizing_key).map(|mapped| (out_key.to_string(), mapped)) + }) + .collect::(); + if !track_data.is_empty() { + let source = o.entry(super::FIG_SOURCE_FIELD).or_insert_with(|| json!({})); + if let Some(source) = source.as_object_mut() { + source.insert("gridTracks".into(), Value::Object(track_data)); + } + } + } + } + + /// Track sizing maps are keyed by GUID; their order comes from the separate track list. + fn map_grid_tracks(&mut self, v: &View<'a>, order_key: &str, sizing_key: &str) -> Option { + let track_entries = v.get(order_key).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default(); + let sizes = v.get(sizing_key).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default(); + if track_entries.len() > MAX_GRID_TRACKS { + self.warn("grid track list exceeds 1000 entries; the tracks use the default equal sizing"); + return None; + } + if sizes.is_empty() { + return None; + } + if sizes.len() > MAX_GRID_TRACKS { + self.warn("grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing"); + return None; + } + let ordered = tracks(v, order_key); + let ordered_ids: BTreeSet<&str> = ordered.iter().map(String::as_str).collect(); + let mut size_by_id: BTreeMap>> = BTreeMap::new(); + for entry in sizes { + let Some(id) = entry.get("id").and_then(guid) else { + self.warn("stale or invalid grid track sizing entries were ignored"); + continue; + }; + if !ordered_ids.contains(id.as_str()) { + self.warn("stale or invalid grid track sizing entries were ignored"); + continue; + } + match size_by_id.entry(id) { + std::collections::btree_map::Entry::Vacant(slot) => { + slot.insert(Some(entry)); + } + std::collections::btree_map::Entry::Occupied(mut slot) => { + slot.insert(None); + self.warn("duplicate grid track sizing entries use equal tracks"); + } + } + } + let mut mapped = Vec::with_capacity(ordered.len()); + for id in ordered { + if let Some(Some(entry)) = size_by_id.get(&id) { + match grid_track_sizing(entry.get("trackSize")) { + Some(track) => mapped.push(track), + None => { + self.warn("grid track sizing is unsupported or malformed; affected tracks use equal sizing"); + mapped.push(json!({"sizing": "fill", "value": 1.0})); + } + } + } else { + mapped.push(json!({"sizing": "fill", "value": 1.0})); + } + } + Some(Value::Array(mapped)) + } +} + +/// The track ids of a grid's columns or rows, in order. +pub(super) fn tracks(v: &View, key: &str) -> Vec { + let mut t: Vec<(&str, String)> = v + .get(key) + .and_then(|m| m.get("entries")) + .map(Kv::list) + .unwrap_or_default() + .iter() + .filter_map(|e| Some((e.get("position").and_then(Kv::str).unwrap_or(""), e.get("id").and_then(guid)?))) + .collect(); + t.sort(); + t.into_iter().map(|e| e.1).collect() +} + +/// Convert only unconstrained tracks, whose min and max sizing functions agree. +fn grid_track_sizing(size: Option<&Kv<'_>>) -> Option { + let size = size?; + let min = size.get("minSizing")?; + let max = size.get("maxSizing")?; + let min_type = min.get("type").and_then(Kv::str)?; + let max_type = max.get("type").and_then(Kv::str)?; + if min_type != max_type { + return None; + } + match min_type { + "FIXED" => { + let px = num(min, "value")?; + let max_px = num(max, "value")?; + (px == max_px && px.is_finite() && (0.0..=MAX_GRID_TRACK_VALUE).contains(&px)).then(|| json!({"sizing": "fixed", "value": px})) + } + "FLEX" => { + let fr = num(min, "value")?; + let max_fr = num(max, "value")?; + (fr == max_fr && fr.is_finite() && fr > 0.0 && fr <= MAX_GRID_TRACK_VALUE).then(|| json!({"sizing": "fill", "value": fr})) + } + "HUG" => Some(json!({"sizing": "hug", "value": 0.0})), + _ => None, + } +} + +/// A layout grid; `k`: the factor its lengths take (a scaled instance's layer). +pub(super) fn layout_grid(g: &Kv, k: f64) -> Value { + let pattern = match (g.get("pattern").and_then(Kv::str), g.get("axis").and_then(Kv::str)) { + (Some("GRID"), _) => "GRID", + (_, Some("Y")) => "ROWS", + _ => "COLUMNS", + }; + let mut o = json!({"pattern": pattern, "alignment": g.get("type").and_then(Kv::str).unwrap_or("STRETCH")}); + for (from, to) in [("sectionSize", "sectionSize"), ("numSections", "count"), ("gutterSize", "gutterSize"), ("offset", "offset")] { + let length = from != "numSections"; + if let Some(x) = num(g, from).and_then(|x| scaled(x, if length { k } else { 1.0 })) { + o[to] = json!(x); + } + } + if let Some(c) = g.get("color") { + o["color"] = color(c); + } + if let Some(b) = g.get("visible").and_then(Kv::bool) { + o["visible"] = json!(b); + } + let vars = [("numSectionsVar", "count"), ("offsetVar", "offset"), ("sectionSizeVar", "sectionSize"), ("gutterSizeVar", "gutterSize")]; + if let Some(b) = bound(Some(g), &vars) { + o["boundVariables"] = b; + } + o +} diff --git a/crates/fig/src/rest/paint.rs b/crates/fig/src/rest/paint.rs new file mode 100644 index 000000000..00f038ad0 --- /dev/null +++ b/crates/fig/src/rest/paint.rs @@ -0,0 +1,354 @@ +use super::layer::cap_name; +use super::variables::alias; +use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, copy_len, copy_str, guid, json, matrix, num, rows, scaled, vec2}; + +fn filter_number(value: &Kv) -> Option { + let number = match value { + Kv::Float(value) => f64::from(*value), + Kv::Int(value) => *value as f64, + Kv::Uint(value) => *value as f64, + _ => return None, + }; + number.is_finite().then_some(number) +} + +pub(super) fn color(c: &Kv) -> Value { + let ch = |k: &str| num(c, k).unwrap_or(0.0); + json!({"r": ch("r"), "g": ch("g"), "b": ch("b"), "a": num(c, "a").unwrap_or(1.0)}) +} + +impl<'a> Mapper<'a> { + /// The style a layer links in `key`, by guid or by library asset (the file's copy of a library + /// style), or in the older `legacy` field when `key` names none. An explicit NO_STYLE guid in + /// `key` with no asset is a detach and wins over the legacy field. The topmost layer that names + /// either field decides, as for every other field: an override row's older field (a detach or + /// a link) over a main's newer one below it; on one layer the newer field wins. + pub(super) fn style_link(&self, v: &View, key: &str, legacy: &str) -> Option { + let fallback = || v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE); + let top = style_layer(v, key, legacy)?; + let Some(link) = top.get(key) else { return top.get(legacy).and_then(guid).filter(|g| g != NO_STYLE) }; + match (self.resource_id(link), link.get("guid").and_then(guid)) { + (Some(id), _) => Some(id), + (None, Some(_)) => None, + (None, None) => fallback(), + } + } + + /// The style node a layer links in `key` (or the older `legacy` field), recorded for the REST + /// `styles` map under `slot`. + /// A link to a style the file doesn't hold leaves the layer its own values (the colours it + /// stored with it), with a note. + pub(super) fn style(&mut self, v: &View<'a>, key: &str, legacy: &str, slot: &str, out: &mut Obj) -> Option<&'a Kv<'a>> { + let found = self.style_link(v, key, legacy).and_then(|id| Some((self.nodes.get(&id).copied()?, id))); + let Some((node, id)) = found.filter(|(n, _)| n.get("styleType").and_then(Kv::str).is_some()) else { + let named = |l: &Kv| l.get("assetRef").is_some() || l.get("guid").and_then(guid).is_some_and(|g| g != NO_STYLE); + let real = |g: Option| g.is_some_and(|g| g != NO_STYLE); + // The layer `style_link` read: its link names a style the file lacks. + let missing = style_layer(v, key, legacy).is_some_and(|l| match l.get(key) { + Some(k) if named(k) => true, + Some(k) if k.get("guid").is_some() => false, + Some(_) => real(v.get(legacy).and_then(guid)), + None => real(l.get(legacy).and_then(guid)), + }); + if missing { + self.warn("links to shared styles that aren't in the file kept the layers' own values"); + } + return None; + }; + out.insert(slot.into(), json!(id)); + if !self.styles.contains_key(&id) { + let name = node.get("name").and_then(Kv::str).unwrap_or("Style"); + let desc = node.get("styleDescription").or_else(|| node.get("description")).and_then(Kv::str).unwrap_or(""); + self.styles.insert(id, json!({"name": name, "description": desc})); + } + Some(node) + } +} + +/// The layer that decides a style link: the topmost that names `key` or the older `legacy`. +fn style_layer<'v>(v: &View<'v>, key: &str, legacy: &str) -> Option<&'v Kv<'v>> { + v.layers.iter().rev().copied().find(|l| l.get(key).is_some() || l.get(legacy).is_some()) +} + +pub(super) fn stroke(o: &mut Obj, v: &View, outlined_stroke: Option<(&str, &str)>) { + copy_len(o, v, "strokeWeight", "strokeWeight"); + if let Some(a) = v.str("strokeAlign").filter(|a| *a != "OFFSET") { + o.insert("strokeAlign".into(), json!(a)); + } + copy_str(o, v, "strokeJoin", "strokeJoin"); + if let Some(cap) = v.str("strokeCap") { + o.insert("strokeCap".into(), json!(cap_name(cap))); + } + // A layer written as the outline of its stroke keeps what it was as plugin data + // (`outlinedStroke`, our extension of the file JSON). + let kept = outlined_stroke.and_then(|(id, key)| { + v.list("pluginData").iter().find(|p| p.get("pluginID").and_then(Kv::str) == Some(id) && p.get("key").and_then(Kv::str) == Some(key)) + }); + if let Some(original) = kept.and_then(|p| p.get("value")).and_then(Kv::str).and_then(|s| serde_json::from_str::(s).ok()) { + o.insert("outlinedStroke".into(), original); + } + // A variable width: each point's reach on the two sides of the path (shares of the stroke + // weight), as `[t, scale, bias]` (`strokeWidthProfile`, our extension of the file JSON). + let profile: Vec = v + .list("variableWidthPoints") + .iter() + .filter_map(|p| { + let (left, right) = (num(p, "ascent")?.max(0.0), num(p, "descent")?.max(0.0)); + let bias = if left + right > 0.0 { (right - left) / (left + right) } else { 0.0 }; + Some(json!([num(p, "position")?, left + right, bias])) + }) + .collect(); + if !profile.is_empty() { + o.insert("strokeWidthProfile".into(), Value::Array(profile)); + } + let (dash_list, dash_scale) = v.list_scaled("dashPattern"); + let dashes: Vec = dash_list.iter().filter_map(Kv::f64).filter_map(|d| scaled(d, dash_scale)).collect(); + if !dashes.is_empty() { + o.insert("strokeDashes".into(), json!(dashes)); + } + // A miter limit L bevels joins sharper than 2·asin(1/L). + if let Some(l) = v.num("miterLimit").filter(|l| *l > 1.0) { + o.insert("strokeMiterAngle".into(), json!((2.0 * (1.0 / l).asin()).to_degrees())); + } + if v.flag("borderStrokeWeightsIndependent") == Some(true) { + // A side that isn't written has no border. + let side = |k: &str| v.len(k).unwrap_or(0.0); + o.insert( + "individualStrokeWeights".into(), + json!({"top": side("borderTopWeight"), "right": side("borderRightWeight"), "bottom": side("borderBottomWeight"), "left": side("borderLeftWeight")}), + ); + } +} + +/// Whether a paint's colour or one of its gradient stops is bound to a variable. +pub(super) fn paint_bound(p: &Kv) -> bool { + alias(p.get("colorVar")).is_some() || p.get("stopsVar").map(Kv::list).unwrap_or_default().iter().any(|s| alias(s.get("colorVar")).is_some()) +} + +pub(crate) fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option { + let ty = p.get("type").and_then(Kv::str)?; + let mut o = Obj::new(); + o.insert("type".into(), json!(ty)); + if let Some(x) = num(p, "opacity") { + o.insert("opacity".into(), json!(x)); + } + if let Some(b) = p.get("visible").and_then(Kv::bool) { + o.insert("visible".into(), json!(b)); + } + if let Some(b) = p.get("blendMode").and_then(Kv::str) { + o.insert("blendMode".into(), json!(b)); + } + let t = p.get("transform").and_then(matrix); + match ty { + "SOLID" => { + if let Some(c) = p.get("color") { + o.insert("color".into(), color(c)); + } + if let Some(var) = alias(p.get("colorVar")) { + o.insert("boundVariables".into(), json!({"color": {"type": "VARIABLE_ALIAS", "id": var}})); + } + } + _ if ty.starts_with("GRADIENT_") => { + // The paint transform maps the layer's unit box to gradient space, where a linear + // gradient runs from (0, ½) to (1, ½) and the others are centred on (½, ½). + let inv = t.filter(|t| t.determinant().abs() > 1e-12).map_or(Affine::IDENTITY, Affine::inverse); + let pts = if ty == "GRADIENT_LINEAR" { [(0.0, 0.5), (1.0, 0.5), (0.0, 1.0)] } else { [(0.5, 0.5), (1.0, 0.5), (0.5, 1.0)] }; + let handles: Vec = pts + .iter() + .map(|(x, y)| { + let q = inv * Point::new(*x, *y); + json!({"x": q.x, "y": q.y}) + }) + .collect(); + o.insert("gradientHandlePositions".into(), Value::Array(handles)); + // Stops bound to variables are listed again, each with its variable. + let vars = p.get("stopsVar").map(Kv::list).unwrap_or_default(); + let stops: Vec = p + .get("stops") + .map(Kv::list) + .unwrap_or_default() + .iter() + .enumerate() + .map(|(i, s)| { + let mut stop = json!({"position": num(s, "position").unwrap_or(0.0), "color": s.get("color").map_or(Value::Null, color)}); + if let Some(b) = bound(vars.get(i), &[("colorVar", "color")]) { + stop["boundVariables"] = b; + } + stop + }) + .collect(); + o.insert("gradientStops".into(), Value::Array(stops)); + } + "IMAGE" => { + // A hash longer than any the file's images can be named by resolves to nothing: it + // isn't spelled out, which a shared style would repeat for every layer using it. + let hash: String = p + .get("image") + .and_then(|i| i.get("hash")) + .and_then(Kv::bytes) + .filter(|h| h.len() <= crate::MAX_IMAGE_HASH) + .unwrap_or_default() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + o.insert("imageRef".into(), json!(hash)); + let mode = p.get("imageScaleMode").and_then(Kv::str).unwrap_or("FILL"); + o.insert("scaleMode".into(), json!(mode)); + if let (Some(t), "STRETCH") = (t, mode) { + o.insert("imageTransform".into(), rows(t)); + } + if let Some(s) = num(p, "scale") { + o.insert("scalingFactor".into(), json!(s)); + } + if let Some(r) = num(p, "rotation") { + o.insert("rotation".into(), json!(r)); + } + match (p.get("filterColorAdjust"), p.get("paintFilter")) { + (Some(_), Some(_)) => { + warn("image filters are ambiguous because both filter containers are present; filter conversion was skipped".into()) + } + (Some(_), None) => warn("legacy image filter container is unsupported; filter conversion was skipped".into()), + (None, Some(filters)) => { + let mut mapped = Obj::new(); + let fields = [ + ("exposure", "exposure"), + ("contrast", "contrast"), + ("vibrance", "saturation"), + ("temperature", "temperature"), + ("tint", "tint"), + ("highlights", "highlights"), + ("shadows", "shadows"), + ]; + if let Kv::Object(values) = filters { + for (source, target) in fields { + if let Some(value) = filters.get(source) { + match filter_number(value) { + Some(value) => { + mapped.insert(target.into(), json!(value)); + } + None => warn(format!("malformed image filter `{source}` was skipped")), + } + } + } + for (name, value) in values { + if fields.iter().any(|(known, _)| *known == *name) { + continue; + } + match filter_number(value) { + Some(0.0) => {} + Some(_) => warn(format!("nonzero unsupported image filter `{}` was skipped", super::clip_str(name))), + None => warn(format!("malformed unsupported image filter `{}` was skipped", super::clip_str(name))), + } + } + } else { + warn("malformed image paintFilter container was skipped".into()); + } + if !mapped.is_empty() { + o.insert("filters".into(), Value::Object(mapped)); + } + } + (None, None) => {} + } + } + _ => {} + } + Some(Value::Object(o)) +} + +pub(super) fn has_bound_variables(value: &Value) -> bool { + match value { + Value::Object(object) => object.contains_key("boundVariables") || object.values().any(has_bound_variables), + Value::Array(values) => values.iter().any(has_bound_variables), + _ => false, + } +} + +/// The variables bound to the fields of a paint stop, an effect or a layout grid, as its REST +/// `boundVariables`: `fields` pairs the format's field with the REST name. +pub(super) fn bound(v: Option<&Kv>, fields: &[(&str, &str)]) -> Option { + let v = v?; + let found: Obj = + fields.iter().filter_map(|(from, to)| Some((to.to_string(), json!({"type": "VARIABLE_ALIAS", "id": alias(v.get(from))?})))).collect(); + (!found.is_empty()).then_some(Value::Object(found)) +} + +/// A layer effect; `k`: the factor its lengths take (a scaled instance's layer). +pub(super) fn effect(e: &Kv, k: f64) -> Option { + let ty = match e.get("type").and_then(Kv::str)? { + "FOREGROUND_BLUR" => "LAYER_BLUR", + "GRAIN" => "TEXTURE", + t => t, + }; + let mut o = Obj::new(); + o.insert("type".into(), json!(ty)); + for key in ["radius", "spread", "startRadius", "density", "opacity"] { + let length = matches!(key, "radius" | "spread" | "startRadius"); + if let Some(x) = num(e, key).and_then(|x| scaled(x, if length { k } else { 1.0 })) { + o.insert(key.into(), json!(x)); + } + } + for k in ["visible", "showShadowBehindNode", "clipToShape"] { + if let Some(b) = e.get(k).and_then(Kv::bool) { + o.insert(k.into(), json!(b)); + } + } + for k in ["blendMode", "noiseType"] { + if let Some(s) = e.get(k).and_then(Kv::str) { + o.insert(k.into(), json!(s)); + } + } + for k in ["color", "secondaryColor"] { + if let Some(c) = e.get(k) { + o.insert(k.into(), color(c)); + } + } + // `startOffset` and `endOffset` are positions in the layer's unit box, not lengths: they stay. + for (from, to) in [("offset", "offset"), ("noiseSize", "noiseSizeVector")] { + if let Some(p) = e.get(from).and_then(vec2) { + o.insert(to.into(), scale_vec2(p, k)); + } + } + for key in ["startOffset", "endOffset"] { + if let Some(p) = e.get(key).and_then(vec2) { + o.insert(key.into(), p); + } + } + if e.get("blurOpType").and_then(Kv::str) == Some("PROGRESSIVE") { + o.insert("blurType".into(), json!("PROGRESSIVE")); + } + let vars = [ + ("colorVar", "color"), + ("radiusVar", "radius"), + ("spreadVar", "spread"), + ("xVar", "offsetX"), + ("yVar", "offsetY"), + ("startRadiusVar", "startRadius"), + ]; + if let Some(b) = bound(Some(e), &vars) { + o.insert("boundVariables".into(), b); + } + Some(Value::Object(o)) +} + +/// A `{"x", "y"}` vector at scale `k` (unchanged when that is not finite). +fn scale_vec2(p: Value, k: f64) -> Value { + let axis = |name: &str| p.get(name).and_then(Value::as_f64).and_then(|x| scaled(x, k)); + match (axis("x"), axis("y")) { + (Some(x), Some(y)) => json!({"x": x, "y": y}), + _ => p, + } +} + +pub(super) fn export_setting(e: &Kv) -> Value { + let c = e.get("constraint"); + let ty = match c.and_then(|c| c.get("type")).and_then(Kv::str) { + Some("CONTENT_WIDTH") => "WIDTH", + Some("CONTENT_HEIGHT") => "HEIGHT", + _ => "SCALE", + }; + json!({ + "format": e.get("imageType").and_then(Kv::str).unwrap_or("PNG"), + "suffix": e.get("suffix").and_then(Kv::str).unwrap_or(""), + "constraint": {"type": ty, "value": c.and_then(|c| num(c, "value")).unwrap_or(1.0)}, + }) +} diff --git a/crates/fig/src/rest/proto.rs b/crates/fig/src/rest/proto.rs new file mode 100644 index 000000000..9238cafb6 --- /dev/null +++ b/crates/fig/src/rest/proto.rs @@ -0,0 +1,179 @@ +use super::paint::color; +use super::variables::variable_data; +use super::{Kv, MAX_ACTION_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, num, ref_guid}; + +impl<'a> Mapper<'a> { + pub(super) fn prototype(&mut self, o: &mut Obj, v: &View<'a>) { + let mut list = Vec::new(); + for it in v.list("prototypeInteractions") { + if it.get("isDeleted").and_then(Kv::bool) == Some(true) { + continue; + } + if let Some(i) = self.interaction(it) { + list.push(i); + } + } + if !list.is_empty() { + o.insert("interactions".into(), Value::Array(list)); + } + let overflow = match v.str("scrollDirection") { + Some("HORIZONTAL") => Some("HORIZONTAL_SCROLLING"), + Some("VERTICAL") => Some("VERTICAL_SCROLLING"), + Some("BOTH") => Some("HORIZONTAL_AND_VERTICAL_SCROLLING"), + _ => None, + }; + if let Some(d) = overflow { + o.insert("overflowDirection".into(), json!(d)); + } + let behavior = match v.str("scrollBehavior") { + Some("FIXED_WHEN_CHILD_OF_SCROLLING_FRAME") => Some("FIXED"), + Some("STICKY_SCROLLS") => Some("STICKY_SCROLLS"), + _ => None, + }; + if let Some(b) = behavior { + o.insert("scrollBehavior".into(), json!(b)); + } + copy_str(o, v, "overlayPositionType", "overlayPositionType"); + copy_str(o, v, "overlayBackgroundInteraction", "overlayBackgroundInteraction"); + if let Some(a) = v.get("overlayBackgroundAppearance") { + let ty = a.get("backgroundType").and_then(Kv::str).unwrap_or("NONE"); + o.insert("overlayBackground".into(), json!({"type": ty, "color": a.get("backgroundColor").map_or(Value::Null, color)})); + } + } + + fn interaction(&mut self, it: &Kv) -> Option { + let ev = it.get("event"); + let ty = match ev.and_then(|e| e.get("interactionType")).and_then(Kv::str).unwrap_or("ON_CLICK") { + "DRAG" => "ON_DRAG", + "MOUSE_IN" => "MOUSE_ENTER", + "MOUSE_OUT" => "MOUSE_LEAVE", + t => t, + }; + let mut trigger = json!({"type": ty}); + if let Some(t) = ev.and_then(|e| num(e, "transitionTimeout")) { + // Seconds as 32-bit floats: whole milliseconds. + trigger["timeout"] = json!((t * 1000.0).round()); + } + if let Some(k) = ev.and_then(|e| e.get("keyTrigger")) { + let codes: Vec = k.get("keyCodes").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).collect(); + trigger["keyCodes"] = json!(codes); + if let Some(d) = k.get("triggerDevice").and_then(Kv::str) { + trigger["device"] = json!(if d.contains("KEYBOARD") { "KEYBOARD" } else { d }); + } + } + let actions: Vec = it.get("actions").map(Kv::list).unwrap_or_default().iter().filter_map(|a| self.action(a, 0)).collect(); + (!actions.is_empty()).then(|| json!({"trigger": trigger, "actions": actions})) + } + + /// One prototype action in the REST shape (`depth`: conditionals it's nested in). + fn action(&mut self, a: &Kv, depth: usize) -> Option { + Some(match a.get("connectionType").and_then(Kv::str) { + Some("INTERNAL_NODE") | None => { + let dest = a.get("transitionNodeID").and_then(guid)?; + let nav = match a.get("navigationType").and_then(Kv::str) { + Some("SWAP_STATE") => "CHANGE_TO", + Some(n) => n, + None => "NAVIGATE", + }; + let mut act = json!({"type": "NODE", "destinationId": dest, "navigation": nav}); + if let Some(t) = transition(a) { + act["transition"] = t; + } + if let Some(b) = a.get("transitionPreserveScroll").and_then(Kv::bool) { + act["preserveScrollPosition"] = json!(b); + } + if let Some(p) = a.get("overlayRelativePosition") { + act["overlayRelativePosition"] = json!({"x": num(p, "x"), "y": num(p, "y")}); + } + act + } + Some("URL") => json!({"type": "URL", "url": a.get("connectionURL").and_then(Kv::str)?}), + Some("BACK") => json!({"type": "BACK"}), + Some("CLOSE") => json!({"type": "CLOSE"}), + Some("NONE") => return None, + Some("SET_VARIABLE") => { + let var = a.get("targetVariable").and_then(|t| t.get("id")).and_then(ref_guid).or_else(|| a.get("targetVariableID").and_then(guid)); + // Missing parts are left out: the REST importer skips the action with a warning. + let mut act = json!({"type": "SET_VARIABLE", "variableId": var}); + if let Some(d) = a.get("targetVariableData").and_then(|d| variable_data(d, 0)) { + act["variableValue"] = d; + } + act + } + Some("SET_VARIABLE_MODE") => json!({ + "type": "SET_VARIABLE_MODE", + "variableCollectionId": a.get("targetVariableSetID").and_then(ref_guid), + "variableModeId": a.get("targetVariableModeID").and_then(guid), + }), + Some("CONDITIONAL") if depth < MAX_ACTION_DEPTH => { + let mut blocks = Vec::new(); + for b in a.get("conditionalActions").map(Kv::list).unwrap_or_default() { + let actions: Vec = + b.get("actions").map(Kv::list).unwrap_or_default().iter().filter_map(|x| self.action(x, depth + 1)).collect(); + let mut block = json!({"actions": actions}); + // A condition that can't be read must not turn the block into an "else". + if let Some(c) = b.get("condition") { + block["condition"] = variable_data(c, 0).unwrap_or_else(|| json!({})); + } + blocks.push(block); + } + json!({"type": "CONDITIONAL", "conditionalBlocks": blocks}) + } + Some(other) => { + self.warn(format!("{} prototype actions are not imported from .fig files yet", other.to_ascii_lowercase().replace('_', " "))); + return None; + } + }) + } +} + +/// A prototype action's transition (`None`: instant). +fn transition(a: &Kv) -> Option { + let ty = a.get("transitionType").and_then(Kv::str)?; + // Directions name where the incoming screen moves (SLIDE_FROM_LEFT moves right). + let (kind, dir) = match ty { + "INSTANT_TRANSITION" => return None, + "DISSOLVE" | "FADE" => ("DISSOLVE", None), + "SMART_ANIMATE" | "MAGIC_MOVE" => ("SMART_ANIMATE", None), + "SCROLL_ANIMATE" => ("SCROLL_ANIMATE", None), + t => { + let prefixes = [ + ("SLIDE_FROM_", "SLIDE_IN"), + ("PUSH_FROM_", "PUSH"), + ("MOVE_FROM_", "MOVE_IN"), + ("SLIDE_OUT_TO_", "SLIDE_OUT"), + ("MOVE_OUT_TO_", "MOVE_OUT"), + ]; + let (kind, side) = prefixes.iter().find_map(|(p, k)| t.strip_prefix(p).map(|r| (*k, r)))?; + let incoming = matches!(kind, "SLIDE_IN" | "PUSH" | "MOVE_IN"); + let dir = match (side, incoming) { + ("LEFT", true) | ("RIGHT", false) => "RIGHT", + ("RIGHT", true) | ("LEFT", false) => "LEFT", + ("TOP", true) | ("BOTTOM", false) => "BOTTOM", + _ => "TOP", + }; + (kind, Some(dir)) + } + }; + let easing = match a.get("easingType").and_then(Kv::str) { + Some("IN_CUBIC" | "EASE_IN") => "EASE_IN", + Some("INOUT_CUBIC") => "EASE_IN_AND_OUT", + Some("LINEAR") => "LINEAR", + Some("IN_BACK_CUBIC") => "EASE_IN_BACK", + Some("OUT_BACK_CUBIC") => "EASE_OUT_BACK", + Some("INOUT_BACK_CUBIC") => "EASE_IN_AND_OUT_BACK", + Some("GENTLE_SPRING") => "GENTLE", + Some("CUSTOM_CUBIC") => "CUSTOM_CUBIC_BEZIER", + _ => "EASE_OUT", + }; + let mut e = json!({"type": easing}); + let f: Vec = a.get("easingFunction").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).collect(); + if let (true, [x1, y1, x2, y2, ..]) = (easing == "CUSTOM_CUBIC_BEZIER", f.as_slice()) { + e["easingFunctionCubicBezier"] = json!({"x1": x1, "y1": y1, "x2": x2, "y2": y2}); + } + let mut t = json!({"type": kind, "duration": (num(a, "transitionDuration").unwrap_or(0.3) * 1000.0).round(), "easing": e}); + if let Some(d) = dir { + t["direction"] = json!(d); + } + Some(t) +} diff --git a/crates/fig/src/rest/swap.rs b/crates/fig/src/rest/swap.rs new file mode 100644 index 000000000..457e14fb6 --- /dev/null +++ b/crates/fig/src/rest/swap.rs @@ -0,0 +1,167 @@ +use super::paint::paint_bound; +use super::{Kv, Mapper, Obj, Value, View}; + +/// One part of a layer's look that a swap can leave as a copy of the previous main's: the wire +/// keys that hold it, the REST keys `look` writes for it, and the `styles` slot it links with +/// (and the wire keys of that link). +struct Group { + wire: &'static [&'static str], + rest: &'static [&'static str], + style: Option<(&'static str, &'static str, &'static str)>, + /// The paints key whose own variable bindings `look` draws over the linked style. + bound: Option<&'static str>, +} + +const GROUPS: [Group; 5] = [ + Group { + wire: &["fillPaints", "styleIdForFill", "inheritFillStyleID"], + rest: &["fills"], + style: Some(("fill", "styleIdForFill", "inheritFillStyleID")), + bound: Some("fillPaints"), + }, + Group { + wire: &["strokePaints", "styleIdForStrokeFill", "inheritFillStyleIDForStroke"], + rest: &["strokes"], + style: Some(("stroke", "styleIdForStrokeFill", "inheritFillStyleIDForStroke")), + bound: Some("strokePaints"), + }, + Group { + wire: &[ + "strokeWeight", + "strokeAlign", + "strokeJoin", + "strokeCap", + "dashPattern", + "miterLimit", + "borderStrokeWeightsIndependent", + "borderTopWeight", + "borderRightWeight", + "borderBottomWeight", + "borderLeftWeight", + "variableWidthPoints", + ], + rest: &[ + "strokeWeight", + "strokeAlign", + "strokeJoin", + "strokeCap", + "strokeDashes", + "strokeMiterAngle", + "individualStrokeWeights", + "strokeWidthProfile", + ], + style: None, + bound: None, + }, + Group { + wire: &["effects", "styleIdForEffect", "inheritEffectStyleID"], + rest: &["effects"], + style: Some(("effect", "styleIdForEffect", "inheritEffectStyleID")), + bound: None, + }, + Group { + wire: &[ + "cornerRadius", + "cornerSmoothing", + "rectangleCornerRadiiIndependent", + "rectangleTopLeftCornerRadius", + "rectangleTopRightCornerRadius", + "rectangleBottomRightCornerRadius", + "rectangleBottomLeftCornerRadius", + ], + rest: &["cornerRadius", "cornerSmoothing", "rectangleCornerRadii"], + style: None, + bound: None, + }, +]; + +impl<'a> Mapper<'a> { + /// A nested instance swapped to another main: Figma stores the previous main's look on the + /// instance node, so `common()` drew that. Every group of the look whose stored values are a + /// copy of the previous main's takes the new main's instead; a group the node or a row above + /// it changes is kept. Rows sit in `view` above `base`, so they win in the new look too. + #[allow(clippy::too_many_arguments)] + pub(super) fn swapped_look( + &mut self, + o: &mut Obj, + view: &View<'a>, + base: &Kv<'a>, + old: Option<&'a Kv<'a>>, + new_main: &'a Kv<'a>, + scale: f64, + ty: &str, + ) { + let copied: Vec<&Group> = GROUPS + .iter() + .filter(|g| { + let Some(old) = old else { return true }; + if g.wire.iter().all(|k| base.get(k) == old.get(k)) { + return true; + } + // Own paints bound to variables are drawn over the linked style (look, #399): paints that + // differ from the old main's are an override even under the same link. + if g.bound.is_some_and(|k| base.get(k) != old.get(k) && base.get(k).map(Kv::list).unwrap_or_default().iter().any(paint_bound)) { + return false; + } + // The same linked style is a stale copy of the style's paints: `look` draws the style. + g.style.is_some_and(|(_, key, legacy)| { + let (a, b) = (self.style_link(&View::of(base), key, legacy), self.style_link(&View::of(old), key, legacy)); + a.is_some() && a == b + }) + }) + .collect(); + if old.is_none() { + self.warn("swapped instances whose previous component isn't in the file took the new component's look"); + } + if copied.is_empty() { + return; + } + let look = View::of_scaled(new_main, scale).with(view.layers.iter().copied().zip(view.scales.iter().copied()).skip(1)); + // Warnings from this run repeat those `common()` gave for the same style links; counted twice at worst. + let mut alt = Obj::new(); + self.look(&mut alt, &look, ty); + // A row that sets a group's paints above every layer linking a style wins over that style: it + // was written against a previous main whose paints had no style to detach. That group is + // drawn from the look from that row up. + let top = |k: &str| look.layers.iter().rposition(|l| l.get(k).is_some()); + let mut over_style: Vec<(&Group, Obj)> = Vec::new(); + for g in &copied { + let Some((_, key, legacy)) = g.style else { continue }; + let (Some(p), Some(st)) = (g.wire.first().and_then(|k| top(k)), top(key).max(top(legacy))) else { continue }; + if p <= st { + continue; + } + let (Some(layer), Some(scale)) = (look.layers.get(p), look.scales.get(p)) else { continue }; + let upper = look.layers.iter().copied().zip(look.scales.iter().copied()).skip(p + 1); + let mut from_row = Obj::new(); + self.look(&mut from_row, &View::of_scaled(layer, *scale).with(upper), ty); + over_style.push((*g, from_row)); + } + let alt_styles = alt.get("styles").and_then(Value::as_object).cloned().unwrap_or_default(); + let mut styles = o.get("styles").and_then(Value::as_object).cloned().unwrap_or_default(); + for g in copied { + let row_look = over_style.iter().find(|(r, _)| std::ptr::eq(*r, g)).map(|(_, l)| l); + let (alt, alt_styles) = match row_look { + Some(l) => (l, l.get("styles").and_then(Value::as_object).cloned().unwrap_or_default()), + None => (&alt, alt_styles.clone()), + }; + for k in g.rest { + o.remove(*k); + if let Some(v) = alt.get(*k) { + o.insert((*k).into(), v.clone()); + } + } + if let Some((slot, _, _)) = g.style { + styles.remove(slot); + if let Some(v) = alt_styles.get(slot) { + styles.insert(slot.into(), v.clone()); + } + } + } + if styles.is_empty() { + o.remove("styles"); + } else { + o.insert("styles".into(), Value::Object(styles)); + } + } +} diff --git a/crates/fig/src/rest/swap_tests.rs b/crates/fig/src/rest/swap_tests.rs new file mode 100644 index 000000000..eeb17d047 --- /dev/null +++ b/crates/fig/src/rest/swap_tests.rs @@ -0,0 +1,407 @@ +//! A nested instance swapped to another main takes that main's look, group by group. +#![allow(clippy::unwrap_used, clippy::panic)] + +use serde_json::Value; + +use super::tests::{arr, g, named, o, s, solid}; +use super::*; +use crate::kiwi::{Schema, encode}; + +/// The schema of the fields these files use, with the ids files carry (FigCraft's `.fig` export +/// schema, plus the older `inheritFillStyleID` files from elsewhere can have). +const SCHEMA: &str = " +enum MessageType JOIN_START=0 NODE_CHANGES=1 +enum NodePhase CREATED=0 REMOVED=1 +enum NodeType NONE=0 DOCUMENT=1 CANVAS=2 GROUP=3 FRAME=4 BOOLEAN_OPERATION=5 VECTOR=6 STAR=7 LINE=8 ELLIPSE=9 RECTANGLE=10 REGULAR_POLYGON=11 ROUNDED_RECTANGLE=12 TEXT=13 SLICE=14 SYMBOL=15 INSTANCE=16 SECTION=25 VARIABLE=28 VARIABLE_SET=31 +enum BlendMode PASS_THROUGH=0 NORMAL=1 DARKEN=2 MULTIPLY=3 LINEAR_BURN=4 COLOR_BURN=5 LIGHTEN=6 SCREEN=7 LINEAR_DODGE=8 COLOR_DODGE=9 OVERLAY=10 SOFT_LIGHT=11 HARD_LIGHT=12 DIFFERENCE=13 EXCLUSION=14 HUE=15 SATURATION=16 COLOR=17 LUMINOSITY=18 +enum PaintType SOLID=0 GRADIENT_LINEAR=1 GRADIENT_RADIAL=2 GRADIENT_ANGULAR=3 GRADIENT_DIAMOND=4 IMAGE=5 +enum ImageScaleMode STRETCH=0 FIT=1 FILL=2 TILE=3 +enum WindingRule NONZERO=0 ODD=1 +enum StrokeAlign CENTER=0 INSIDE=1 OUTSIDE=2 +enum StrokeCap NONE=0 ROUND=1 SQUARE=2 ARROW_LINES=3 ARROW_EQUILATERAL=4 TRIANGLE_FILLED=6 DIAMOND_FILLED=5 CIRCLE_FILLED=14 +enum StrokeJoin MITER=0 BEVEL=1 ROUND=2 +enum ConstraintType MIN=0 CENTER=1 MAX=2 STRETCH=3 SCALE=4 +enum StackMode NONE=0 HORIZONTAL=1 VERTICAL=2 GRID=3 +enum StackAlign MIN=0 CENTER=1 MAX=2 BASELINE=3 +enum StackJustify MIN=0 CENTER=1 MAX=2 SPACE_EVENLY=3 SPACE_BETWEEN=4 SPACE_AROUND=5 SPACE_EVENLY_CSS=6 +enum StackCounterAlign MIN=0 CENTER=1 MAX=2 STRETCH=3 AUTO=4 BASELINE=5 +enum StackSize FIXED=0 RESIZE_TO_FIT=1 +enum StackWrap NO_WRAP=0 WRAP=1 +enum StackCounterAlignContent AUTO=0 SPACE_BETWEEN=1 +enum StackPositioning AUTO=0 ABSOLUTE=1 +enum EffectType INNER_SHADOW=0 DROP_SHADOW=1 FOREGROUND_BLUR=2 BACKGROUND_BLUR=3 NOISE=7 GRAIN=6 GLASS=8 +enum BlurOpType NORMAL=0 PROGRESSIVE=1 +enum NoiseType MONOTONE=1 DUOTONE=2 MULTITONE=0 +enum TextAlignHorizontal LEFT=0 CENTER=1 RIGHT=2 JUSTIFIED=3 +enum TextAlignVertical TOP=0 CENTER=1 BOTTOM=2 +enum TextAutoResize NONE=0 WIDTH_AND_HEIGHT=1 HEIGHT=2 +enum TextTruncation DISABLED=0 ENDING=1 +enum TextCase ORIGINAL=0 UPPER=1 LOWER=2 TITLE=3 SMALL_CAPS=4 +enum TextDecoration NONE=0 UNDERLINE=1 STRIKETHROUGH=2 +enum NumberUnits RAW=0 PIXELS=1 PERCENT=2 +enum LineType PLAIN=0 ORDERED_LIST=1 UNORDERED_LIST=2 +enum SourceDirectionality AUTO=0 LTR=1 RTL=2 +enum DocumentColorProfile LEGACY=0 SRGB=1 DISPLAY_P3=2 +enum EmojiImageSet APPLE=0 NOTO=1 +enum MaskType ALPHA=0 OUTLINE=1 LUMINANCE=2 +enum VariableResolvedDataType BOOLEAN=0 FLOAT=1 STRING=2 COLOR=4 SYMBOL_ID=6 TEXT_DATA=8 +enum VectorMirror NONE=0 ANGLE=1 ANGLE_AND_LENGTH=2 +enum BooleanOperation UNION=0 INTERSECT=1 SUBTRACT=2 XOR=3 +enum LayoutGridType MIN=0 CENTER=1 STRETCH=2 MAX=3 +enum LayoutGridPattern STRIPES=0 GRID=1 +enum Axis X=0 Y=1 +enum ImageType PNG=0 JPEG=1 SVG=2 PDF=3 +enum ExportConstraintType CONTENT_SCALE=0 CONTENT_WIDTH=1 CONTENT_HEIGHT=2 +enum ComponentPropType BOOL=0 TEXT=1 INSTANCE_SWAP=3 VARIANT=4 +enum VariableField MISSING=0 CORNER_RADIUS=1 PARAGRAPH_SPACING=2 PARAGRAPH_INDENT=3 STROKE_WEIGHT=4 STACK_SPACING=5 STACK_PADDING_LEFT=6 STACK_PADDING_TOP=7 STACK_PADDING_RIGHT=8 STACK_PADDING_BOTTOM=9 VISIBLE=10 TEXT_DATA=11 WIDTH=12 HEIGHT=13 RECTANGLE_TOP_LEFT_CORNER_RADIUS=14 RECTANGLE_TOP_RIGHT_CORNER_RADIUS=15 RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS=16 RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS=17 BORDER_TOP_WEIGHT=18 BORDER_BOTTOM_WEIGHT=19 BORDER_LEFT_WEIGHT=20 BORDER_RIGHT_WEIGHT=21 STACK_COUNTER_SPACING=23 MIN_WIDTH=24 MAX_WIDTH=25 MIN_HEIGHT=26 MAX_HEIGHT=27 FONT_FAMILY=28 FONT_STYLE=29 OPACITY=31 FONT_SIZE=32 LETTER_SPACING=34 LINE_HEIGHT=36 OVERRIDDEN_SYMBOL_ID=37 +enum VariableDataType BOOLEAN=0 FLOAT=1 STRING=2 ALIAS=3 COLOR=4 SYMBOL_ID=7 TEXT_DATA=9 PROP_REF=13 +enum VariableScope ALL_SCOPES=0 TEXT_CONTENT=1 CORNER_RADIUS=2 WIDTH_HEIGHT=3 GAP=4 ALL_FILLS=5 FRAME_FILL=6 SHAPE_FILL=7 TEXT_FILL=8 STROKE=9 STROKE_FLOAT=10 EFFECT_FLOAT=11 EFFECT_COLOR=12 OPACITY=13 FONT_STYLE=14 FONT_FAMILY=15 FONT_SIZE=16 LINE_HEIGHT=17 LETTER_SPACING=18 PARAGRAPH_SPACING=19 PARAGRAPH_INDENT=20 +enum StyleType NONE=0 FILL=1 STROKE=2 TEXT=3 EFFECT=4 EXPORT=5 GRID=6 +enum InteractionType ON_CLICK=0 DRAG=9 ON_HOVER=4 ON_PRESS=7 MOUSE_IN=2 MOUSE_OUT=3 MOUSE_DOWN=5 MOUSE_UP=6 AFTER_TIMEOUT=1 ON_KEY_DOWN=10 +enum TriggerDevice KEYBOARD=0 UNKNOWN_CONTROLLER=1 +enum ConnectionType NONE=0 INTERNAL_NODE=1 URL=2 BACK=3 CLOSE=4 +enum NavigationType NAVIGATE=0 OVERLAY=1 SWAP=2 SWAP_STATE=3 SCROLL_TO=4 +enum TransitionType INSTANT_TRANSITION=0 DISSOLVE=1 SMART_ANIMATE=24 SLIDE_FROM_LEFT=3 SLIDE_FROM_RIGHT=4 SLIDE_FROM_TOP=5 SLIDE_FROM_BOTTOM=6 PUSH_FROM_LEFT=7 PUSH_FROM_RIGHT=8 PUSH_FROM_TOP=9 PUSH_FROM_BOTTOM=10 MOVE_FROM_LEFT=11 MOVE_FROM_RIGHT=12 MOVE_FROM_TOP=13 MOVE_FROM_BOTTOM=14 SLIDE_OUT_TO_LEFT=15 SLIDE_OUT_TO_RIGHT=16 SLIDE_OUT_TO_TOP=17 SLIDE_OUT_TO_BOTTOM=18 MOVE_OUT_TO_LEFT=19 MOVE_OUT_TO_RIGHT=20 MOVE_OUT_TO_TOP=21 MOVE_OUT_TO_BOTTOM=22 +enum EasingType IN_CUBIC=0 OUT_CUBIC=1 INOUT_CUBIC=2 LINEAR=3 IN_BACK_CUBIC=4 OUT_BACK_CUBIC=5 INOUT_BACK_CUBIC=6 CUSTOM_CUBIC=7 GENTLE_SPRING=9 +enum ScrollDirection NONE=0 HORIZONTAL=1 VERTICAL=2 BOTH=3 +enum ScrollBehavior SCROLLS=0 FIXED_WHEN_CHILD_OF_SCROLLING_FRAME=1 STICKY_SCROLLS=2 +enum OverlayPositionType CENTER=0 TOP_LEFT=1 TOP_CENTER=2 TOP_RIGHT=3 BOTTOM_LEFT=4 BOTTOM_CENTER=5 BOTTOM_RIGHT=6 MANUAL=7 +enum OverlayBackgroundInteraction NONE=0 CLOSE_ON_CLICK_OUTSIDE=1 +enum OverlayBackgroundType NONE=0 SOLID_COLOR=1 +struct GUID uint sessionID, uint localID +struct Color float r, float g, float b, float a +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +struct ColorStop Color color, float position +struct ParentIndex GUID guid, string position +message Image byte[] hash=1, string name=2 +message VariableWidthPoint float position=1, float ascent=2, float descent=3 +message PluginData string pluginID=1, string value=2, string key=3 +message StyleId GUID guid=1 +message VariableID GUID guid=1 +message VariableSetID GUID guid=1 +message ColorStopVar Color color=1, VariableData colorVar=2, float position=3 +message Paint PaintType type=1, Color color=2, float opacity=3, bool visible=4, BlendMode blendMode=5, ColorStop[] stops=6, Matrix transform=7, Image image=8, ImageScaleMode imageScaleMode=10, float rotation=11, float scale=12, VariableData colorVar=21, ColorStopVar[] stopsVar=23 +message Effect EffectType type=1, Color color=2, Vector offset=3, float radius=4, bool visible=5, BlendMode blendMode=6, float spread=7, bool showShadowBehindNode=8, BlurOpType blurOpType=19, float startRadius=21, Vector startOffset=20, Vector endOffset=28, NoiseType noiseType=26, Vector noiseSize=22, float density=25, float opacity=27, Color secondaryColor=24, bool clipToShape=23, VariableData radiusVar=9, VariableData colorVar=10, VariableData spreadVar=11, VariableData xVar=12, VariableData yVar=13, VariableData startRadiusVar=45 +struct FontName string family, string style, string postscript +struct Number float value, NumberUnits units +message Hyperlink string url=1 +message TextLineData LineType lineType=1, int styleId=10, int indentationLevel=2, SourceDirectionality sourceDirectionality=9, int listStartOffset=7, bool isFirstLineOfList=8 +message TextData string characters=1, uint[] characterStyleIDs=2, NodeChange[] styleOverrideTable=3, TextLineData[] lines=12 +message Path WindingRule windingRule=1, uint commandsBlob=2, uint styleID=3 +message VectorData uint vectorNetworkBlob=1, Vector normalizedSize=2, NodeChange[] styleOverrideTable=3 +message ArcData float startingAngle=1, float endingAngle=2, float innerRadius=3 +message OptionalVector Vector value=1 +message LayoutGrid LayoutGridType type=1, Axis axis=2, bool visible=3, int numSections=4, float offset=5, float sectionSize=6, float gutterSize=7, Color color=8, LayoutGridPattern pattern=9, VariableData numSectionsVar=10, VariableData offsetVar=11, VariableData sectionSizeVar=12, VariableData gutterSizeVar=13 +struct ExportConstraint ExportConstraintType type, float value +message ExportSettings string suffix=1, ImageType imageType=2, ExportConstraint constraint=3 +message GUIDPath GUID[] guids=1 +message SymbolData GUID symbolID=1, NodeChange[] symbolOverrides=2, float uniformScaleFactor=3 +message ComponentPropValue bool boolValue=1, TextData textValue=2, GUID guidValue=3 +message ComponentPropPreferredValues string[] stringValues=1 +message ComponentPropDef GUID id=1, string name=2, ComponentPropValue initialValue=3, string sortPosition=4, GUID parentPropDefId=5, ComponentPropType type=6, ComponentPropPreferredValues preferredValues=8, VariableData varValue=9 +message ComponentPropAssignment GUID defID=1, ComponentPropValue value=2, VariableData varValue=3 +enum ComponentPropNodeField VISIBLE=0 TEXT_DATA=1 OVERRIDDEN_SYMBOL_ID=2 +message ComponentPropRef GUID defID=2, ComponentPropNodeField componentPropNodeField=4 +message VariantPropSpec GUID propDefId=1, string value=2 +message StateGroupPropertyValueOrder string property=1, string[] values=2 +message PropRefValue GUID defId=1 +message SymbolId GUID guid=1 +message VariableAnyValue bool boolValue=1, string textValue=2, float floatValue=3, VariableID alias=4, Color colorValue=5, SymbolId symbolIdValue=8, TextData textDataValue=10, PropRefValue propRefValue=13 +message VariableData VariableAnyValue value=1, VariableDataType dataType=2, VariableResolvedDataType resolvedDataType=3 +message VariableDataMapEntry uint nodeField=1, VariableData variableData=2, VariableField variableField=3 +message VariableDataMap VariableDataMapEntry[] entries=1 +message VariableModeBySetMapEntry VariableSetID variableSetID=1, GUID variableModeID=2 +message VariableModeBySetMap VariableModeBySetMapEntry[] entries=1 +message VariableSetMode GUID id=1, string name=2, string sortPosition=3 +message VariableDataValuesEntry GUID modeID=1, VariableData variableData=2 +message VariableDataValues VariableDataValuesEntry[] entries=1 +message KeyTrigger int[] keyCodes=1, TriggerDevice triggerDevice=2 +message PrototypeEvent InteractionType interactionType=1, float transitionTimeout=6, KeyTrigger keyTrigger=4 +message PrototypeAction GUID transitionNodeID=1, TransitionType transitionType=2, float transitionDuration=3, EasingType easingType=4, float[] easingFunction=12, ConnectionType connectionType=7, Vector overlayRelativePosition=9, NavigationType navigationType=10, string connectionURL=8, bool transitionPreserveScroll=11 +message PrototypeInteraction PrototypeEvent event=2, PrototypeAction[] actions=3 +message PrototypeStartingPoint string name=1, string position=3 +message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Color backgroundColor=2 +message GUIDPositionMapEntry GUID id=1, string position=2 +message GUIDPositionMap GUIDPositionMapEntry[] entries=1 +enum GridTrackSizingType FLEX=0 FIXED=1 HUG=2 UNKNOWN=3 +message GridTrackSizingFunction GridTrackSizingType type=1, float value=2 +message GridTrackSize GridTrackSizingFunction minSizing=1, GridTrackSizingFunction maxSizing=2 +message GUIDGridTrackSizeMapEntry GUID id=1, GridTrackSize trackSize=2 +message GUIDGridTrackSizeMap GUIDGridTrackSizeMapEntry[] entries=1 +message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, StateGroupPropertyValueOrder[] stateGroupPropertyValueOrders=238, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GUIDGridTrackSizeMap gridColumnsSizing=474, GUIDGridTrackSizeMap gridRowsSizing=475, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropRef[] componentPropRefs=267, ComponentPropAssignment[] componentPropAssignments=268, VariantPropSpec[] variantPropSpecs=483, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 +struct Blob byte[] bytes +message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6 +"; + +type Fields = Vec<(&'static str, Kv<'static>)>; +type Rgb = (f32, f32, f32); + +/// A node under `parent` (the shared `node` with a position from the id). +fn node(id: u64, parent: Option, ty: &'static str, name: &str, more: Fields) -> Kv<'static> { + let pos = format!("{id:04}"); + super::tests::node(id, parent.map(|p| (p, pos.as_str())), ty, name, more) +} + +fn symbol(main: u64, overrides: Vec>) -> Kv<'static> { + o(vec![("symbolID", g(main)), ("symbolOverrides", arr(overrides))]) +} + +fn row(path: &[u64], more: Fields) -> Kv<'static> { + let mut fields = vec![("guidPath", o(vec![("guids", arr(path.iter().map(|id| g(*id)).collect()))]))]; + fields.extend(more); + o(fields) +} + +fn paints((r, gr, b): Rgb) -> Kv<'static> { + arr(vec![solid(f64::from(r), f64::from(gr), f64::from(b))]) +} + +/// `paints` whose one paint is bound to the variable `var`. +fn bound_paints((r, gr, b): Rgb, var: u64) -> Kv<'static> { + let alias = o(vec![("value", o(vec![("alias", o(vec![("guid", g(var))]))])), ("dataType", Kv::Enum("ALIAS"))]); + let mut paint = match solid(f64::from(r), f64::from(gr), f64::from(b)) { + Kv::Object(fields) => fields, + _ => Vec::new(), + }; + paint.push(("colorVar", alias)); + arr(vec![Kv::Object(paint)]) +} + +fn style_link(id: u64) -> Kv<'static> { + o(vec![("guid", g(id))]) +} + +/// The link a layer writes when its fill is detached from a style. +fn no_style() -> Kv<'static> { + o(vec![("guid", o(vec![("sessionID", Kv::Uint(4_294_967_295)), ("localID", Kv::Uint(4_294_967_295))]))]) +} + +const INFO_FILL: Rgb = (0.1, 0.2, 0.9); +const IDEAL_FILL: Rgb = (0.9, 0.2, 0.1); +const INFO_STROKE: Rgb = (0.1, 0.9, 0.2); +const IDEAL_STROKE: Rgb = (0.5, 0.5, 0.0); +const RED: Rgb = (1.0, 0.0, 0.0); +const BLUE: Rgb = (0.0, 0.0, 1.0); +const BOUND: Rgb = (0.3, 0.6, 0.3); + +/// A variant main: both paints with their styles and a stroke weight, as the file stores them. +/// `fill` None: the fill links no style. +fn variant(id: u64, name: &str, styles: (Option, Rgb, u64, Rgb), weight: f32, child: u64) -> Vec> { + let (fill, fill_rgb, stroke, stroke_rgb) = styles; + let mut fields = vec![("fillPaints", paints(fill_rgb))]; + fields.extend(fill.map(|f| ("styleIdForFill", style_link(f)))); + fields.extend([("strokePaints", paints(stroke_rgb)), ("styleIdForStrokeFill", style_link(stroke)), ("strokeWeight", Kv::Float(weight))]); + vec![node(id, Some(2), "SYMBOL", name, fields), node(child, Some(id), "RECTANGLE", &format!("{name} child"), vec![])] +} + +/// What differs between the payloads: the badge's own fill (None: the copy of its main's), the +/// swap row's extra fields, the main the badge records and whether the Info fill links its style. +struct Case { + badge_fill: Option, + swap_extra: Fields, + recorded_main: u64, + info_fill_style: bool, +} + +impl Case { + fn copy() -> Self { + Self { badge_fill: None, swap_extra: vec![], recorded_main: 10, info_fill_style: true } + } +} + +fn message(case: Case) -> Kv<'static> { + let style = + |id: u64, name: &str, rgb: Rgb| node(id, Some(2), "RECTANGLE", name, vec![("styleType", Kv::Enum("FILL")), ("fillPaints", paints(rgb))]); + let mut changes = vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + style(3, "Info fill", INFO_FILL), + style(4, "Info stroke", INFO_STROKE), + style(5, "Ideal fill", IDEAL_FILL), + style(6, "Ideal stroke", IDEAL_STROKE), + node( + 7, + Some(2), + "VARIABLE_SET", + "Theme", + vec![("variableSetModes", Kv::Array(vec![o(vec![("id", g(90)), ("name", s("Light")), ("sortPosition", s("!"))])]))], + ), + node( + 8, + Some(2), + "VARIABLE", + "badge-bg", + vec![ + ("variableSetID", o(vec![("guid", g(7))])), + ("variableResolvedType", Kv::Enum("COLOR")), + ( + "variableDataValues", + o(vec![( + "entries", + Kv::Array(vec![o(vec![ + ("modeID", g(90)), + ( + "variableData", + o(vec![ + ( + "value", + o(vec![( + "colorValue", + o(vec![("r", Kv::Float(0.3)), ("g", Kv::Float(0.6)), ("b", Kv::Float(0.3)), ("a", Kv::Float(1.0))]), + )]), + ), + ("dataType", Kv::Enum("COLOR")), + ]), + ), + ])]), + )]), + ), + ], + ), + ]; + let info_fill = case.info_fill_style.then_some(3); + changes.extend(variant(10, "Type=Info", (info_fill, INFO_FILL, 4, INFO_STROKE), 1.0, 11)); + changes.extend(variant(20, "Type=Ideal", (Some(5), IDEAL_FILL, 6, IDEAL_STROKE), 2.0, 21)); + changes.push(node(30, Some(2), "SYMBOL", "Card", vec![])); + let mut badge = vec![ + ("symbolData", symbol(case.recorded_main, vec![])), + ("strokePaints", paints(INFO_STROKE)), + ("styleIdForStrokeFill", style_link(4)), + ("strokeWeight", Kv::Float(1.0)), + ]; + match case.badge_fill { + Some(fields) => badge.extend(fields), + None => { + badge.push(("fillPaints", paints(INFO_FILL))); + badge.extend(info_fill.map(|f| ("styleIdForFill", style_link(f)))); + } + } + changes.push(node(31, Some(30), "INSTANCE", "Badge", badge)); + let mut swap = vec![("overriddenSymbolID", g(20))]; + swap.extend(case.swap_extra); + changes.push(node(40, Some(2), "INSTANCE", "Swapped", vec![("symbolData", symbol(30, vec![row(&[31], swap)]))])); + changes.push(node(41, Some(2), "INSTANCE", "Plain", vec![("symbolData", symbol(30, vec![]))])); + o(vec![("nodeChanges", Kv::Array(changes))]) +} + +fn map_case(case: Case) -> Mapped { + let schema = SCHEMA.replace("message NodeChange GUID guid=1,", "message NodeChange GUID guid=1, GUID inheritFillStyleID=501,"); + let schema = Schema::parse(&schema).unwrap(); + // Through the bytes, as a file's message comes. + let root = schema.find("Message").unwrap(); + let bytes = encode::message(&schema, root, &message(case)).unwrap(); + let msg = schema.decode_message(root, &bytes).unwrap(); + to_rest(&msg, "Swap.fig", &Options::default()) +} + +/// The first child of the layer named `outer`: the badge. +fn badge<'j>(json: &'j Value, outer: &str) -> &'j Value { + &named(json, outer)["children"][0] +} + +fn is_rgb(paints: &Value, want: Rgb) -> bool { + let c = &paints[0]["color"]; + [&c["r"], &c["g"], &c["b"]].iter().zip([want.0, want.1, want.2]).all(|(a, b)| a.as_f64().is_some_and(|a| (a - f64::from(b)).abs() < 1e-3)) +} + +/// The name of the style `layer` links in `slot`. +fn style_name<'j>(json: &'j Value, layer: &Value, slot: &str) -> Option<&'j str> { + json["styles"][layer["styles"][slot].as_str()?]["name"].as_str() +} + +#[test] +fn a_swapped_nested_instance_takes_the_new_mains_look() { + let out = map_case(Case::copy()); + let json = &out.json; + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let swapped = badge(json, "Swapped"); + assert!(is_rgb(&swapped["fills"], IDEAL_FILL), "fill {}", swapped["fills"]); + assert!(is_rgb(&swapped["strokes"], IDEAL_STROKE), "stroke {}", swapped["strokes"]); + assert_eq!(style_name(json, swapped, "fill"), Some("Ideal fill")); + assert_eq!(style_name(json, swapped, "stroke"), Some("Ideal stroke")); + assert_eq!(swapped["strokeWeight"], 2.0); + assert_eq!(swapped["children"][0]["name"], "Type=Ideal child"); + let plain = badge(json, "Plain"); + assert!(is_rgb(&plain["fills"], INFO_FILL)); + assert_eq!(style_name(json, plain, "fill"), Some("Info fill")); + assert_eq!(style_name(json, plain, "stroke"), Some("Info stroke")); + assert_eq!(plain["strokeWeight"], 1.0); +} + +#[test] +fn a_real_override_on_the_nested_instance_survives_the_swap() { + let out = map_case(Case { badge_fill: Some(vec![("fillPaints", paints(RED))]), ..Case::copy() }); + let json = &out.json; + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let swapped = badge(json, "Swapped"); + assert!(is_rgb(&swapped["fills"], RED), "fill {}", swapped["fills"]); + assert!(swapped["styles"].get("fill").is_none()); + assert!(is_rgb(&swapped["strokes"], IDEAL_STROKE), "stroke {}", swapped["strokes"]); + assert_eq!(style_name(json, swapped, "stroke"), Some("Ideal stroke")); + assert!(is_rgb(&badge(json, "Card")["fills"], RED)); +} + +#[test] +fn a_row_above_the_swap_beats_the_new_main() { + let out = map_case(Case { swap_extra: vec![("fillPaints", paints(BLUE)), ("styleIdForFill", no_style())], ..Case::copy() }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let swapped = badge(&out.json, "Swapped"); + assert!(is_rgb(&swapped["fills"], BLUE), "fill {}", swapped["fills"]); + assert!(is_rgb(&swapped["strokes"], IDEAL_STROKE), "stroke {}", swapped["strokes"]); +} + +#[test] +fn a_fill_row_without_a_style_link_beats_the_new_mains_style() { + // The previous main's fill links no style, so a row that recolours the badge has none to + // detach: its paints must still win over the style the new main links. + let out = map_case(Case { info_fill_style: false, swap_extra: vec![("fillPaints", paints(BLUE))], ..Case::copy() }); + let json = &out.json; + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let swapped = badge(json, "Swapped"); + assert!(is_rgb(&swapped["fills"], BLUE), "fill {}", swapped["fills"]); + assert!(swapped["styles"].get("fill").is_none()); + assert!(is_rgb(&swapped["strokes"], IDEAL_STROKE), "stroke {}", swapped["strokes"]); + assert_eq!(style_name(json, swapped, "stroke"), Some("Ideal stroke")); +} + +#[test] +fn a_rows_older_style_field_beats_the_new_mains_newer_one() { + // The older `inheritFillStyleID` on the swap row is the topmost word on the fill's style: a + // detach keeps the row's paints, a link takes that style. + let detach = o(vec![("sessionID", Kv::Uint(4_294_967_295)), ("localID", Kv::Uint(4_294_967_295))]); + let out = map_case(Case { swap_extra: vec![("fillPaints", paints(BLUE)), ("inheritFillStyleID", detach)], ..Case::copy() }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let swapped = badge(&out.json, "Swapped"); + assert!(is_rgb(&swapped["fills"], BLUE), "fill {}", swapped["fills"]); + assert!(swapped["styles"].get("fill").is_none()); + let out = map_case(Case { swap_extra: vec![("inheritFillStyleID", g(3))], ..Case::copy() }); + let swapped = badge(&out.json, "Swapped"); + assert!(is_rgb(&swapped["fills"], INFO_FILL), "fill {}", swapped["fills"]); + assert_eq!(style_name(&out.json, swapped, "fill"), Some("Info fill")); +} + +#[test] +fn a_swap_whose_previous_main_is_missing_takes_the_new_look() { + let out = map_case(Case { recorded_main: 99, ..Case::copy() }); + assert_eq!(out.warnings.iter().filter(|n| n.contains("previous component isn't in the file")).count(), 1, "{:?}", out.warnings); + let swapped = badge(&out.json, "Swapped"); + assert!(is_rgb(&swapped["fills"], IDEAL_FILL), "fill {}", swapped["fills"]); + assert!(is_rgb(&swapped["strokes"], IDEAL_STROKE), "stroke {}", swapped["strokes"]); +} + +#[test] +fn a_bound_fill_under_the_old_mains_style_link_survives_the_swap() { + // The badge keeps the old main's fill style link but binds its own fill to a variable: the + // binding is drawn over the style, so the swap must not replace it with the new main's fill. + let bound = vec![("fillPaints", bound_paints(BOUND, 8)), ("styleIdForFill", style_link(3))]; + let out = map_case(Case { badge_fill: Some(bound), ..Case::copy() }); + let json = &out.json; + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let swapped = badge(json, "Swapped"); + assert!(is_rgb(&swapped["fills"], BOUND), "fill {}", swapped["fills"]); + assert_eq!(swapped["fills"][0]["boundVariables"]["color"]["id"], "1:8"); + assert_eq!(style_name(json, swapped, "fill"), Some("Info fill")); + assert!(is_rgb(&swapped["strokes"], IDEAL_STROKE), "stroke {}", swapped["strokes"]); + assert_eq!(swapped["strokeWeight"], 2.0); +} diff --git a/crates/fig/src/rest/tests.rs b/crates/fig/src/rest/tests.rs new file mode 100644 index 000000000..92cf9aee6 --- /dev/null +++ b/crates/fig/src/rest/tests.rs @@ -0,0 +1,752 @@ +//! The mapping on a message built in code: a small schema in the compact text form (FigCraft's +//! `.fig` fixture), mapped onto the REST shape and read back with craft-figma-rest. +#![allow(clippy::unwrap_used, clippy::panic)] + +use std::cell::RefCell; + +use craft_figma_rest::{File, NodeType}; +use serde_json::Value; + +use super::*; +use crate::kiwi::{Schema, encode}; + +/// The fixture schema: `enum Name A B …`, `struct|message Name type field, type field[] …`. +/// Message field ids count from 1; enum values from 0. +const SCHEMA: &str = " +enum NodeType NONE DOCUMENT CANVAS GROUP FRAME BOOLEAN_OPERATION VECTOR STAR LINE ELLIPSE RECTANGLE REGULAR_POLYGON ROUNDED_RECTANGLE TEXT SLICE SYMBOL INSTANCE STICKY VARIABLE VARIABLE_SET SHAPE_WITH_TEXT CONNECTOR TABLE +enum PaintType SOLID GRADIENT_LINEAR IMAGE EMOJI +enum ImageScaleMode STRETCH FIT FILL TILE +enum WindingRule NONZERO ODD +enum StyleType NONE FILL TEXT +enum StackMode NONE HORIZONTAL VERTICAL +enum PropType BOOL TEXT +enum VariableField MISSING VISIBLE TEXT_DATA +enum DataType BOOLEAN FLOAT STRING ALIAS COLOR PROP_REF EXPRESSION +enum ResolvedType BOOLEAN FLOAT STRING COLOR +enum InteractionType ON_CLICK ON_HOVER +enum ConnectionType NONE INTERNAL_NODE URL BACK CLOSE SET_VARIABLE CONDITIONAL SET_VARIABLE_MODE +enum StrokeCap NONE ROUND SQUARE ARROW_LINES ARROW_EQUILATERAL +enum StrokeJoin MITER BEVEL ROUND +enum VectorMirror NONE ANGLE ANGLE_AND_LENGTH +enum ShapeWithTextType SQUARE ELLIPSE +enum NodePhase CREATED REMOVED +enum ConnectorLineStyle ELBOWED STRAIGHT CURVED +enum ConnectorMagnet NONE AUTO TOP LEFT BOTTOM RIGHT CENTER +enum ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQUALS +enum NavigationType NAVIGATE OVERLAY +enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE +enum EasingType IN_CUBIC OUT_CUBIC +enum ConstraintType MIN CENTER MAX STRETCH SCALE +enum LeadingTrim NONE CAP_HEIGHT +struct GUID uint sessionID, uint localID +struct Color float r, float g, float b, float a +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +struct ColorStop Color color, float position +message Image byte[] hash, string name, uint dataBlob +message AssetRef string key, string version +message VariableID GUID guid, AssetRef assetRef +message PropRefValue GUID defId +message AnyValue bool boolValue, float floatValue, VariableID alias, Color colorValue, PropRefValue propRefValue, TextData textDataValue, string textValue, Expression expressionValue +message Expression ExpressionFunction expressionFunction, VariableData[] expressionArguments +message VariableTarget VariableID id +message VariableSetID GUID guid, AssetRef assetRef +message ConditionalActions Action[] actions, VariableData condition +message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable +message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition, ConnectorMagnet magnet +message ConnectorControlPoint Vector position, Vector axis +message VariableData AnyValue value, DataType dataType +message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar, ImageScaleMode imageScaleMode +message ParentIndex GUID guid, string position +message FontName string family, string style, string postscript +message TextData string characters, uint[] characterStyleIDs, NodeChange[] styleOverrideTable +message Path WindingRule windingRule, uint commandsBlob +message GUIDPath GUID[] guids +message SymbolData GUID symbolID, NodeChange[] symbolOverrides, float uniformScaleFactor +message StyleId GUID guid, AssetRef assetRef +message PropValue bool boolValue, TextData textValue +message PropDef GUID id, string name, PropValue initialValue, PropType type +message PropAssignment GUID defID, PropValue value +message MapEntry VariableData variableData, VariableField variableField +message VariableDataMap MapEntry[] entries +message SetMode GUID id, string name, string sortPosition +message ValuesEntry GUID modeID, VariableData variableData +message Values ValuesEntry[] entries +message ModeEntry VariableID variableSetID, GUID variableModeID +message ModeMap ModeEntry[] entries +message Event InteractionType interactionType +message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions +message Interaction Event event, Action[] actions +message StartingPoint string name, string position +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, LeadingTrim leadingTrim, float[] dashPattern, ConnectorControlPoint[] connectorControlPoints, NodePhase phase, GUID overriddenSymbolID +message Blob byte[] bytes +message Message NodeChange[] nodeChanges, Blob[] blobs +"; + +fn schema() -> Schema { + Schema::parse(SCHEMA).unwrap() +} + +pub(super) fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} +fn f(x: f64) -> Kv<'static> { + Kv::Float(x as f32) +} +fn u(x: u64) -> Kv<'static> { + Kv::Uint(x) +} +fn e(n: &'static str) -> Kv<'static> { + Kv::Enum(n) +} +pub(super) fn s(x: impl Into) -> Kv<'static> { + Kv::Str(x.into()) +} +pub(super) fn g(l: u64) -> Kv<'static> { + o(vec![("sessionID", u(1)), ("localID", u(l))]) +} +pub(super) fn arr(v: Vec>) -> Kv<'static> { + Kv::Array(v) +} +fn xy(x: f64, y: f64) -> Kv<'static> { + o(vec![("x", f(x)), ("y", f(y))]) +} +fn at(x: f64, y: f64) -> Kv<'static> { + o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(x)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(y))]) +} +fn rgb(r: f64, gr: f64, b: f64) -> Kv<'static> { + o(vec![("r", f(r)), ("g", f(gr)), ("b", f(b)), ("a", f(1.0))]) +} +pub(super) fn solid(r: f64, gr: f64, b: f64) -> Kv<'static> { + o(vec![("type", e("SOLID")), ("color", rgb(r, gr, b)), ("opacity", f(1.0)), ("visible", Kv::Bool(true))]) +} +fn root_guid() -> Kv<'static> { + o(vec![("sessionID", u(0)), ("localID", u(0))]) +} +fn alias_data(l: u64) -> Kv<'static> { + o(vec![("value", o(vec![("alias", o(vec![("guid", g(l))]))])), ("dataType", e("ALIAS"))]) +} +fn prop_ref(def: u64, field: &'static str) -> Kv<'static> { + let data = o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(def))]))])), ("dataType", e("PROP_REF"))]); + o(vec![("entries", arr(vec![o(vec![("variableData", data), ("variableField", e(field))])]))]) +} + +/// A node: id (0: the document), parent (id, position), type, name and further fields. +pub(super) fn node(id: u64, par: Option<(u64, &str)>, ty: &'static str, name: &str, mut more: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut v = vec![("guid", if id == 0 { root_guid() } else { g(id) })]; + if let Some((p, pos)) = par { + v.push(("parentIndex", o(vec![("guid", if p == 0 { root_guid() } else { g(p) }), ("position", s(pos))]))); + } + v.push(("type", e(ty))); + v.push(("name", s(name))); + v.append(&mut more); + o(v) +} + +/// A path blob: a closed triangle. +fn triangle_blob() -> Vec { + let mut b = Vec::new(); + for (cmd, pts) in [(1u8, vec![0.0f32, 0.0]), (2, vec![10.0, 0.0]), (2, vec![5.0, 8.0]), (0, vec![])] { + b.push(cmd); + for p in pts { + b.extend_from_slice(&p.to_le_bytes()); + } + } + b +} + +const HASH: [u8; 20] = [0xab; 20]; + +/// The fixture message: a page with a frame holding a rectangle (image fill, click interaction), +/// a two-style text, a group with a triangle vector and a star, a layer with a linked fill style, +/// one with a variable-bound fill, and an instance of a component with a boolean property +/// (property off, child renamed); a flow and an explicit variable mode; an unsupported sticky +/// note; styles and variables on the internal page. +fn message() -> Kv<'static> { + let modes = arr(vec![ + o(vec![("id", g(90)), ("name", s("Light")), ("sortPosition", s("!"))]), + o(vec![("id", g(91)), ("name", s("Dark")), ("sortPosition", s("\""))]), + ]); + let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]); + let values = o(vec![( + "entries", + arr(vec![ + o(vec![("modeID", g(90)), ("variableData", color_value(rgb(1.0, 1.0, 1.0)))]), + o(vec![("modeID", g(91)), ("variableData", color_value(rgb(0.0, 0.0, 0.0)))]), + ]), + )]); + let click = o(vec![ + ("event", o(vec![("interactionType", e("ON_CLICK"))])), + ( + "actions", + arr(vec![o(vec![ + ("transitionNodeID", g(20)), + ("transitionType", e("SMART_ANIMATE")), + ("transitionDuration", f(0.25)), + ("easingType", e("OUT_CUBIC")), + ("connectionType", e("INTERNAL_NODE")), + ("navigationType", e("NAVIGATE")), + ])]), + ), + ]); + let font = |style: &str| o(vec![("family", s("Inter")), ("style", s(style)), ("postscript", s(""))]); + let text = o(vec![ + ("characters", s("Hi there")), + ("characterStyleIDs", arr(vec![u(0), u(0), u(0), u(5), u(5), u(5)])), + ("styleOverrideTable", arr(vec![o(vec![("fontName", font("Bold")), ("styleID", u(5))])])), + ]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page A", vec![]), + node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + node(3, Some((2, "!")), "RECTANGLE", "Brand", vec![("styleType", e("FILL")), ("fillPaints", arr(vec![solid(0.0, 0.0, 1.0)]))]), + node(4, Some((2, "\"")), "VARIABLE_SET", "Theme", vec![("variableSetModes", modes)]), + node( + 5, + Some((2, "#")), + "VARIABLE", + "surface", + vec![("variableSetID", o(vec![("guid", g(4))])), ("variableResolvedType", e("COLOR")), ("variableDataValues", values)], + ), + // Listed before the frame, but its position puts it second. + node( + 20, + Some((1, "\"")), + "SYMBOL", + "Button", + vec![ + ("size", xy(100.0, 40.0)), + ("transform", at(500.0, 0.0)), + ("fillPaints", arr(vec![solid(0.0, 1.0, 0.0)])), + ( + "componentPropDefs", + arr(vec![o(vec![ + ("id", g(80)), + ("name", s("Show icon")), + ("initialValue", o(vec![("boolValue", Kv::Bool(true))])), + ("type", e("BOOL")), + ])]), + ), + ("stackMode", e("HORIZONTAL")), + ("stackSpacing", f(8.0)), + ("stackHorizontalPadding", f(12.0)), + ("stackVerticalPadding", f(4.0)), + ], + ), + node( + 21, + Some((20, "!")), + "ELLIPSE", + "Icon", + vec![("size", xy(16.0, 16.0)), ("transform", at(12.0, 12.0)), ("parameterConsumptionMap", prop_ref(80, "VISIBLE"))], + ), + node( + 10, + Some((1, "!")), + "FRAME", + "Screen", + vec![ + ("size", xy(400.0, 300.0)), + ("fillPaints", arr(vec![solid(1.0, 1.0, 1.0)])), + ("prototypeStartingPoint", o(vec![("name", s("Main flow")), ("position", s("!"))])), + ( + "variableModeBySetMap", + o(vec![("entries", arr(vec![o(vec![("variableSetID", o(vec![("guid", g(4))])), ("variableModeID", g(91))])]))]), + ), + ], + ), + node( + 11, + Some((10, "!")), + "ROUNDED_RECTANGLE", + "Photo", + vec![ + ("size", xy(50.0, 40.0)), + ("transform", at(10.0, 20.0)), + ("cornerRadius", f(6.0)), + ("fillPaints", arr(vec![o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))]))])])), + ("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])), + ("strokeWeight", f(2.0)), + ("prototypeInteractions", arr(vec![click])), + ], + ), + node( + 12, + Some((10, "\"")), + "TEXT", + "Title", + vec![ + ("size", xy(120.0, 20.0)), + ("transform", at(10.0, 80.0)), + ("fillPaints", arr(vec![solid(0.0, 0.0, 0.0)])), + ("fontSize", f(16.0)), + ("fontName", font("Regular")), + ("leadingTrim", e("CAP_HEIGHT")), + ("textData", text), + ], + ), + node(13, Some((10, "#")), "FRAME", "Group", vec![("size", xy(30.0, 30.0)), ("transform", at(100.0, 100.0)), ("resizeToFit", Kv::Bool(true))]), + node( + 14, + Some((13, "!")), + "VECTOR", + "Tri", + vec![ + ("size", xy(10.0, 8.0)), + ("transform", at(5.0, 6.0)), + ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])), + ("fillGeometry", arr(vec![o(vec![("windingRule", e("NONZERO")), ("commandsBlob", u(0))])])), + ], + ), + node( + 15, + Some((13, "\"")), + "STAR", + "Star", + vec![("size", xy(20.0, 20.0)), ("transform", at(10.0, 10.0)), ("count", u(6)), ("starInnerScale", f(0.5))], + ), + node( + 16, + Some((10, "$")), + "ROUNDED_RECTANGLE", + "Styled", + vec![ + ("size", xy(10.0, 10.0)), + ("transform", at(0.0, 200.0)), + // A stale copy of the style's paint: the style wins. + ("fillPaints", arr(vec![solid(0.5, 0.5, 0.5)])), + ("styleIdForFill", o(vec![("guid", g(3))])), + ], + ), + node( + 17, + Some((10, "%")), + "ROUNDED_RECTANGLE", + "Card", + vec![ + ("size", xy(10.0, 10.0)), + ("transform", at(20.0, 200.0)), + ("fillPaints", arr(vec![o(vec![("type", e("SOLID")), ("color", rgb(0.0, 0.0, 0.0)), ("colorVar", alias_data(5))])])), + ], + ), + node( + 18, + Some((10, "&")), + "INSTANCE", + "Button copy", + vec![ + ("size", xy(100.0, 40.0)), + ("transform", at(200.0, 200.0)), + ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])), + ("stackMode", e("HORIZONTAL")), + ( + "symbolData", + o(vec![ + ("symbolID", g(20)), + ("symbolOverrides", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(21)]))])), ("name", s("Renamed icon"))])])), + ]), + ), + ("componentPropAssignments", arr(vec![o(vec![("defID", g(80)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ], + ), + node(19, Some((10, "'")), "STICKY", "Note", vec![]), + ]; + o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![o(vec![("bytes", Kv::Bytes(triangle_blob()))])]))]) +} + +fn mapped(msg: &Kv) -> Mapped { + to_rest(msg, "Fixture", &Options::default()) +} + +/// A node of the mapped document by name. +pub(super) fn named<'v>(json: &'v Value, name: &str) -> &'v Value { + let mut pending = vec![&json["document"]]; + while let Some(n) = pending.pop() { + if n["name"] == name { + return n; + } + pending.extend(n["children"].as_array().into_iter().flatten()); + } + panic!("no layer {name}") +} + +#[test] +fn the_fixture_maps_onto_the_rest_shape() { + let msg = message(); + let out = mapped(&msg); + let json = &out.json; + assert_eq!(json["name"], "Fixture"); + // The internal page is skipped; siblings by position. + let pages = json["document"]["children"].as_array().unwrap(); + assert_eq!(pages.len(), 1); + assert_eq!((pages[0]["type"].as_str(), pages[0]["name"].as_str()), (Some("CANVAS"), Some("Page A"))); + assert_eq!(pages[0]["children"][0]["name"], "Screen"); + let names: Vec<&str> = named(json, "Screen")["children"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy", "Note"]); + // An image fill names its image by the hash in hex; corner and stroke come along. + let photo = named(json, "Photo"); + assert_eq!(photo["fills"][0]["type"], "IMAGE"); + assert_eq!(photo["fills"][0]["imageRef"], "ab".repeat(20)); + assert_eq!((photo["cornerRadius"].as_f64(), photo["strokeWeight"].as_f64()), (Some(6.0), Some(2.0))); + assert_eq!(photo["interactions"][0]["actions"][0]["destinationId"], "1:20"); + // Text: the bold run over "the" is style 5. + let title = named(json, "Title"); + assert_eq!(title["characters"], "Hi there"); + assert_eq!(title["style"]["fontSize"], 16.0); + let runs: Vec = title["characterStyleOverrides"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + assert_eq!(runs, [0.0, 0.0, 0.0, 5.0, 5.0, 5.0]); + assert_eq!(title["styleOverrideTable"]["5"]["fontStyle"], "Bold"); + // The vector's outline as SVG path data; the star's points and inner radius (extensions). + let tri = named(json, "Tri"); + assert_eq!(tri["fillGeometry"][0]["path"], "M 0 0 L 10 0 L 5 8 Z"); + let star = named(json, "Star"); + assert_eq!((star["type"].as_str(), star["pointCount"].as_f64(), star["innerRadius"].as_f64()), (Some("STAR"), Some(6.0), Some(0.5))); + // Shared style, variable binding, explicit mode. + assert_eq!(named(json, "Styled")["styles"]["fill"], "1:3"); + assert_eq!(json["styles"]["1:3"]["name"], "Brand"); + assert_eq!(named(json, "Card")["fills"][0]["boundVariables"]["color"]["id"], "1:5"); + assert_eq!(named(json, "Screen")["explicitVariableModes"]["1:4"], "1:91"); + // A component, and an instance of it with its property off and its child renamed. + assert_eq!(named(json, "Button")["type"], "COMPONENT"); + let inst = named(json, "Button copy"); + assert_eq!((inst["type"].as_str(), inst["componentId"].as_str()), (Some("INSTANCE"), Some("1:20"))); + assert_eq!(inst["children"][0]["name"], "Renamed icon"); + assert_eq!(inst["children"][0]["visible"], false); + // A flow; the sticky note as a frame, with a warning. + assert_eq!(pages[0]["flowStartingPoints"][0]["nodeId"], "1:10"); + assert_eq!(named(json, "Note")["type"], "FRAME"); + assert!(out.warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{:?}", out.warnings); +} + +#[test] +fn the_result_reads_as_the_typed_model() { + let msg = message(); + let out = mapped(&msg); + let file = File::from_value(out.json.clone(), 1000).unwrap(); + let document = file.document.as_ref().unwrap(); + assert_eq!(document.kind, Some(NodeType::Document)); + let tri = document.walk().find(|n| n.name.as_deref() == Some("Tri")).unwrap(); + assert_eq!(tri.kind, Some(NodeType::Vector)); + assert_eq!(tri.fill_geometry.as_ref().unwrap()[0].winding_rule.as_deref(), Some("NONZERO")); + let star = document.walk().find(|n| n.name.as_deref() == Some("Star")).unwrap(); + assert_eq!(star.extra["pointCount"], 6.0); + // Written back, the typed model holds the same file (whole numbers aside). + let back = file.to_value().unwrap(); + assert_eq!(File::from_value(back, 1000).unwrap(), file); +} + +#[test] +fn vector_networks_are_handed_over_beside_the_json() { + // A vector with a network blob: three points, a closed region. + let blob = crate::network::encode(&crate::network::Network { + vertices: vec![ + crate::network::Vertex { style: 0, x: 0.0, y: 0.0 }, + crate::network::Vertex { style: 0, x: 1.0, y: 0.0 }, + crate::network::Vertex { style: 0, x: 0.5, y: 1.0 }, + ], + segments: (0..3).map(|i| crate::network::Segment { style: 0, a: i, ta: (0.0, 0.0), b: (i + 1) % 3, tb: (0.0, 0.0) }).collect(), + regions: vec![crate::network::Region { style: 0, non_zero: true, loops: vec![vec![0, 1, 2]] }], + }); + let vd = o(vec![("vectorNetworkBlob", u(0)), ("normalizedSize", xy(1.0, 1.0))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(2, Some((1, "!")), "VECTOR", "V", vec![("size", xy(20.0, 10.0)), ("vectorData", vd)]), + ]; + let msg = o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![o(vec![("bytes", Kv::Bytes(blob))])]))]); + let out = mapped(&msg); + assert_eq!(named(&out.json, "V")["vectorNetworkRef"], 0); + let net = out.networks[0].as_ref().unwrap(); + // Scaled from the normalized size to the layer's. + assert_eq!((net.vertices[1].x, net.vertices[2].y), (20.0, 10.0)); + assert_eq!(net.regions[0].loops, [vec![0, 1, 2]]); + assert!(net.is_consistent()); +} + +/// Records the connector ends it is asked to route, and routes them straight. +#[derive(Default)] +struct Recording(RefCell>); + +impl Geometry for Recording { + fn route(&self, from: RouteEnd, to: RouteEnd, routing: Routing, saved: Option<&SavedRuns>) -> Option> { + self.0.borrow_mut().push((from, to, routing)); + Straight.route(from, to, routing, saved) + } + + fn attach(&self, from: RouteEnd, to: RouteEnd) -> ((Point, Option), (Point, Option)) { + Straight.attach(from, to) + } +} + +#[test] +fn connectors_are_routed_by_the_callers_geometry() { + let end = |x: f64, y: f64| o(vec![("position", xy(x, y))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(2, Some((1, "!")), "RECTANGLE", "Box", vec![("size", xy(40.0, 20.0)), ("transform", at(100.0, 100.0))]), + node( + 3, + Some((1, "\"")), + "CONNECTOR", + "Line", + vec![ + ("connectorStart", end(10.0, 20.0)), + ("connectorEnd", o(vec![("endpointNodeID", g(2)), ("magnet", e("LEFT"))])), + ("connectorLineStyle", e("ELBOWED")), + ], + ), + ]; + let msg = o(vec![("nodeChanges", arr(nodes))]); + let geometry = Recording::default(); + let out = to_rest(&msg, "x", &Options { geometry: &geometry, ..Options::default() }); + let calls = geometry.0.borrow(); + assert_eq!(calls.len(), 1); + assert_eq!( + calls[0], + (RouteEnd::Point(Point::new(10.0, 20.0)), RouteEnd::Box(kurbo::Rect::new(100.0, 100.0, 140.0, 120.0), Side::Left), Routing::Elbow) + ); + // The straight route the geometry gave: from the free end to the middle of the box's left side. + let line = named(&out.json, "Line"); + assert_eq!(line["size"], json!({"x": 90.0, "y": 90.0})); + assert_eq!(line["vectorNetwork"]["segments"].as_array().map(Vec::len), Some(1)); + assert!(out.warnings.iter().any(|w| w.contains("routed afresh")), "{:?}", out.warnings); +} + +#[test] +fn the_outlined_stroke_note_is_read_only_under_the_key_given() { + let plugin = o(vec![("pluginID", s("writer")), ("key", s("stroke")), ("value", s("{\"weight\":3}"))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(2, Some((1, "!")), "RECTANGLE", "R", vec![("size", xy(4.0, 4.0)), ("pluginData", arr(vec![plugin]))]), + ]; + let msg = o(vec![("nodeChanges", arr(nodes))]); + assert!(named(&mapped(&msg).json, "R").get("outlinedStroke").is_none()); + let out = to_rest(&msg, "x", &Options { outlined_stroke: Some(("writer", "stroke")), ..Options::default() }); + assert_eq!(named(&out.json, "R")["outlinedStroke"], json!({"weight": 3})); +} + +#[test] +fn no_variable_table_outlives_the_mapping() { + let msg = message(); + let _ = mapped(&msg); + assert!(!VarCanon::installed()); +} + +#[test] +fn the_layer_cap_holds() { + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.extend((2..12).map(|i| node(i, Some((1, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + let msg = o(vec![("nodeChanges", arr(nodes))]); + let out = to_rest(&msg, "x", &Options { max_nodes: 5, ..Options::default() }); + assert!(out.warnings.iter().any(|w| w.contains("more than 5 layers")), "{:?}", out.warnings); + assert!(!mapped(&msg).warnings.iter().any(|w| w.contains("more than"))); + // The budget follows the compressed canvas, between its floor and ceiling. + assert_eq!(layer_budget(0), MAX_NODES); + assert!(layer_budget(83_491_232) > 894_004); + assert_eq!(layer_budget(usize::MAX), MAX_FIG_NODES); +} + +struct Lcg(u64); +impl Lcg { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + self.0 >> 33 + } +} + +#[test] +fn mangled_messages_never_panic() { + let sc = schema(); + let root = sc.find("Message").unwrap(); + let bytes = encode::message(&sc, root, &message()).unwrap(); + let mut r = Lcg(11); + for cut in (0..bytes.len()).step_by(3) { + if let Ok(kv) = sc.decode_message(root, &bytes[..cut]) { + let _ = mapped(&kv); + } + } + for _ in 0..1500 { + let mut m = bytes.clone(); + for _ in 0..1 + r.next() % 4 { + let i = (r.next() as usize) % m.len(); + m[i] = r.next() as u8; + } + if let Ok(kv) = sc.decode_message(root, &m) { + let out = mapped(&kv); + assert!(craft_figma_rest::check(&out.json, usize::MAX).is_ok()); + } + } +} + +/// The GUID written for "none". +fn empty_guid() -> Kv<'static> { + o(vec![("sessionID", u(4_294_967_295)), ("localID", u(4_294_967_295))]) +} + +fn symbol(main: u64, overrides: Vec>) -> Kv<'static> { + o(vec![("symbolID", g(main)), ("symbolOverrides", arr(overrides))]) +} + +fn row(path: &[u64], more: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut fields = vec![("guidPath", o(vec![("guids", arr(path.iter().map(|id| g(*id)).collect()))]))]; + fields.extend(more); + o(fields) +} + +/// A Badge main with a label, an Error variant, a Card main holding a Badge instance whose +/// `overriddenSymbolID` is the empty GUID and a second one the Card swaps to Error, and a Card +/// instance whose override rows write the empty GUID over both. +fn cards() -> Mapped { + let n = |id: u64, parent: u64, ty: &'static str, name: &str, more| node(id, Some((parent, &format!("{id:04}"))), ty, name, more); + let msg = o(vec![( + "nodeChanges", + arr(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + n(2, 1, "CANVAS", "Page", vec![]), + n(10, 2, "SYMBOL", "Property 1=Informative", vec![]), + n(11, 10, "RECTANGLE", "Label", vec![]), + n(20, 2, "SYMBOL", "Property 1=Error", vec![]), + n(21, 20, "RECTANGLE", "Error label", vec![]), + n(30, 2, "SYMBOL", "Card", vec![]), + n(31, 30, "INSTANCE", "Badge", vec![("symbolData", symbol(10, vec![])), ("overriddenSymbolID", empty_guid())]), + n(32, 30, "INSTANCE", "Swapped badge", vec![("symbolData", symbol(10, vec![])), ("overriddenSymbolID", g(20))]), + n( + 40, + 2, + "INSTANCE", + "Card copy", + vec![( + "symbolData", + symbol(30, vec![row(&[31], vec![("overriddenSymbolID", empty_guid())]), row(&[32], vec![("overriddenSymbolID", empty_guid())])]), + )], + ), + ]), + )]); + mapped(&msg) +} + +/// The main of the `index`th child of `card`. +fn badge_main(json: &Value, card: &str, index: usize) -> Option { + let badge = &named(json, card)["children"][index]; + (badge["type"] == "INSTANCE").then(|| badge["componentId"].as_str().map(str::to_string)).flatten() +} + +#[test] +fn an_empty_swap_guid_keeps_the_instance_of_its_main() { + let out = cards(); + assert!(out.warnings.is_empty(), "no missing components: {:?}", out.warnings); + for card in ["Card", "Card copy"] { + assert_eq!(badge_main(&out.json, card, 0).as_deref(), Some("1:10"), "{card}"); + assert_eq!(named(&out.json, card)["children"][0]["children"][0]["name"], "Label", "{card}: the main's layers"); + } +} + +#[test] +fn an_empty_swap_guid_on_a_row_resets_a_swap_below_it_to_the_default_main() { + let out = cards(); + // In the Card main the badge is swapped to Error; the Card instance's row writes the empty + // GUID over that swap: back to the main its symbolData names. + assert_eq!(badge_main(&out.json, "Card", 1).as_deref(), Some("1:20")); + assert_eq!(badge_main(&out.json, "Card copy", 1).as_deref(), Some("1:10")); +} + +/// A three-point network blob whose points have no style of their own. +fn triangle_network() -> Vec { + crate::network::encode(&crate::network::Network { + vertices: vec![ + crate::network::Vertex { style: 0, x: 0.0, y: 0.0 }, + crate::network::Vertex { style: 0, x: 1.0, y: 0.0 }, + crate::network::Vertex { style: 0, x: 0.5, y: 1.0 }, + ], + segments: (0..3).map(|i| crate::network::Segment { style: 0, a: i, ta: (0.0, 0.0), b: (i + 1) % 3, tb: (0.0, 0.0) }).collect(), + regions: vec![crate::network::Region { style: 0, non_zero: true, loops: vec![vec![0, 1, 2]] }], + }) +} + +#[test] +fn points_without_a_radius_of_their_own_take_the_layers() { + let vd = || o(vec![("vectorNetworkBlob", u(0)), ("normalizedSize", xy(1.0, 1.0))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(2, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(20.0, 20.0))]), + node(3, Some((2, "!")), "VECTOR", "V", vec![("size", xy(20.0, 20.0)), ("vectorData", vd()), ("cornerRadius", f(4.0))]), + // An instance overriding the vector's radius: its own network, not its main's. + node( + 4, + Some((1, "\"")), + "INSTANCE", + "I", + vec![ + ("size", xy(20.0, 20.0)), + ( + "symbolData", + o(vec![ + ("symbolID", g(2)), + ("symbolOverrides", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(3)]))])), ("cornerRadius", f(7.0))])])), + ]), + ), + ], + ), + ]; + let msg = o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![o(vec![("bytes", Kv::Bytes(triangle_network()))])]))]); + let out = mapped(&msg); + let radii = |r: &Value| -> Vec { + let net = out.networks[r.as_u64().unwrap() as usize].as_ref().unwrap(); + net.vertices.iter().map(|v| v.radius).collect() + }; + assert_eq!(radii(&named(&out.json, "Main")["children"][0]["vectorNetworkRef"]), [4.0, 4.0, 4.0]); + let in_instance = &named(&out.json, "I")["children"][0]; + assert_eq!(radii(&in_instance["vectorNetworkRef"]), [7.0, 7.0, 7.0]); +} + +/// 270 frames inside one another, or 270 mains each holding an instance of the next: past the +/// nesting limit either way. +fn nested(instances: bool) -> Kv<'static> { + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + let levels = 270; + if instances { + for i in 0..levels { + let id = 10 + 2 * i; + nodes.push(node(id, Some((1, "!")), "SYMBOL", "M", vec![("size", xy(1.0, 1.0))])); + if i + 1 < levels { + nodes.push(node( + id + 1, + Some((id, "!")), + "INSTANCE", + "I", + vec![("size", xy(1.0, 1.0)), ("symbolData", o(vec![("symbolID", g(id + 2))]))], + )); + } + } + nodes.push(node(5, Some((1, "\"")), "INSTANCE", "Top", vec![("size", xy(1.0, 1.0)), ("symbolData", o(vec![("symbolID", g(10))]))])); + } else { + let mut parent = 1; + for i in 0..levels { + nodes.push(node(10 + i, Some((parent, "!")), "FRAME", "F", vec![("size", xy(1.0, 1.0))])); + parent = 10 + i; + } + } + o(vec![("nodeChanges", arr(nodes))]) +} + +#[test] +fn deep_nesting_stops_at_the_limit_within_a_small_stack() { + // The walk recurses once per level, in small frames: past the limit of 256 it fits the 1 MiB + // stack of a WebAssembly build (an unoptimised build maps it on 512 KiB). + let run = || { + for instances in [false, true] { + let msg = nested(instances); + let out = mapped(&msg); + assert!( + out.warnings.iter().any(|w| w.starts_with(&format!("layers nested deeper than {MAX_DEPTH} levels were skipped"))), + "{:?}", + out.warnings + ); + assert!(craft_figma_rest::check(&out.json, usize::MAX).is_ok()); + } + }; + std::thread::Builder::new().stack_size(1 << 20).spawn(run).unwrap().join().unwrap(); +} diff --git a/crates/fig/src/rest/text.rs b/crates/fig/src/rest/text.rs new file mode 100644 index 000000000..06cf07932 --- /dev/null +++ b/crates/fig/src/rest/text.rs @@ -0,0 +1,327 @@ +use super::paint::paint; +use super::{Kv, Mapper, Obj, Value, View, copy_len, copy_str, json, network, num, scaled}; + +/// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through. +/// `length`: the setting is a length, at the scale of the layer that has it. +fn text_num(v: &View, key: &str, length: bool) -> Option { + v.layers.iter().zip(&v.scales).rev().find_map(|(layer, k)| { + layer.get(key).and_then(|value| match value { + Kv::Bool(_) => None, + _ => scaled(value.f64()?, if length { *k } else { 1.0 }), + }) + }) +} + +fn text_flag(v: &View, key: &str) -> Option { + v.layers.iter().rev().find_map(|layer| layer.get(key).and_then(Kv::bool)) +} + +fn insert_text_options(style: &mut Obj, v: &View) { + if let Some(value) = text_num(v, "listSpacing", true) { + style.insert("listSpacing".into(), json!(value)); + } + for k in ["hangingList", "hangingPunctuation"] { + if let Some(value) = text_flag(v, k) { + style.insert(k.into(), json!(value)); + } + } + if let Some(value) = leading_trim(v) { + style.insert("leadingTrim".into(), json!(value)); + } +} + +fn leading_trim<'a>(v: &View<'a>) -> Option<&'a str> { + v.layers.iter().rev().find_map(|layer| match layer.get("leadingTrim").and_then(Kv::str) { + Some(value @ ("NONE" | "CAP_HEIGHT")) => Some(value), + _ => None, + }) +} + +/// The chars that start a wrapped line in the layout the file saved with the text +/// (`derivedTextData`'s `baselines`, each line's `firstCharacter` in UTF-16 units), when that layout +/// is for these characters; lines after a hard break are left out (empty: it wraps nowhere). None +/// when the saved layout is missing, for other text or inconsistent. +fn saved_line_starts(v: &View, chars: &str) -> Option> { + // Derived data from below the layer that set the characters was laid out for other text. + let top = |k: &str| v.layers.iter().rposition(|l| l.get(k).is_some()); + if top("derivedTextData")? < top("textData")? { + return None; + } + let baselines = v.get("derivedTextData")?.get("baselines")?.list(); + // UTF-16 offset of each char, and of the end. + let mut offsets: Vec = Vec::with_capacity(chars.len() + 1); + let mut unit = 0; + for c in chars.chars() { + offsets.push(unit); + unit += c.len_utf16(); + } + offsets.push(unit); + let list: Vec = chars.chars().collect(); + let mut starts = Vec::new(); + let mut prev = None; + for b in baselines { + let u = num(b, "firstCharacter").filter(|u| *u >= 0.0 && u.fract() == 0.0 && *u < unit as f64)? as usize; + let c = offsets.binary_search(&u).ok()?; + // Lines start at the first char, then further on each time. + if !prev.map_or(c == 0, |p| c > p) { + return None; + } + prev = Some(c); + if c > 0 && !list.get(c - 1).is_some_and(|ch| matches!(ch, '\n' | '\u{2028}')) { + starts.push(c); + } + } + prev.map(|_| starts) +} + +impl<'a> Mapper<'a> { + pub(super) fn text(&mut self, o: &mut Obj, v: &View<'a>) { + let (key, legacy) = ("styleIdForText", "inheritTextStyleID"); + let style_node = self.style_link(v, key, legacy).and_then(|s| self.nodes.get(&s).copied()); + // Overrides above the one that links the style beat it; it beats the copies below. + let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")), key, legacy); + let found = v.find("textData"); + let (td, td_scale) = (found.map(|f| f.0), found.map_or(1.0, |f| f.1)); + let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or(""); + o.insert("characters".into(), json!(chars)); + // Where the file's own layout wrapped the lines (not a REST field). + if let Some(starts) = saved_line_starts(v, chars) { + o.insert("_figLineStarts".into(), json!(starts)); + } + let mut style = text_style(&sv, None); + // The style's typography beats the layer's own copy of it (the copy is stale when the style + // changed). The file cannot tell a stale copy of these four values from a deliberate local + // one, and "original", "none" and 0 could never be set locally if the style always won, so + // a value the layer or an override sets itself is taken as deliberate. + if let Some(decoration @ ("NONE" | "UNDERLINE" | "STRIKETHROUGH")) = v.str("textDecoration") { + style.insert("textDecoration".into(), json!(decoration)); + } + if let Some(skip_ink) = v.flag("textDecorationSkipInk") { + style.insert("textDecorationSkipInk".into(), json!(skip_ink)); + } + if let Some(case @ ("ORIGINAL" | "UPPER" | "LOWER" | "TITLE" | "SMALL_CAPS" | "SMALL_CAPS_FORCED")) = v.str("textCase") { + style.insert("textCase".into(), json!(case)); + } + for k in ["textAlignHorizontal", "textAlignVertical", "textAutoResize", "textTruncation"] { + copy_str(&mut style, v, k, k); + } + // Paragraph settings belong to the effective text layer, including explicit zero clears. + for k in ["paragraphSpacing", "paragraphIndent"] { + copy_len(&mut style, v, k, k); + } + insert_text_options(&mut style, v); + if let Some(n) = v.num("maxLines").filter(|n| *n >= 1.0) { + style.insert("maxLines".into(), json!(n)); + } + let size = sv.len("fontSize"); + if let Some(b) = o.get_mut("style").and_then(|s| s.get_mut("boundVariables")).map(Value::take) { + style.insert("boundVariables".into(), b); + } + o.insert("style".into(), Value::Object(style)); + let Some(td) = td else { return }; + char_styles(o, td, td_scale, size, &mut |w| self.warn(w)); + let raw = td.get("lines").map(Kv::list).unwrap_or_default(); + let lines: Vec<&str> = raw + .iter() + .map(|l| match l.get("lineType").and_then(Kv::str) { + Some("ORDERED_LIST") => "ORDERED", + Some("UNORDERED_LIST") => "UNORDERED", + _ => "NONE", + }) + .collect(); + if lines.iter().any(|l| *l != "NONE") { + o.insert("lineTypes".into(), json!(lines)); + } + // A forced base direction per paragraph (`sourceDirectionality`; not a REST field). + let dirs: Vec<&str> = raw + .iter() + .map(|l| match l.get("sourceDirectionality").and_then(Kv::str) { + Some("LTR") => "LTR", + Some("RTL") => "RTL", + _ => "AUTO", + }) + .collect(); + if dirs.iter().any(|d| *d != "AUTO") { + o.insert("lineDirections".into(), json!(dirs)); + } + // Where a numbered list starts counting, less one (not a REST field). + let starts: Vec = + raw.iter().map(|l| num(l, "listStartOffset").filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32)).collect(); + if starts.iter().any(|n| *n > 0) { + o.insert("listStartOffsets".into(), json!(starts)); + } + } + + /// Carry supported text-path data through the private .fig REST ingress only. + pub(super) fn text_path(&mut self, o: &mut Obj, v: &View<'a>) { + let fail = |this: &mut Self, reason: &str| { + this.warn(format!("text path {reason}; text imports off its path")); + }; + let Some(start) = v.get("textPathStart") else { + fail(self, "has no start data"); + return; + }; + let (Some(t), Some(forward)) = (num(start, "tValue"), start.get("forward").and_then(Kv::bool)) else { + fail(self, "has invalid start data"); + return; + }; + let Some(vd) = v.get("vectorData") else { + fail(self, "has no vector data"); + return; + }; + let Some(blob) = num(vd, "vectorNetworkBlob") + .filter(|i| i.is_finite() && *i >= 0.0 && i.fract() == 0.0) + .and_then(|i| usize::try_from(i as u64).ok()) + .and_then(|i| self.blobs.get(i)) + .copied() + else { + fail(self, "has no vector network"); + return; + }; + let Some(net) = network::decode(blob) else { + fail(self, "has damaged vector data"); + return; + }; + let Some(norm) = vd.get("normalizedSize") else { + fail(self, "has invalid normalized size"); + return; + }; + let (Some(nx), Some(ny), Some((w, h))) = (num(norm, "x"), num(norm, "y"), v.size()) else { + fail(self, "has invalid dimensions"); + return; + }; + let axis_scale = |size: f64, normalized: f64, values: &[f64]| -> Option { + if !size.is_finite() || size < 0.0 || !normalized.is_finite() || normalized < 0.0 { + return None; + } + if normalized > 1e-9 { + if size <= 0.0 { + return None; + } + return Some(size / normalized); + } + (size == 0.0 && values.iter().all(|v| *v == 0.0)).then_some(1.0) + }; + let xs: Vec = net.vertices.iter().map(|p| p.x).chain(net.segments.iter().flat_map(|s| [s.ta.0, s.tb.0])).collect(); + let ys: Vec = net.vertices.iter().map(|p| p.y).chain(net.segments.iter().flat_map(|s| [s.ta.1, s.tb.1])).collect(); + let sx = axis_scale(w, nx, &xs); + let sy = axis_scale(h, ny, &ys); + let (Some(sx), Some(sy)) = (sx, sy) else { + fail(self, "has invalid path scale"); + return; + }; + if v.str("textAlignVertical").is_some_and(|x| x != "TOP") { + fail(self, "uses unsupported vertical placement"); + return; + } + if v.get("textData").and_then(|td| td.get("characters")).and_then(Kv::str).is_some_and(|s| s.chars().any(|c| matches!(c, '\n' | '\r'))) { + fail(self, "contains multiple lines"); + return; + } + let path = match super::text_path::from_network(&net, sx, sy, t, forward) { + Ok(path) if self.geometry.text_path_measurable(&path.d) => path, + Ok(_) => { + fail(self, "cannot be measured by text renderer"); + return; + } + Err(reason) => { + fail(self, reason); + return; + } + }; + let has_decoration = |style: &Value| style.get("textDecoration").and_then(Value::as_str).is_some_and(|x| x != "NONE"); + let decorated = o.get("style").is_some_and(has_decoration) + || o.get("styleOverrideTable").and_then(Value::as_object).into_iter().flat_map(|styles| styles.values()).any(has_decoration); + if decorated { + self.warn("text path underline or strikethrough decoration is not rendered"); + } + o.insert("_figTextPath".into(), json!({"d": path.d, "start": path.start, "flip": path.flip})); + } +} + +/// Per-character styles of text data `td` (`characterStyleIDs` + `styleOverrideTable`) as REST +/// `characterStyleOverrides` + `styleOverrideTable`; `scale`: the factor the lengths of `td` take +/// (a scaled instance's layer); `size`: the base font size. +pub(super) fn char_styles(o: &mut Obj, td: &Kv, scale: f64, size: Option, warn: &mut dyn FnMut(String)) { + let ids: Vec = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect(); + if ids.iter().all(|i| *i == 0) { + return; + } + let mut table = Obj::new(); + for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() { + let Some(sid) = num(e, "styleID") else { continue }; + let mut s = text_style(&View::of_scaled(e, scale), size); + if let Some(fills) = e.get("fillPaints") { + s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(|p| paint(p, warn)).collect())); + } + table.insert((sid as u64).to_string(), Value::Object(s)); + } + o.insert("characterStyleOverrides".into(), json!(ids)); + o.insert("styleOverrideTable".into(), Value::Object(table)); +} + +/// The REST TypeStyle fields a node (or text override entry) sets. `size`: the font size that +/// letter spacing in percent refers to when the entry doesn't set one. +fn text_style(v: &View, size: Option) -> Obj { + let mut s = Obj::new(); + if let Some(f) = v.get("fontName") { + for (from, to) in [("family", "fontFamily"), ("style", "fontStyle"), ("postscript", "fontPostScriptName")] { + if let Some(x) = f.get(from).and_then(Kv::str).filter(|x| !x.is_empty()) { + // Compact and spaced style names ("SemiBold", "Semi Bold") are the same style. + s.insert(to.into(), if from == "style" { json!(craft_figma_rest::style_name(x)) } else { json!(x) }); + } + } + } + copy_len(&mut s, v, "fontSize", "fontSize"); + copy_len(&mut s, v, "paragraphSpacing", "paragraphSpacing"); + copy_len(&mut s, v, "paragraphIndent", "paragraphIndent"); + insert_text_options(&mut s, v); + copy_str(&mut s, v, "textCase", "textCase"); + copy_str(&mut s, v, "textDecoration", "textDecoration"); + if let Some(skip_ink) = v.flag("textDecorationSkipInk") { + s.insert("textDecorationSkipInk".into(), json!(skip_ink)); + } + copy_str(&mut s, v, "leadingTrim", "leadingTrim"); + let font_size = v.len("fontSize").or(size).unwrap_or(12.0); + if let Some((ls, k)) = v.find("letterSpacing") { + let val = num(ls, "value").unwrap_or(0.0); + let px = match ls.get("units").and_then(Kv::str) { + Some("PIXELS") => scaled(val, k).unwrap_or(val), + Some("PERCENT") => { + // Private (read by `rest/text.rs`): REST spacing is in pixels; keep the percent. + s.insert("_letterSpacingPercent".into(), json!(val)); + val / 100.0 * font_size + } + _ => val * font_size, + }; + s.insert("letterSpacing".into(), json!(px)); + } + if let Some((lh, k)) = v.find("lineHeight") { + let val = num(lh, "value").unwrap_or(100.0); + match lh.get("units").and_then(Kv::str) { + Some("PIXELS") => { + s.insert("lineHeightUnit".into(), json!("PIXELS")); + s.insert("lineHeightPx".into(), json!(scaled(val, k).unwrap_or(val))); + } + // shortcut: percent units count from the font's own line height (100 % is "auto"), which + // the document can only say for 100 %; other values read as % of the font size. Upgrade + // when line heights can be a share of the font's own. + Some("PERCENT") if (val - 100.0).abs() < 1e-6 => { + s.insert("lineHeightUnit".into(), json!("INTRINSIC_%")); + } + Some("PERCENT") => { + s.insert("lineHeightUnit".into(), json!("FONT_SIZE_%")); + s.insert("lineHeightPercentFontSize".into(), json!(val)); + } + _ => { + s.insert("lineHeightUnit".into(), json!("FONT_SIZE_%")); + s.insert("lineHeightPercentFontSize".into(), json!(val * 100.0)); + } + } + } + // An empty URL is kept: on an override entry it clears the text's own link. + if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str) { + s.insert("hyperlink".into(), json!({"type": "URL", "url": u})); + } + s +} diff --git a/crates/fig/src/rest/text_path.rs b/crates/fig/src/rest/text_path.rs new file mode 100644 index 000000000..8822122e3 --- /dev/null +++ b/crates/fig/src/rest/text_path.rs @@ -0,0 +1,297 @@ +//! The simple vector network a text-on-path layer of a `.fig` file follows, as a text path: SVG +//! path data, where the text starts along it and which side it runs on. + +use kurbo::{BezPath, CubicBez, Line, ParamCurve, ParamCurveArclen, Point}; + +use crate::network::{Network, Segment}; + +/// Text on a path: the baseline follows `d` (SVG path data in the layer's own space), starting at +/// `start` (0–1 by length); `flip`: on the other side of the path, running the other way. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct TextPath { + pub d: String, + pub start: f64, + pub flip: bool, +} + +const START_SEGMENT_STYLE: u32 = 0x01ff_ffff; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Use { + segment: usize, + forward: bool, +} + +/// Build a native path from a validated connected chain or cycle. The marked source segment is +/// oriented a→b; `t` is local to that line/cubic. Coordinates are scaled into layer space. +pub(super) fn from_network(net: &Network, sx: f64, sy: f64, t: f64, forward: bool) -> Result { + if !t.is_finite() || !(0.0..=1.0).contains(&t) { + return Err("invalid start parameter"); + } + if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 { + return Err("invalid path scale"); + } + let markers: Vec = net.segments.iter().enumerate().filter_map(|(i, s)| (s.style == START_SEGMENT_STYLE).then_some(i)).collect(); + let [marked] = markers.as_slice() else { + return Err("missing or ambiguous start segment marker"); + }; + + let (uses, closed) = ordered_chain(net, *marked)?; + let mut d = BezPath::new(); + let mut total = 0.0; + let mut prefix = 0.0; + let mut start_segment_length = 0.0; + for use_ in &uses { + let (p0, p1, p2, p3) = cubic(net, *use_, sx, sy)?; + if d.elements().is_empty() { + d.move_to((p0.0, p0.1)); + } + let line = p1 == p0 && p2 == p3; + let (length, partial) = if line { + d.line_to((p3.0, p3.1)); + let line = Line::new(Point::new(p0.0, p0.1), Point::new(p3.0, p3.1)); + (line.arclen(1e-9), line.arclen(1e-9) * t) + } else { + d.curve_to((p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1)); + let curve = CubicBez::new((p0.0, p0.1), (p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1)); + let partial = if t <= 0.0 { 0.0 } else { curve.subsegment(0.0..t).arclen(1e-9) }; + (curve.arclen(1e-9), partial) + }; + if !length.is_finite() || length <= 1e-10 { + return Err("zero-length path segment"); + } + if use_.segment == *marked { + start_segment_length = partial; + if !start_segment_length.is_finite() { + return Err("invalid path length"); + } + prefix = total; + } + total += length; + } + if closed { + d.close_path(); + } + if !total.is_finite() || total <= 1e-10 { + return Err("zero-length path"); + } + let mut start = ((prefix + start_segment_length) / total).clamp(0.0, 1.0); + let flip = !forward; + if flip { + start = 1.0 - start; + } + Ok(TextPath { d: d.to_svg(), start, flip }) +} + +/// Return every segment in chain order. Open chains are oriented so the marked segment runs a→b; +/// cycles start at the marked segment's a vertex and traverse it first. +fn ordered_chain(net: &Network, marked: usize) -> Result<(Vec, bool), &'static str> { + let marker = net.segments.get(marked).ok_or("invalid start segment index")?; + let mut adjacency = vec![Vec::::new(); net.vertices.len()]; + for (i, s) in net.segments.iter().enumerate() { + if s.a >= adjacency.len() || s.b >= adjacency.len() { + return Err("invalid path vertex index"); + } + if s.a == s.b { + return Err("path contains a segment that starts and ends at the same vertex"); + } + adjacency[s.a].push(i); + adjacency[s.b].push(i); + } + let mut endpoints = Vec::new(); + for edges in &adjacency { + if edges.len() > 2 { + return Err("branched path network"); + } + if edges.len() == 1 { + endpoints.push(edges[0]); + } + } + if !endpoints.is_empty() && endpoints.len() != 2 { + return Err("disconnected or malformed path network"); + } + let cycle = endpoints.is_empty(); + let mut used = vec![false; net.segments.len()]; + let mut out = Vec::with_capacity(net.segments.len()); + if cycle { + out.push(Use { segment: marked, forward: true }); + used[marked] = true; + let mut at = marker.b; + let mut visited = vec![false; net.vertices.len()]; + visited[marker.a] = true; + visited[marker.b] = true; + while at != marker.a { + let next = adjacency[at].iter().copied().find(|i| !used[*i]).ok_or("disconnected path cycle")?; + used[next] = true; + let s = &net.segments[next]; + let is_forward = s.a == at; + at = if is_forward { s.b } else { s.a }; + if at != marker.a && std::mem::replace(&mut visited[at], true) { + return Err("path cycle is not simple"); + } + out.push(Use { segment: next, forward: is_forward }); + if out.len() > net.segments.len() { + return Err("invalid path cycle"); + } + } + } else { + let endpoint = adjacency.iter().position(|e| e.len() == 1).ok_or("missing path endpoint")?; + let mut at = endpoint; + while let Some(next) = adjacency[at].iter().copied().find(|i| !used[*i]) { + used[next] = true; + let s = &net.segments[next]; + let is_forward = s.a == at; + at = if is_forward { s.b } else { s.a }; + out.push(Use { segment: next, forward: is_forward }); + } + let Some(marker_use) = out.iter().find(|u| u.segment == marked) else { return Err("disconnected start segment") }; + if !marker_use.forward { + out.reverse(); + for u in &mut out { + u.forward = !u.forward; + } + } + } + if !used.iter().all(|x| *x) { + return Err("disconnected path network"); + } + Ok((out, cycle)) +} + +type P = (f64, f64); + +fn cubic(net: &Network, use_: Use, sx: f64, sy: f64) -> Result<(P, P, P, P), &'static str> { + let s: &Segment = net.segments.get(use_.segment).ok_or("invalid path segment index")?; + let a = net.vertices.get(s.a).ok_or("invalid path vertex index")?; + let b = net.vertices.get(s.b).ok_or("invalid path vertex index")?; + let p0 = (a.x * sx, a.y * sy); + let p3 = (b.x * sx, b.y * sy); + let ta = (s.ta.0 * sx, s.ta.1 * sy); + let tb = (s.tb.0 * sx, s.tb.1 * sy); + if [p0.0, p0.1, p3.0, p3.1, ta.0, ta.1, tb.0, tb.1].iter().any(|x| !x.is_finite()) { + return Err("invalid path coordinates"); + } + let (p0, p1, p2, p3) = + if ta == (0.0, 0.0) && tb == (0.0, 0.0) { (p0, p0, p3, p3) } else { (p0, (p0.0 + ta.0, p0.1 + ta.1), (p3.0 + tb.0, p3.1 + tb.1), p3) }; + if use_.forward { Ok((p0, p1, p2, p3)) } else { Ok((p3, p2, p1, p0)) } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use crate::network::{Segment, Vertex}; + use kurbo::PathEl; + + impl TextPath { + fn path(&self) -> BezPath { + BezPath::from_svg(&self.d).unwrap() + } + } + + fn vertex(x: f64, y: f64) -> Vertex { + Vertex { style: 0, x, y } + } + + fn segment(style: u32, a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment { + Segment { style, a, ta, b, tb } + } + + fn curved() -> Network { + Network { + vertices: vec![vertex(0.0, 82.5), vertex(300.0, 82.5), vertex(600.0, 82.5)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (70.0, -110.0), (-70.0, -110.0)), segment(0, 1, 2, (70.0, 110.0), (-70.0, 110.0))], + ..Network::default() + } + } + + #[test] + fn local_cubic_parameter_converts_to_path_distance() { + let path = from_network(&curved(), 1.0, 1.0, 0.0086954795, true).unwrap(); + assert!((path.start - 0.004744078).abs() < 0.00001); + // Cubic t is not its fraction of total path length. + assert!((path.start - 0.0086954795).abs() > 0.003); + let elements = path.path().elements().to_vec(); + assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 82.5)); + assert!(matches!(elements.get(1), Some(PathEl::CurveTo(..)))); + } + + #[test] + fn second_segment_marker_and_t_are_respected() { + let mut net = curved(); + net.segments[0].style = 0; + net.segments[1].style = START_SEGMENT_STYLE; + let path = from_network(&net, 1.0, 1.0, 0.49800667, true).unwrap(); + assert!((path.start - 0.7490379757).abs() < 0.00002); + } + + #[test] + fn reverse_direction_inverts_normalized_start() { + let net = Network { + vertices: vec![vertex(0.0, 0.0), vertex(600.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + let path = from_network(&net, 1.0, 1.0, 0.83986014, false).unwrap(); + assert!(path.flip); + assert!((path.start - 0.16013986).abs() < 1e-8); + assert!(matches!(path.path().elements().get(1), Some(PathEl::LineTo(p)) if p.x == 600.0 && p.y == 0.0)); + } + + #[test] + fn rejects_missing_duplicate_and_zero_length_segments() { + let mut net = curved(); + net.segments[0].style = 0; + assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker")); + net.segments[1].style = START_SEGMENT_STYLE; + net.segments[0].style = START_SEGMENT_STYLE; + assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker")); + let zero = Network { + vertices: vec![vertex(0.0, 0.0), vertex(0.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&zero, 1.0, 1.0, 0.5, true), Err("zero-length path segment")); + } + + #[test] + fn rejects_branching_and_disconnected_segments() { + let branch = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(1.0, 1.0)], + segments: vec![ + segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 3, (0.0, 0.0), (0.0, 0.0)), + ], + ..Network::default() + }; + assert_eq!(from_network(&branch, 1.0, 1.0, 0.5, true), Err("branched path network")); + let disconnected = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(3.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), segment(0, 2, 3, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&disconnected, 1.0, 1.0, 0.5, true), Err("disconnected or malformed path network")); + } + + #[test] + fn scales_geometry_and_accepts_closed_cycles() { + let cycle = Network { + vertices: vec![vertex(0.0, 0.0), vertex(100.0, 0.0), vertex(100.0, 100.0)], + segments: vec![ + segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)), + segment(0, 2, 0, (0.0, 0.0), (0.0, 0.0)), + ], + ..Network::default() + }; + let path = from_network(&cycle, 2.0, 3.0, 0.5, true).unwrap(); + let elements = path.path().elements().to_vec(); + assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 0.0)); + assert!(matches!(elements.get(1), Some(PathEl::LineTo(p)) if p.x == 200.0 && p.y == 0.0)); + let total = 200.0 + 300.0 + 130_000.0_f64.sqrt(); + assert!((path.start - 100.0 / total).abs() < 1e-8); + assert!(matches!(elements.last(), Some(PathEl::ClosePath))); + } +} diff --git a/crates/fig/src/rest/variables.rs b/crates/fig/src/rest/variables.rs new file mode 100644 index 000000000..0794ef155 --- /dev/null +++ b/crates/fig/src/rest/variables.rs @@ -0,0 +1,168 @@ +use super::paint::color; +use super::{Kv, MAX_EXPR_DEPTH, Mapper, Obj, Value, View, guid, json, num, ref_guid}; + +impl<'a> Mapper<'a> { + /// Layer `v`'s variable bindings (`boundVariables`, and the text style's) and its explicitly + /// set modes (`explicitVariableModes`) into `o`, in the REST shape. Variables and collections + /// named by library asset resolve to their newest copy in the file. + pub(super) fn variable_bindings(&mut self, o: &mut Obj, v: &View<'a>) { + let mut bound = Obj::new(); + let mut text_bound = Obj::new(); + for e in v.get("variableConsumptionMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() { + let Some(var) = alias(e.get("variableData")) else { continue }; + let a = json!({"type": "VARIABLE_ALIAS", "id": var}); + let field = e.get("variableField").and_then(Kv::str).unwrap_or(""); + match (binding_key(field), text_binding_key(field)) { + (Some(k), _) => { + bound.insert(k.into(), a); + } + (_, Some(k)) => { + text_bound.insert(k.into(), a); + } + _ if field == "CORNER_RADIUS" => { + for k in ["topLeftRadius", "topRightRadius", "bottomLeftRadius", "bottomRightRadius"] { + bound.insert(k.into(), a.clone()); + } + } + _ if field.starts_with("BORDER_") => { + let side = match field { + "BORDER_TOP_WEIGHT" => "strokeTopWeight", + "BORDER_RIGHT_WEIGHT" => "strokeRightWeight", + "BORDER_BOTTOM_WEIGHT" => "strokeBottomWeight", + _ => "strokeLeftWeight", + }; + bound.insert(side.into(), a); + } + _ if field == "WIDTH" || field == "HEIGHT" => { + let axis = if field == "WIDTH" { "x" } else { "y" }; + let size = bound.entry("size").or_insert_with(|| json!({})); + size[axis] = a; + } + _ => self.warn(format!("variables bound to {} are not imported from .fig files yet", field.to_ascii_lowercase().replace('_', " "))), + } + } + if !bound.is_empty() { + o.insert("boundVariables".into(), Value::Object(bound)); + } + // (The text style itself is added after this: it keeps these.) + if !text_bound.is_empty() { + o.insert("style".into(), json!({"boundVariables": text_bound})); + } + let mut modes = Obj::new(); + let mut lost = false; + for e in v.get("variableModeBySetMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() { + let (Some(set), Some(mode)) = (e.get("variableSetID"), e.get("variableModeID").and_then(guid)) else { continue }; + match self.collection_id(set) { + Some(id) => { + modes.insert(id, json!(mode)); + } + // A mode of a collection neither named by guid nor resolvable in the file. + None if set.get("guid").is_some() || set.get("assetRef").is_some() => lost = true, + None => {} + } + } + if lost { + self.warn("variable modes of collections that aren't in the file were skipped"); + } + if !modes.is_empty() { + o.insert("explicitVariableModes".into(), Value::Object(modes)); + } + } +} + +/// The REST `boundVariables` key of a bound layer field. +fn binding_key(field: &str) -> Option<&'static str> { + Some(match field { + "STROKE_WEIGHT" => "strokeWeight", + "STACK_SPACING" => "itemSpacing", + "STACK_COUNTER_SPACING" => "counterAxisSpacing", + "STACK_PADDING_LEFT" => "paddingLeft", + "STACK_PADDING_TOP" => "paddingTop", + "STACK_PADDING_RIGHT" => "paddingRight", + "STACK_PADDING_BOTTOM" => "paddingBottom", + "VISIBLE" => "visible", + "TEXT_DATA" => "characters", + "OPACITY" => "opacity", + "MIN_WIDTH" => "minWidth", + "MAX_WIDTH" => "maxWidth", + "MIN_HEIGHT" => "minHeight", + "MAX_HEIGHT" => "maxHeight", + "RECTANGLE_TOP_LEFT_CORNER_RADIUS" => "topLeftRadius", + "RECTANGLE_TOP_RIGHT_CORNER_RADIUS" => "topRightRadius", + "RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS" => "bottomLeftRadius", + "RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS" => "bottomRightRadius", + _ => return None, + }) +} + +/// The REST text style `boundVariables` key of a bound text field. +fn text_binding_key(field: &str) -> Option<&'static str> { + Some(match field { + "FONT_SIZE" => "fontSize", + "FONT_FAMILY" => "fontFamily", + "FONT_STYLE" => "fontStyle", + "LETTER_SPACING" => "letterSpacing", + "LINE_HEIGHT" => "lineHeight", + "PARAGRAPH_SPACING" => "paragraphSpacing", + "PARAGRAPH_INDENT" => "paragraphIndent", + _ => return None, + }) +} + +/// A `VariableData` (literal, variable reference or expression) in the REST shape; `None` for +/// kinds a prototype can't evaluate. +pub(super) fn variable_data(d: &Kv, depth: usize) -> Option { + let v = d.get("value")?; + let ty = d.get("dataType").and_then(Kv::str)?; + let value = match ty { + "ALIAS" => json!({"type": "VARIABLE_ALIAS", "id": v.get("alias").and_then(ref_guid)?}), + "EXPRESSION" if depth < MAX_EXPR_DEPTH => { + let e = v.get("expressionValue")?; + let function = match e.get("expressionFunction").and_then(Kv::str)? { + "MULTIPLY" => "MULTIPLICATION", + "DIVIDE" => "DIVISION", + f => f, + }; + let args = e.get("expressionArguments").map(Kv::list).unwrap_or_default(); + let args = args.iter().map(|x| variable_data(x, depth + 1)).collect::>>()?; + json!({"expressionFunction": function, "expressionArguments": args}) + } + "BOOLEAN" => json!(v.get("boolValue").and_then(Kv::bool)?), + "FLOAT" => json!(num(v, "floatValue").filter(|x| x.is_finite())?), + "STRING" => json!(v.get("textValue").and_then(Kv::str)?), + "COLOR" => color(v.get("colorValue")?), + _ => return None, + }; + let mut out = json!({"type": ty, "value": value}); + if let Some(r) = d.get("resolvedDataType").and_then(Kv::str) { + out["resolvedType"] = json!(r); + } + Some(out) +} + +/// The variable an `ALIAS` variable data points at: by guid, or by library asset (the newest copy +/// of it in the file). +pub(super) fn alias(d: Option<&Kv>) -> Option { + let d = d?; + if d.get("dataType").and_then(Kv::str) != Some("ALIAS") { + return None; + } + d.get("value").and_then(|v| v.get("alias")).and_then(super::VarCanon::resolve) +} + +pub(super) fn variable_value(d: &Kv) -> Option { + if let Some(a) = alias(Some(d)) { + return Some(json!({"type": "VARIABLE_ALIAS", "id": a})); + } + let v = d.get("value")?; + if let Some(c) = v.get("colorValue") { + return Some(color(c)); + } + if let Some(b) = v.get("boolValue").and_then(Kv::bool) { + return Some(json!(b)); + } + if let Some(x) = num(v, "floatValue") { + return Some(json!(x)); + } + v.get("textValue").and_then(Kv::str).map(|s| json!(s)) +}