From 7bee269c4d6c48c805bba45859c8df48e27ea856 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 31 Oct 2026 10:58:09 -0400 Subject: [PATCH 01/16] M8: posting a comment in the browser no longer stops the app (#1764) The event log read the clock through std::time::SystemTime and Instant, which panic on wasm32-unknown-unknown ("time not implemented on this platform"). Every mutating comment.* command appends an event, so the first comment posted in the web build hit it. The log now takes SystemTime, UNIX_EPOCH and Instant from web_time, as the rest of the engine does. A test checks the stamp is a current Unix time read through the same clock. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/events.rs | 6 +++++- crates/engine/src/events_tests.rs | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/events.rs b/crates/engine/src/events.rs index fbc1b9b6c..328d6ba5e 100644 --- a/crates/engine/src/events.rs +++ b/crates/engine/src/events.rs @@ -7,10 +7,14 @@ use std::collections::VecDeque; use std::sync::{Condvar, Mutex, PoisonError}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use serde::Serialize; use serde_json::Value; +// The browser build has no `std` clock: `std::time::SystemTime::now()` panics on +// wasm32-unknown-unknown ("time not implemented on this platform"), and every `comment.*` command +// stamps an event. `web_time` is `std::time` on native targets and the JavaScript clock on wasm. +use web_time::{Instant, SystemTime, UNIX_EPOCH}; /// Most events kept; the oldest are dropped. pub const MAX_EVENTS: usize = 1024; diff --git a/crates/engine/src/events_tests.rs b/crates/engine/src/events_tests.rs index 36b118a79..ad20974c5 100644 --- a/crates/engine/src/events_tests.rs +++ b/crates/engine/src/events_tests.rs @@ -44,6 +44,18 @@ fn events_keep_sequence_and_filter_topics() { assert_eq!(r.events[0].kind, "document.changed"); } +#[test] +fn events_are_stamped_with_the_wall_clock() { + // The stamp is read through `web_time`, the clock the browser build has as well: with + // `std::time::SystemTime` every comment posted on the web panicked (#1764). + let log = EventLog::default(); + log.push("comment.added", "Doc", None, json!({"commentId": 1})); + let r = log.poll(0, &comments(), 10, Duration::ZERO); + let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).unwrap().as_secs_f64(); + let time = r.events[0].time; + assert!(time > 1_700_000_000.0 && time <= now, "{time} is seconds since the Unix epoch, read just before {now}"); +} + #[test] fn a_poll_wakes_for_a_push() { let log = Arc::new(EventLog::default()); From 12c9acbf0cf4a12d632c7a73617161aa3e02f6bb Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 1 Nov 2026 00:17:54 -0400 Subject: [PATCH 02/16] events test: the stamp is within a minute of a second clock read, not ordered against it Review follow-up on #1766: a backward wall-clock step between the push and the test read made the ordering assertion flaky. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/events_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/events_tests.rs b/crates/engine/src/events_tests.rs index ad20974c5..304a8f8d1 100644 --- a/crates/engine/src/events_tests.rs +++ b/crates/engine/src/events_tests.rs @@ -53,7 +53,8 @@ fn events_are_stamped_with_the_wall_clock() { let r = log.poll(0, &comments(), 10, Duration::ZERO); let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).unwrap().as_secs_f64(); let time = r.events[0].time; - assert!(time > 1_700_000_000.0 && time <= now, "{time} is seconds since the Unix epoch, read just before {now}"); + // Within a minute of the same clock read afterwards (not ordered: the wall clock can step). + assert!(time > 1_700_000_000.0 && (time - now).abs() < 60.0, "{time} is seconds since the Unix epoch, read next to {now}"); } #[test] From dfe703deb26857bd8b930bcf992d079ef22142b9 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 13:46:24 +0330 Subject: [PATCH 03/16] Add files via upload --- map.rs | 490 +++++++++++++++++ tests.rs | 1588 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 2078 insertions(+) create mode 100644 map.rs create mode 100644 tests.rs diff --git a/map.rs b/map.rs new file mode 100644 index 000000000..98239cd7b --- /dev/null +++ b/map.rs @@ -0,0 +1,490 @@ +//! Decoded `.fig` message → REST API file JSON, which [`crate::rest`] then imports. +//! +//! 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 import 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 import looks as saved. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::rc::Rc; + +use figcraft_doc::VectorNetwork; +use figcraft_doc::geom::{Affine, Point}; +use figcraft_doc::proto::{MAX_ACTION_DEPTH, MAX_EXPR_DEPTH}; +use serde_json::{Map, Value, json}; + +use super::kiwi::Kv; +use super::network; + +mod component; +mod connector; +mod document; +mod layer; +mod layout; +mod paint; +mod proto; +mod text; +mod variables; + +use component::OverrideScope; +#[cfg(test)] +use paint::paint; + +type Obj = Map; + +/// Deepest nesting followed. +const MAX_DEPTH: usize = 256; +/// Most path commands read from one geometry blob. +const MAX_COMMANDS: usize = 200_000; +const MAX_GRID_TRACKS: usize = 1_000; +pub(super) const MAX_GRID_TRACK_VALUE: f64 = 1e6; +/// 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; + +/// The REST API file JSON for message `msg`, with the warnings about what was skipped and the +/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]). +pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec, Vec>) { + let mut m = Mapper::new(msg, max_nodes); + 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[crate::rest::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 (#981). + 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[crate::rest::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); + } + (out, m.warnings.finish(), m.networks) +} + +#[cfg(test)] +mod image_filter_tests; + +/// REST importer warnings reworded for a `.fig` import (`None`: drop it). +pub(super) fn adapt_warning(w: String) -> Option { + if w.contains("geometry=paths") { + return None; + } + Some(w.replace("the variables JSON", "the file").replace("the file JSON", "the file")) +} + +// ---- 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)) +} + +#[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)); + } +} + +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>>, + /// 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 { layers: vec![n], fitted: None } + } + + fn size(&self) -> Option<(f64, f64)> { + self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(size)) + } + + fn transform(&self) -> Option { + self.fitted.map(|f| f.1).or_else(|| self.get("transform").and_then(matrix)) + } + + fn with(&self, top: impl IntoIterator>) -> Self { + let mut v = self.clone(); + v.layers.extend(top); + v + } + + /// 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); + v.layers.insert(at.min(v.layers.len()), s); + } + v + } + + fn get(&self, k: &str) -> Option<&'a Kv<'a>> { + self.layers.iter().rev().find_map(|l| l.get(k)) + } + + 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: crate::rest::Warnings, + 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 [`super::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 what a network is made from: its blob, its scale and its style table. + network_ids: HashMap<(usize, u64, u64, usize), 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>, max_nodes: usize) -> Self { + 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: crate::rest::Warnings::default(), + 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. + pub(super) fn collection_id(&self, v: &Kv) -> Option { + let id = self.resource_id(v)?; + Some(self.canonical_collection(&id).unwrap_or(id)) + } + + /// 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())?; + let live_set = |n: &Kv| n.get("type").and_then(Kv::str) == Some("VARIABLE_SET") && n.get("isSoftDeleted").and_then(Kv::bool) != Some(true); + self.assets + .get(key)? + .iter() + .filter(|(_, nid)| self.nodes.get(nid.as_str()).is_some_and(|n| live_set(n))) + .max_by_key(|(ver, _)| version_rank(ver)) + .map(|(_, nid)| nid.clone()) + } + + /// 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, +} + +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 { + fn drop(&mut self) { + VAR_CANON.with(|c| c.borrow_mut().take()); + } +} + +impl VarCanon { + fn install(m: &Mapper) -> VarCanonGuard { + let mut newest: HashMap = HashMap::new(); + let mut copies: Vec<(String, String)> = Vec::new(); + for (id, n) in &m.nodes { + if n.get("type").and_then(Kv::str) != Some("VARIABLE") || n.get("isSoftDeleted").and_then(Kv::bool) == Some(true) { + continue; + } + 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(); + VAR_CANON.with(|c| *c.borrow_mut() = Some(t)); + VarCanonGuard + } + + /// The variable a reference (`{guid}` or `{assetRef}`) names, as its newest copy. + 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 { + return Some(t.by_id.get(g).cloned().unwrap_or_else(|| g.clone())); + } + let key = r.get("assetRef")?.get("key").and_then(Kv::str)?; + t.by_key.get(key).cloned() + }) + } + + /// 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/tests.rs b/tests.rs new file mode 100644 index 000000000..0e2c5016e --- /dev/null +++ b/tests.rs @@ -0,0 +1,1588 @@ +//! `.fig` import tests on fixtures built in code: a small schema written in a compact text form, +//! a message encoded with our own kiwi encoder, compressed (deflate and zstd), framed and zipped. + +use std::io::Write; + +use figcraft_doc::{NodeKind, VariableType}; +use serde_json::Value; + +use super::kiwi::{Def, Field, Kind, Kv, Schema, Type, encode}; +use super::*; + +/// 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 ConnectorLineStyle ELBOWED STRAIGHT CURVED +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 +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 +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 +message Blob byte[] bytes +message Message NodeChange[] nodeChanges, Blob[] blobs +"; + +pub(super) fn schema() -> Schema { + Schema::parse(SCHEMA).unwrap() +} + +pub(super) fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} +pub(super) fn f(x: f64) -> Kv<'static> { + Kv::Float(x as f32) +} +pub(super) fn u(x: u64) -> Kv<'static> { + Kv::Uint(x) +} +pub(super) 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) +} +pub(super) fn xy(x: f64, y: f64) -> Kv<'static> { + o(vec![("x", f(x)), ("y", f(y))]) +} +pub(super) 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 +} + +/// A 2×1 PNG. +pub(super) fn png() -> Vec { + let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +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()))])]))]) +} + +/// `canvas.fig`: magic, version, deflated schema, zstd message. +pub(super) fn canvas(schema: &Schema, msg: &Kv) -> Vec { + let root = schema.find("Message").unwrap(); + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(&encode::schema(schema)).unwrap(); + let schema_chunk = z.finish().unwrap(); + let msg_chunk = ruzstd::encoding::compress_to_vec(&encode::message(schema, root, msg).unwrap()[..], ruzstd::encoding::CompressionLevel::Fastest); + let mut out = b"fig-kiwi".to_vec(); + out.extend_from_slice(&101u32.to_le_bytes()); + for c in [schema_chunk, msg_chunk] { + out.extend_from_slice(&(c.len() as u32).to_le_bytes()); + out.extend_from_slice(&c); + } + out +} + +fn zip_file(canvas: &[u8]) -> Vec { + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let image_name = format!("images/{}", HASH.iter().map(|b| format!("{b:02x}")).collect::()); + for (name, bytes) in [("canvas.fig", canvas.to_vec()), ("meta.json", br#"{"file_name":"Fixture"}"#.to_vec()), (image_name.as_str(), png())] { + z.start_file(name, stored).unwrap(); + z.write_all(&bytes).unwrap(); + } + z.finish().unwrap().into_inner() +} + +fn fixture() -> Vec { + zip_file(&canvas(&schema(), &message())) +} + +fn named<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}")) +} + +/// The shared schema plus the grid sizing definitions only these tests use. +fn grid_schema() -> Schema { + const NODE_FIELDS: &str = ", GridTrackMap gridColumns, GridTrackMap gridRows, GridTrackSizeMap gridColumnsSizing, GridTrackSizeMap gridRowsSizing, GUID gridColumnAnchor, GUID gridRowAnchor, uint gridColumnSpan, uint gridRowSpan, float gridColumnGap, float gridRowGap"; + const TYPES: &str = " +enum GridTrackSizingType FLEX FIXED HUG UNKNOWN +message GridTrackSizeFunction GridTrackSizingType type, float value +message GridTrackSize GridTrackSizeFunction minSizing, GridTrackSizeFunction maxSizing +message GridTrackSizeEntry GUID id, GridTrackSize trackSize +message GridTrackSizeMap GridTrackSizeEntry[] entries +message GridTrackEntry GUID id, string position +message GridTrackMap GridTrackEntry[] entries +message Blob "; + let text = SCHEMA.replace("enum StackMode NONE HORIZONTAL VERTICAL\n", "enum StackMode NONE HORIZONTAL VERTICAL GRID\n").replacen( + "\nmessage Blob ", + &format!("{NODE_FIELDS}{TYPES}"), + 1, + ); + Schema::parse(&text).unwrap() +} + +fn track_size(kind: &'static str, value: f64) -> Kv<'static> { + let sizing = || o(vec![("type", e(kind)), ("value", f(value))]); + o(vec![("minSizing", sizing()), ("maxSizing", sizing())]) +} + +fn ranged_track_size(min: Kv<'static>, max: Kv<'static>) -> Kv<'static> { + o(vec![("minSizing", min), ("maxSizing", max)]) +} + +fn track_map_entry(id: u64, size: Kv<'static>) -> Kv<'static> { + o(vec![("id", g(id)), ("trackSize", size)]) +} + +fn positioned_track(id: u64, position: &'static str) -> Kv<'static> { + o(vec![("id", g(id)), ("position", s(position))]) +} + +fn unpositioned_track(id: u64) -> Kv<'static> { + o(vec![("id", g(id))]) +} + +#[test] +fn grid_tracks_import_by_guid_and_keep_track_order() { + let cols = arr(vec![positioned_track(13, "$"), unpositioned_track(10), positioned_track(12, "#"), unpositioned_track(11)]); + let rows = arr(vec![positioned_track(21, "\""), positioned_track(20, "!")]); + // Sizing entries intentionally differ from track order. Their GUIDs define the join. + let col_sizes = arr(vec![ + track_map_entry(12, track_size("FLEX", 1.0)), + track_map_entry(10, track_size("FIXED", 120.0)), + track_map_entry(13, track_size("FLEX", 2.0)), + track_map_entry(11, track_size("HUG", 80.0)), + ]); + let row_sizes = arr(vec![track_map_entry(21, track_size("HUG", 30.0)), track_map_entry(20, track_size("FIXED", 50.0))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node( + 2, + Some((1, "!")), + "FRAME", + "Grid", + vec![ + ("size", xy(600.0, 300.0)), + ("transform", at(0.0, 0.0)), + ("stackMode", e("GRID")), + ("gridColumns", o(vec![("entries", cols)])), + ("gridRows", o(vec![("entries", rows)])), + ("gridColumnsSizing", o(vec![("entries", col_sizes)])), + ("gridRowsSizing", o(vec![("entries", row_sizes)])), + ("gridColumnGap", f(10.0)), + ("gridRowGap", f(12.0)), + ], + ), + node( + 3, + Some((2, "!")), + "RECTANGLE", + "Hug child", + vec![ + ("size", xy(80.0, 30.0)), + ("transform", at(0.0, 0.0)), + ("gridColumnAnchor", g(11)), + ("gridRowAnchor", g(21)), + ("gridColumnSpan", u(2)), + ("gridRowSpan", u(1)), + ], + ), + ]; + let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); + let (doc, warnings) = import(&bytes, "Grid tracks").unwrap(); + let grid = named(&doc, "Grid").layout.as_ref().unwrap(); + assert_eq!((grid.mode, grid.grid_columns, grid.grid_rows, grid.gap, grid.cross_gap), (figcraft_doc::LayoutMode::Grid, 4, 2, 10.0, 12.0)); + assert_eq!( + grid.column_tracks, + vec![ + figcraft_doc::GridTrack::fixed(120.0), + figcraft_doc::GridTrack::hug(), + figcraft_doc::GridTrack::fill(1.0), + figcraft_doc::GridTrack::fill(2.0), + ] + ); + assert_eq!(grid.row_tracks, vec![figcraft_doc::GridTrack::fixed(50.0), figcraft_doc::GridTrack::hug()]); + let child = &named(&doc, "Hug child").child_layout; + assert_eq!((child.grid_column, child.grid_row, child.grid_column_span, child.grid_row_span), (Some(1), Some(1), 2, 1)); + assert!(!warnings.iter().any(|w| w.contains("grid track")), "{warnings:?}"); +} + +#[test] +fn malformed_grid_tracks_warn_and_keep_equal_track_fallbacks() { + let tracks = arr(vec![positioned_track(30, "!"), positioned_track(31, "\""), positioned_track(32, "#"), positioned_track(33, "$")]); + let sizing = arr(vec![ + track_map_entry(30, track_size("FIXED", -1.0)), + track_map_entry(31, ranged_track_size(o(vec![("type", e("FLEX")), ("value", f(1.0))]), o(vec![("type", e("FLEX")), ("value", f(2.0))]))), + track_map_entry(33, track_size("UNKNOWN", 5.0)), + track_map_entry(34, track_size("FLEX", 3.0)), + ]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node( + 2, + Some((1, "!")), + "FRAME", + "Grid", + vec![ + ("size", xy(100.0, 100.0)), + ("transform", at(0.0, 0.0)), + ("stackMode", e("GRID")), + ("gridColumns", o(vec![("entries", tracks)])), + ("gridColumnsSizing", o(vec![("entries", sizing)])), + ], + ), + ]; + let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); + let (doc, warnings) = import(&bytes, "Malformed grid tracks").unwrap(); + let layout = named(&doc, "Grid").layout.as_ref().unwrap(); + assert_eq!(layout.column_tracks, vec![figcraft_doc::GridTrack::default(); 4]); + assert!(warnings.iter().any(|w| w.contains("grid track sizing is unsupported or malformed")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("stale or invalid grid track sizing entries")), "{warnings:?}"); +} + +#[test] +fn oversized_grid_track_lists_warn_and_use_default_tracks() { + let oversized: Vec> = (0..1_001).map(|i| unpositioned_track(100 + i)).collect(); + let sizing = vec![track_map_entry(100, track_size("FIXED", 40.0))]; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node( + 2, + Some((1, "!")), + "FRAME", + "Grid", + vec![ + ("size", xy(100.0, 100.0)), + ("transform", at(0.0, 0.0)), + ("stackMode", e("GRID")), + ("gridColumns", o(vec![("entries", arr(oversized))])), + ("gridColumnsSizing", o(vec![("entries", arr(sizing))])), + ], + ), + ]; + let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); + let (doc, warnings) = import(&bytes, "Oversized grid tracks").unwrap(); + let layout = named(&doc, "Grid").layout.as_ref().unwrap(); + assert!(layout.column_tracks.is_empty(), "{:?}", layout.column_tracks.len()); + assert!(warnings.iter().any(|w| w.contains("grid track list exceeds 1000 entries")), "{warnings:?}"); +} + +#[test] +fn kiwi_round_trip() { + let sc = schema(); + let back = Schema::decode(&encode::schema(&sc)).unwrap(); + assert_eq!(back.defs.len(), sc.defs.len()); + for (a, b) in sc.defs.iter().zip(&back.defs) { + assert_eq!((a.name.as_str(), a.kind), (b.name.as_str(), b.kind)); + let fields = |d: &Def| d.fields.iter().map(|f| (f.name.clone(), f.ty, f.array, f.value)).collect::>(); + assert_eq!(fields(a), fields(b)); + } + let msg = message(); + let root = sc.find("Message").unwrap(); + assert_eq!(back.decode_message(root, &encode::message(&sc, root, &msg).unwrap()).unwrap(), msg); +} + +#[test] +fn kiwi_scalars() { + let names = ["f", "i", "u", "i64", "u64", "s", "b"]; + let types = [Type::Float, Type::Int, Type::Uint, Type::Int64, Type::Uint64, Type::Str, Type::Bool]; + let fields = names + .iter() + .zip(types) + .enumerate() + .map(|(i, (n, t))| Field { name: n.to_string(), ty: Some(t), array: false, value: i as u32 + 1 }) + .collect(); + let sc = Schema { defs: vec![Def { name: "M".into(), kind: Kind::Message, fields }] }; + let cases = [ + (0.0f32, 0i32, 0u32, 0i64, 0u64), + (-1.5, -7, 300, i64::MIN, u64::MAX), + (3.25e7, i32::MAX, u32::MAX, i64::MAX, 1 << 60), + (1e-40, i32::MIN, 1, -1, 127), + ]; + for (fv, iv, uv, i64v, u64v) in cases { + // Written by hand: the generic encoder goes through f64, which can't hold every 64-bit value. + let mut w = encode::Writer::default(); + w.var_uint(1); + w.var_float(fv); + w.var_uint(2); + w.var_int(iv); + w.var_uint(3); + w.var_uint(uv); + w.var_uint(4); + w.var_int64(i64v); + w.var_uint(5); + w.var_uint64(u64v); + w.var_uint(6); + w.string("héllo"); + w.var_uint(7); + w.byte(1); + w.var_uint(0); + let got = sc.decode_message(0, &w.0).unwrap(); + // Denormals are stored as zero. + let want_f = if fv.abs() < f32::MIN_POSITIVE { 0.0 } else { fv }; + assert_eq!(got.get("f"), Some(&Kv::Float(want_f))); + assert_eq!(got.get("i"), Some(&Kv::Int(i64::from(iv)))); + assert_eq!(got.get("u"), Some(&Kv::Uint(u64::from(uv)))); + assert_eq!(got.get("i64"), Some(&Kv::Int(i64v))); + assert_eq!(got.get("u64"), Some(&Kv::Uint(u64v))); + assert_eq!(got.get("s"), Some(&s("héllo"))); + assert_eq!(got.get("b"), Some(&Kv::Bool(true))); + } +} + +#[test] +fn imports_the_fixture() { + let bytes = fixture(); + assert!(is_fig(&bytes)); + let (doc, warnings) = import(&bytes, "fallback").unwrap(); + assert_eq!(doc.name, "Fixture"); + assert_fixture_layers(&doc); + assert_fixture_styles(&doc); + assert_fixture_variables(&doc); + assert_fixture_components(&doc); + assert_fixture_prototype(&doc, &warnings); +} + +fn assert_fixture_layers(doc: &Document) { + // The internal page is skipped. + assert_eq!(doc.pages.len(), 1); + let page = doc.node(doc.pages[0]).unwrap(); + assert_eq!(page.name, "Page A"); + // Siblings by position: the frame comes first even though it's listed after the component. + assert_eq!(doc.node(page.children[0]).unwrap().name, "Screen"); + let screen = named(doc, "Screen"); + let names: Vec<&str> = screen.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy", "Note"]); + // Image fill from the archive. + let photo = named(doc, "Photo"); + let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") }; + assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1))); + assert_eq!((photo.corners.radii[0], photo.stroke.weight), (6.0, 2.0)); + // Text with a bold run over "the". + let NodeKind::Text(t) = &named(doc, "Title").kind else { panic!("not text") }; + assert_eq!(t.characters, "Hi there"); + assert_eq!(t.style.font_size, 16.0); + assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)"); + assert_eq!(t.runs.len(), 1); + assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold")); + // The group's children keep their place on the page (group at 100, 100). + assert!(matches!(named(doc, "Group").kind, NodeKind::Group)); + let tri = named(doc, "Tri"); + assert!(matches!(&tri.kind, NodeKind::Vector { network } if !network.regions.is_empty())); + let abs = doc.world_transform(tri.id).translation(); + assert!((abs.x - 105.0).abs() < 1e-6 && (abs.y - 106.0).abs() < 1e-6, "{abs:?}"); + assert!(matches!(named(doc, "Star").kind, NodeKind::Star { count: 6, .. })); +} + +fn assert_fixture_styles(doc: &Document) { + let styled = named(doc, "Styled"); + assert_eq!(styled.styles.fill.as_deref(), Some("1:3")); + assert!(doc.styles.iter().any(|s| s.name == "Brand")); + let figcraft_doc::color::PaintKind::Solid { color } = styled.fills[0].kind else { panic!("not solid") }; + assert_eq!((color.r, color.g, color.b), (0.0, 0.0, 1.0)); +} + +fn assert_fixture_variables(doc: &Document) { + let screen = named(doc, "Screen"); + assert_eq!(doc.variables.collections.len(), 1); + assert_eq!(doc.variables.variables[0].kind, VariableType::Color); + assert_eq!(named(doc, "Card").bound.get("fill").map(String::as_str), Some("1:5")); + assert_eq!(screen.var_modes.get("1:4").map(String::as_str), Some("1:91")); +} + +fn assert_fixture_components(doc: &Document) { + let button = named(doc, "Button"); + assert!(matches!(&button.kind, NodeKind::Component { props, .. } if props.contains_key("Show icon"))); + let inst = named(doc, "Button copy"); + assert!( + matches!(&inst.kind, NodeKind::Instance { component, props } if *component == button.id && props.get("Show icon") == Some(&Value::Bool(false))) + ); + let icon = doc.node(inst.children[0]).unwrap(); + assert_eq!(icon.name, "Renamed icon"); + assert!(!icon.visible); +} + +fn assert_fixture_prototype(doc: &Document, warnings: &[String]) { + let button = named(doc, "Button"); + let screen = named(doc, "Screen"); + let it = &named(doc, "Photo").interactions[0]; + assert!(matches!(it.actions[0], figcraft_doc::proto::Action::Navigate { dest, .. } if dest == button.id)); + assert_eq!(it.duration_ms, 250); + let Some(NodeKind::Page { flows, .. }) = doc.node(doc.pages[0]).map(|p| &p.kind) else { panic!("no page") }; + assert_eq!((flows[0].name.as_str(), flows[0].start), ("Main flow", screen.id)); + // The sticky note comes in as a frame, with a warning. + assert!(matches!(named(doc, "Note").kind, NodeKind::Frame)); + assert!(warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{warnings:?}"); +} + +#[test] +fn bare_canvas_and_zlib_chunks_open() { + let sc = schema(); + let c = canvas(&sc, &message()); + let (doc, w) = import(&c, "Bare").unwrap(); + assert_eq!(doc.name, "Bare"); + // Without the archive the image is a placeholder, reported. + assert!(w.iter().any(|w| w.contains("isn't in the file")), "{w:?}"); + // A zlib-wrapped schema chunk reads too. + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(&encode::schema(&sc)).unwrap(); + let zl = z.finish().unwrap(); + let parts = chunks(&c).unwrap(); + let mut out = c[..12].to_vec(); + for ch in [zl.as_slice(), parts[1]] { + out.extend_from_slice(&(ch.len() as u32).to_le_bytes()); + out.extend_from_slice(ch); + } + assert!(import(&out, "Z").is_ok()); +} + +/// The fixture message with the photo's image bytes in its own blobs, as copies written by plugins +/// and tools carry them: the PNG goes at the end of `blobs` and `image.dataBlob` points `shift` +/// places past it (0 is the PNG itself). +fn message_with_image_in_blobs(shift: u64) -> Kv<'static> { + fn point_images_at(value: &mut Kv<'static>, blob: u64) { + match value { + Kv::Object(fields) => { + for (name, field) in fields.iter_mut() { + match field { + Kv::Object(image) if *name == "image" => image.push(("dataBlob", u(blob))), + _ => point_images_at(field, blob), + } + } + } + Kv::Array(items) => items.iter_mut().for_each(|item| point_images_at(item, blob)), + _ => {} + } + } + let mut msg = message(); + let index = { + let Kv::Object(fields) = &mut msg else { panic!("the message is an object") }; + let Some((_, Kv::Array(blobs))) = fields.iter_mut().find(|(name, _)| *name == "blobs") else { panic!("no blobs") }; + blobs.push(o(vec![("bytes", Kv::Bytes(png()))])); + blobs.len() as u64 - 1 + }; + point_images_at(&mut msg, index + shift); + msg +} + +#[test] +fn a_bare_canvas_reads_images_carried_in_its_blobs() { + // The clipboard holds a bare canvas (no `images/`); the image comes from `image.dataBlob`. + let c = canvas(&schema(), &message_with_image_in_blobs(0)); + let (doc, warnings) = import(&c, "Pasted").unwrap(); + let photo = named(&doc, "Photo"); + let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") }; + assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1))); + assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + +#[test] +fn a_data_blob_past_the_blobs_stays_a_placeholder() { + let c = canvas(&schema(), &message_with_image_in_blobs(5)); + let (_, warnings) = import(&c, "Pasted").unwrap(); + assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + +/// A file can point any number of image hashes at one large blob; each used to get its own copy +/// of it (#1059). +#[test] +fn hashes_naming_one_blob_share_one_copy_of_it() { + let image = |hash: u8, blob: u64| o(vec![("image", o(vec![("hash", Kv::Bytes(vec![hash; 20])), ("dataBlob", u(blob))]))]); + let msg = o(vec![ + ("blobs", Kv::Array(vec![o(vec![("bytes", Kv::Bytes(png()))]), o(vec![("bytes", Kv::Bytes(vec![7; 16]))])])), + ("nodeChanges", Kv::Array((1..=50).map(|h| image(h, 0)).chain([image(99, 1), image(98, 2)]).collect())), + ]); + let copies = embedded_copies(&msg); + assert_eq!(copies.len(), 51, "the hash naming no blob is left out"); + let first = &copies[&"01".repeat(20)]; + assert_eq!(**first, *png()); + assert_eq!(copies.values().filter(|c| Arc::ptr_eq(c, first)).count(), 50); + assert_eq!(*copies[&"63".repeat(20)], [7; 16]); +} + +mod image_budget { + use super::*; + + fn image(hash: u8) -> Vec { + let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([hash, 0, 0, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + } + + fn file(references: &[u8], members: &[(u8, Vec)]) -> Vec { + let mut nodes = vec![node(0, None, "DOCUMENT", "Document", vec![]), node(1, Some((0, "!")), "CANVAS", "Page", vec![])]; + for (i, hash) in references.iter().enumerate() { + nodes.push(node( + i as u64 + 2, + Some((1, &format!("{i:04}"))), + "RECTANGLE", + &format!("Image {i}"), + vec![ + ("size", xy(10.0, 10.0)), + ("fillPaints", arr(vec![o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(vec![*hash; 20]))]))])])), + ], + )); + } + let canvas = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + z.start_file("canvas.fig", deflated).unwrap(); + z.write_all(&canvas).unwrap(); + for (hash, bytes) in members { + z.start_file(format!("images/{}", format!("{hash:02x}").repeat(20)), deflated).unwrap(); + z.write_all(bytes).unwrap(); + } + z.finish().unwrap().into_inner() + } + + fn bytes(doc: &Document, index: usize) -> &[u8] { + let node = named(doc, &format!("Image {index}")); + let figcraft_doc::color::PaintKind::Image(fill) = &node.fills[0].kind else { panic!("not an image fill") }; + &doc.images[&fill.image].bytes + } + + fn budget_warnings(warnings: &[String]) -> Vec<&str> { + warnings.iter().filter(|w| w.contains("total ZIP image byte budget")).map(String::as_str).collect() + } + + fn change_member_field(zip: &mut [u8], hash: u8, offset: usize, value: u32) { + let start = { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&*zip)).unwrap(); + z.by_name(&format!("images/{}", format!("{hash:02x}").repeat(20))).unwrap().central_header_start() as usize + }; + zip[start + offset..start + offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + #[test] + fn exact_total_limit_keeps_all_images_and_each_import_gets_its_own_budget() { + let members = [(1, image(1)), (2, image(2))]; + let limit = members.iter().map(|(_, b)| b.len() as u64).sum(); + let zip = file(&[1, 2], &members); + for _ in 0..2 { + let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap(); + assert_eq!(bytes(&doc, 0), members[0].1); + assert_eq!(bytes(&doc, 1), members[1].1); + assert!(warnings.is_empty(), "{warnings:?}"); + } + } + + #[test] + fn individually_acceptable_images_overflow_the_total_and_leave_placeholders() { + let members = [(1, image(1)), (2, image(2))]; + let limit = members.iter().map(|(_, b)| b.len() as u64).sum::() - 1; + assert!(members.iter().all(|(_, b)| b.len() as u64 <= limit)); + let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap(); + assert_eq!(bytes(&doc, 0), members[0].1); + assert_ne!(bytes(&doc, 1), members[1].1); + assert!(!doc.images.contains_key(&crate::image_hash(&members[1].1))); + let notes = budget_warnings(&warnings); + assert_eq!(notes.len(), 1, "{warnings:?}"); + assert!(notes[0].contains("1 ZIP image") && notes[0].contains(&limit.to_string()), "{warnings:?}"); + } + + #[test] + fn repeated_references_do_not_charge_or_count_a_rejection_twice() { + let members = [(1, image(1)), (2, image(2))]; + let (doc, warnings) = import_with_image_limit(&file(&[1, 1, 2, 2, 1], &members), "Images", members[0].1.len() as u64).unwrap(); + for i in [0, 1, 4] { + assert_eq!(bytes(&doc, i), members[0].1); + } + assert_eq!(bytes(&doc, 2), bytes(&doc, 3)); + assert_ne!(bytes(&doc, 2), members[1].1); + let notes = budget_warnings(&warnings); + assert_eq!(notes.len(), 1, "{warnings:?}"); + assert!(notes[0].contains("1 ZIP image"), "{warnings:?}"); + } + + #[test] + fn distinct_members_with_identical_contents_still_use_the_read_budget() { + let png = image(1); + let members = [(1, png.clone()), (2, png.clone())]; + let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap(); + assert_eq!(bytes(&doc, 0), png); + assert_ne!(bytes(&doc, 1), png); + assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); + } + + #[test] + fn rejecting_a_member_before_reading_leaves_room_for_a_smaller_one() { + let png = image(1); + let members = [(1, vec![0; png.len() + 1]), (2, png.clone())]; + let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap(); + assert_ne!(bytes(&doc, 0), members[0].1); + assert_eq!(bytes(&doc, 1), png); + assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); + } + + #[test] + fn failed_crc_reads_still_consume_the_bytes_read() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&[1, 2], &members); + change_member_field(&mut zip, 1, 16, 0); + let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64 + 1).unwrap(); + assert_ne!(bytes(&doc, 0), members[0].1); + assert_ne!(bytes(&doc, 1), members[1].1); + assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); + } + + #[test] + fn a_truncated_deflate_read_keeps_its_partial_charge() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&[1, 2], &members); + // Shorten the first member's compressed size, cutting its deflate stream mid-image. + change_member_field(&mut zip, 1, 20, 15); + let limit = members[0].1.len() as u64; + let archive = Archive::open(&zip).unwrap(); + archive.image_budget.remaining.set(limit); + assert!(archive.image(&"01".repeat(20)).is_none()); + let remaining = archive.image_budget.remaining.get(); + assert!(remaining > 0 && remaining < limit, "partial read left {remaining} of {limit} bytes"); + assert!(archive.image(&"02".repeat(20)).is_none()); + assert_eq!(archive.image_budget.remaining.get(), remaining); + let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap(); + assert_ne!(bytes(&doc, 1), members[1].1); + assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); + } + + #[test] + fn the_individual_image_cap_is_preserved_without_reading_a_large_member() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&[1, 2], &members); + change_member_field(&mut zip, 1, 24, (MAX_IMAGE + 1) as u32); + let (doc, warnings) = import_with_image_limit(&zip, "Images", members[1].1.len() as u64).unwrap(); + assert_ne!(bytes(&doc, 0), members[0].1); + assert_eq!(bytes(&doc, 1), members[1].1); + assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); + } + + #[test] + fn crc_is_checked_even_when_the_read_ends_exactly_at_the_budget() { + let members = [(1, image(1))]; + let mut zip = file(&[1], &members); + change_member_field(&mut zip, 1, 16, 0); + let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64).unwrap(); + assert_ne!(bytes(&doc, 0), members[0].1); + assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); + assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); + } + + #[test] + fn an_understated_size_cannot_read_past_the_remaining_budget() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&[1, 2], &members); + change_member_field(&mut zip, 1, 24, 1); + let archive = Archive::open(&zip).unwrap(); + archive.image_budget.remaining.set(members[0].1.len() as u64 - 1); + assert!(archive.image(&"01".repeat(20)).is_none()); + assert_eq!(archive.image_budget.remaining.get(), 0); + assert_eq!(archive.image_budget.skipped.get(), 1); + assert!(archive.image(&"02".repeat(20)).is_none()); + assert_eq!(archive.image_budget.remaining.get(), 0); + assert_eq!(archive.image_budget.skipped.get(), 2); + } + + #[test] + fn invalid_image_contents_are_charged_before_image_validation() { + let members = [(1, vec![0; 8]), (2, image(2))]; + let limit = members[1].1.len() as u64 + 7; + let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap(); + assert_ne!(bytes(&doc, 1), members[1].1); + assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); + } + + #[test] + fn missing_and_unreferenced_members_do_not_consume_the_budget() { + let members = [(1, image(1)), (2, vec![0; 128])]; + let (doc, warnings) = import_with_image_limit(&file(&[9, 1], &members), "Images", members[0].1.len() as u64).unwrap(); + assert_eq!(bytes(&doc, 1), members[0].1); + assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); + assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); + } + + #[test] + fn embedded_image_fallback_is_independent_of_the_zip_budget() { + let canvas = canvas(&schema(), &message_with_image_in_blobs(0)); + for bytes in [&canvas, &zip_file(&canvas)] { + let (doc, warnings) = import_with_image_limit(bytes, "Images", 0).unwrap(); + let photo = named(&doc, "Photo"); + let figcraft_doc::color::PaintKind::Image(fill) = &photo.fills[0].kind else { panic!("not an image fill") }; + assert_eq!(*doc.images[&fill.image].bytes, png()); + assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); + assert_eq!(budget_warnings(&warnings).len(), usize::from(bytes.starts_with(b"PK")), "{warnings:?}"); + } + } + + #[test] + fn default_import_preserves_image_bytes_dimensions_and_existing_warnings() { + let members = [(1, image(1)), (2, image(2))]; + let (doc, warnings) = import(&file(&[1, 2], &members), "Images").unwrap(); + assert_eq!(bytes(&doc, 0), members[0].1); + assert_eq!(bytes(&doc, 1), members[1].1); + assert!(doc.images.values().all(|img| (img.width, img.height) == (2, 1))); + assert!(warnings.is_empty(), "{warnings:?}"); + let (doc, warnings) = import(&fixture(), "Fixture").unwrap(); + assert!(doc.images.values().any(|img| *img.bytes == png() && (img.width, img.height) == (2, 1))); + assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); + } +} + +#[test] +fn other_files_are_not_fig() { + assert!(try_import(b"{\"document\": {}}", "x").is_none()); + assert!(try_import(b"", "x").is_none()); + let native = crate::save_bytes(&figcraft_doc::Document::new("N"), None).unwrap(); + assert!(try_import(&native, "x").is_none()); +} + +/// A cheap deterministic generator (mangled inputs, generated fixtures). +pub(super) struct Lcg(pub(super) u64); +impl Lcg { + pub(super) fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + self.0 >> 33 + } +} + +#[test] +fn mangled_files_never_panic() { + let bytes = fixture(); + let c = canvas(&schema(), &message()); + let mut r = Lcg(7); + for src in [&bytes, &c] { + for cut in (0..src.len()).step_by(37) { + let _ = import(&src[..cut], "x"); + } + for _ in 0..300 { + let mut m = src.clone(); + for _ in 0..1 + r.next() % 8 { + let i = (r.next() as usize) % m.len(); + m[i] ^= 1 << (r.next() % 8); + } + let _ = import(&m, "x"); + } + } +} + +#[test] +fn mangled_kiwi_never_panics() { + let sc = schema(); + let root = sc.find("Message").unwrap(); + let schema_bytes = encode::schema(&sc); + let msg = encode::message(&sc, root, &message()).unwrap(); + let mut r = Lcg(11); + for cut in 0..schema_bytes.len() { + let _ = Schema::decode(&schema_bytes[..cut]); + } + for cut in (0..msg.len()).step_by(3) { + if let Ok(kv) = sc.decode_message(root, &msg[..cut]) { + let _ = map::to_rest(&kv, "x", crate::rest::MAX_NODES); + } + } + for _ in 0..2000 { + let mut m = msg.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 (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES); + let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES); + } + let mut sbytes = schema_bytes.clone(); + let i = (r.next() as usize) % sbytes.len(); + sbytes[i] = r.next() as u8; + if let Ok(s2) = Schema::decode(&sbytes) + && let Some(rt) = s2.find("Message") + { + let _ = s2.decode_message(rt, &msg); + } + } + // Pure junk, bare and framed. + for _ in 0..500 { + let junk: Vec = (0..(r.next() % 200)).map(|_| r.next() as u8).collect(); + let _ = Schema::decode(&junk); + let _ = sc.decode_message(root, &junk); + let mut framed = b"fig-kiwi\0\0\0\0".to_vec(); + framed.extend_from_slice(&junk); + let _ = import(&framed, "x"); + } +} + +#[test] +fn large_files_get_a_larger_value_budget() { + // Small files keep the fixed floor: a few bytes can't expand into a huge tree. + assert_eq!(kiwi::value_budget(0), 20_000_000); + assert_eq!(kiwi::value_budget(1 << 20), 20_000_000); + // A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds + // 30.5 million values was refused as "too many values". + assert!(kiwi::value_budget(83_491_232) > 30_514_899); + // A real 194 MB design-system file (UX4G): a 39.5 MB canvas holding 29.7 million values. + assert!(kiwi::value_budget(39_528_420) > 29_703_730); + // Still bounded by the largest canvas read. + assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); + // Counting every scalar field, a dense file may reach about 2.4 values per compressed byte (the + // 15 MB text-heavy fixture in `value_budget_tests` measures 1.8): below the ceiling the budget must + // cover that, the floor still holds up to 5 MB of canvas, and 32 MiB is enough for the ceiling. + assert!(kiwi::value_budget(8 << 20) >= (8 << 20) * 12 / 5); + #[cfg(not(target_pointer_width = "32"))] + assert!(kiwi::value_budget(30_000_000) >= 30_000_000 * 12 / 5); + assert_eq!(kiwi::value_budget(5_000_000), 20_000_000); + assert_eq!(kiwi::value_budget(32 << 20), kiwi::value_budget(MAX_CANVAS as usize)); +} + +#[test] +fn the_layer_cap_follows_the_file_size() { + // A small (or broken) file keeps the REST import's cap: instances expand into a layer per + // layer of their component, and a few bytes mustn't claim millions of layers. + assert_eq!(layer_budget(0), crate::rest::MAX_NODES); + assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES); + // A real 894 004-layer file (an 83.5 MB canvas) stopped at 200 000 and came in with 27 of + // its 30 pages empty. + assert!(layer_budget(83_491_232) > 894_004); + // No file gets more than the ceiling. + assert_eq!(layer_budget(MAX_CANVAS as usize), MAX_FIG_NODES); +} + +#[test] +fn a_small_file_that_unpacks_large_gets_no_larger_budgets() { + // 40 MB of zeros in a blob pack into a few KB. Keyed to the unpacked message, they lifted the + // layer cap above the 231 231 layers its instances expand into; keyed to the file, the cap + // stays at the REST import's. + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.push(node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(10.0, 10.0))])); + nodes.extend((10..1010).map(|i| node(i, Some((5, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + nodes.extend((2000..2230).map(|i| node(i, Some((1, "!")), "INSTANCE", "I", vec![("symbolData", o(vec![("symbolID", g(5))]))]))); + let blob = o(vec![("bytes", Kv::Bytes(vec![0; 40 << 20]))]); + let file = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![blob]))])); + assert!(file.len() < 1 << 20, "{} bytes", file.len()); + let (doc, warnings) = import(&file, "x").unwrap(); + assert!(warnings.iter().any(|w| w.contains("more than 200000 layers")), "{warnings:?}"); + assert!(doc.node_count() <= crate::rest::MAX_NODES + 10); +} + +#[test] +fn the_mapper_stops_at_the_layer_cap_it_is_given() { + 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 (_, warnings, _) = map::to_rest(&msg, "x", 5); + assert!(warnings.iter().any(|w| w.contains("more than 5 layers")), "{warnings:?}"); + let (_, warnings, _) = map::to_rest(&msg, "x", crate::rest::MAX_NODES); + assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); +} + +#[test] +fn scalar_fields_count_against_the_value_budget() { + // A message of 1000 scalar fields (id 1, `true`) is 1001 values: a budget of 10 refuses it. + let fields = vec![Field { name: "b".into(), ty: Some(Type::Bool), array: false, value: 1 }]; + let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] }; + let mut data = [1u8, 1].repeat(1000); + data.push(0); + let e = sc.decode_message_within(0, &data, 10).unwrap_err(); + assert_eq!(e.0, "message: too many values"); + assert!(sc.decode_message_within(0, &data, 1001).is_ok()); +} + +#[test] +fn hostile_input_is_refused() { + // A message that contains itself, nested past the depth cap. + let field = |ty, array| vec![Field { name: "m".into(), ty: Some(ty), array, value: 1 }]; + let nested = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields: field(Type::Def(0), false) }] }; + assert!(nested.decode_message(0, &[1u8; 10_000]).is_err()); + // An array count larger than the data. + let array = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields: field(Type::Uint, true) }] }; + assert!(array.decode_message(0, &[1, 0xff, 0xff, 0xff, 0xff, 0x0f]).is_err()); + // A field type naming a missing definition. + let mut w = encode::Writer::default(); + w.var_uint(1); + w.string("Message"); + w.byte(2); + w.var_uint(1); + w.string("x"); + w.var_int(5); + w.byte(0); + w.var_uint(1); + assert!(Schema::decode(&w.0).is_err()); + // A schema without a Message definition. + let c = canvas(&Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields: vec![] }] }, &Kv::Object(vec![])); + let renamed: Vec = { + let sc = Schema { defs: vec![Def { name: "Other".into(), kind: Kind::Message, fields: vec![] }] }; + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(&encode::schema(&sc)).unwrap(); + let sch = z.finish().unwrap(); + let mut out = c[..12].to_vec(); + out.extend_from_slice(&(sch.len() as u32).to_le_bytes()); + out.extend_from_slice(&sch); + out.extend_from_slice(&c[12 + 4 + chunks(&c).unwrap()[0].len()..]); + out + }; + assert!(import(&c, "x").is_ok()); + assert!(import(&renamed, "x").is_err()); +} + +#[test] +fn instances_inside_their_own_main_stop() { + let sym = node(5, Some((1, "!")), "SYMBOL", "Loop", vec![("size", xy(10.0, 10.0))]); + let inner = node(6, Some((5, "!")), "INSTANCE", "Self", vec![("symbolData", o(vec![("symbolID", g(5))]))]); + let doc_nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![]), sym, inner]; + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(doc_nodes))])), "x").unwrap(); + assert!(doc.nodes().any(|n| n.name == "Self")); +} + +/// A collection from a library is named by its asset key, not a guid — also in the explicit mode +/// map of the frames, components and instances set it on, and in the variables' collection link. +#[test] +fn modes_of_collections_named_by_asset_import() { + let modes = arr(vec![ + o(vec![("id", g(90)), ("name", s("Dark")), ("sortPosition", s("!"))]), + o(vec![("id", g(91)), ("name", s("Light")), ("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(0.0, 0.0, 0.0)))]), + o(vec![("modeID", g(91)), ("variableData", color_value(rgb(1.0, 1.0, 1.0)))]), + ]), + )]); + let by_asset = |key: &'static str, version: Option<&'static str>| { + let mut r = vec![("key", s(key))]; + r.extend(version.map(|v| ("version", s(v)))); + o(vec![("assetRef", o(r))]) + }; + let mode_map = |set: Kv<'static>| o(vec![("entries", arr(vec![o(vec![("variableSetID", set), ("variableModeID", g(91))])]))]); + 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))]), + // The collection itself says the asset key its references name. + node(4, Some((2, "!")), "VARIABLE_SET", "Schema", vec![("variableSetModes", modes), ("key", s("schema-collection")), ("version", s("7"))]), + node( + 5, + Some((2, "\"")), + "VARIABLE", + "surface", + vec![("variableSetID", by_asset("schema-collection", Some("7"))), ("variableResolvedType", e("COLOR")), ("variableDataValues", values)], + ), + node( + 10, + Some((1, "!")), + "FRAME", + "Light section", + vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7"))))], + ), + node( + 20, + Some((1, "\"")), + "SYMBOL", + "Button", + vec![("size", xy(100.0, 40.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", None)))], + ), + node( + 21, + Some((1, "#")), + "INSTANCE", + "Button copy", + vec![ + ("size", xy(100.0, 40.0)), + ("symbolData", o(vec![("symbolID", g(20))])), + ("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7")))), + ], + ), + // One names a collection that isn't in the file: its mode is skipped, with a warning. + node(11, Some((1, "$")), "FRAME", "Gone", vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("gone", None)))]), + ]; + let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert_eq!(doc.variables.collections.len(), 1); + assert_eq!(doc.variables.variables.len(), 1); + assert_eq!(doc.variables.variables[0].collection, "1:4"); + for name in ["Light section", "Button", "Button copy"] { + let n = named(&doc, name); + assert_eq!(n.var_modes.get("1:4").map(String::as_str), Some("1:91"), "{name}"); + } + assert!(named(&doc, "Gone").var_modes.is_empty()); + assert!(warnings.iter().any(|w| w.contains("variable modes of collections that aren't in the file")), "{warnings:?}"); +} + +#[test] +fn resized_instances_lay_their_layers_out_by_their_constraints() { + let pin = |h: &'static str, v: &'static str| vec![("horizontalConstraint", e(h)), ("verticalConstraint", e(v))]; + let layer = |id, pos, ty, name, w, h, x, y, c: Vec<(&'static str, Kv<'static>)>| { + node(id, Some((5, pos)), ty, name, [vec![("size", xy(w, h)), ("transform", at(x, y))], c].concat()) + }; + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(5, Some((1, "!")), "SYMBOL", "Logo", vec![("size", xy(132.0, 22.0)), ("transform", at(0.0, 0.0))]), + layer(6, "!", "ROUNDED_RECTANGLE", "Mark", 132.0, 22.0, 0.0, 0.0, pin("SCALE", "SCALE")), + layer(7, "\"", "ROUNDED_RECTANGLE", "Badge", 32.0, 10.0, 100.0, 12.0, pin("MAX", "MAX")), + layer(8, "#", "ROUNDED_RECTANGLE", "Bar", 112.0, 2.0, 10.0, 0.0, pin("STRETCH", "MIN")), + // A group scales its layers with it. + layer(11, "$", "FRAME", "Wordmark", 66.0, 22.0, 66.0, 0.0, [pin("SCALE", "SCALE"), vec![("resizeToFit", Kv::Bool(true))]].concat()), + node(12, Some((11, "!")), "ROUNDED_RECTANGLE", "Letter", vec![("size", xy(66.0, 22.0)), ("transform", at(0.0, 0.0))]), + node( + 9, + Some((1, "\"")), + "INSTANCE", + "Small logo", + vec![("size", xy(90.0, 15.0)), ("transform", at(0.0, 100.0)), ("symbolData", o(vec![("symbolID", g(5))]))], + ), + // Derived layout the file gives wins over the constraints. + node( + 10, + Some((1, "#")), + "INSTANCE", + "Derived logo", + vec![ + ("size", xy(90.0, 15.0)), + ("transform", at(0.0, 200.0)), + ("symbolData", o(vec![("symbolID", g(5))])), + ("derivedSymbolData", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("size", xy(50.0, 5.0))])])), + ], + ), + // A size the instance overrides: the place still follows the constraints. + node( + 13, + Some((1, "$")), + "INSTANCE", + "Resized badge", + vec![ + ("size", xy(90.0, 15.0)), + ("transform", at(0.0, 300.0)), + ( + "symbolData", + o(vec![ + ("symbolID", g(5)), + ("symbolOverrides", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(7)]))])), ("size", xy(20.0, 10.0))])])), + ]), + ), + ], + ), + ]; + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + let box_of = |inst: &str, name: &str| { + let i = named(&doc, inst); + let n = i.children.iter().map(|c| doc.node(*c).unwrap()).find(|n| n.name == name).unwrap(); + let t = n.transform.translation(); + [t.x, t.y, n.width, n.height].map(|x| (x * 1e3).round() / 1e3) + }; + assert_eq!(box_of("Small logo", "Mark"), [0.0, 0.0, 90.0, 15.0]); + assert_eq!(box_of("Small logo", "Badge"), [58.0, 5.0, 32.0, 10.0]); + assert_eq!(box_of("Small logo", "Bar"), [10.0, 0.0, 70.0, 2.0]); + // The main's own, then the instances'. + let mut letters: Vec<[f64; 2]> = + doc.nodes().filter(|n| n.name == "Letter").map(|n| [n.width, n.height].map(|x| (x * 1e3).round() / 1e3)).collect(); + letters.sort_by(|a, b| a[0].total_cmp(&b[0])); + assert_eq!(letters, [[45.0, 15.0], [45.0, 15.0], [45.0, 15.0], [66.0, 22.0]]); + assert_eq!(box_of("Derived logo", "Mark")[2..], [50.0, 5.0]); + assert_eq!(box_of("Resized badge", "Badge"), [58.0, 5.0, 20.0, 10.0]); +} + +#[test] +fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { + let stretch = || vec![("horizontalConstraint", e("STRETCH")), ("verticalConstraint", e("MIN"))]; + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(20, Some((1, "!")), "SYMBOL", "Track", vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))]), + node(21, Some((20, "!")), "ROUNDED_RECTANGLE", "Fill", [vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))], stretch()].concat()), + node(30, Some((1, "\"")), "SYMBOL", "Slider", vec![("size", xy(200.0, 50.0)), ("transform", at(0.0, 100.0))]), + // The nested instance is 150 wide in its main, with the layout derived for that. + node( + 31, + Some((30, "!")), + "INSTANCE", + "Inner", + [ + vec![ + ("size", xy(150.0, 10.0)), + ("transform", at(0.0, 0.0)), + ("symbolData", o(vec![("symbolID", g(20))])), + ( + "derivedSymbolData", + arr(vec![o(vec![ + ("guidPath", o(vec![("guids", arr(vec![g(21)]))])), + ("size", xy(150.0, 10.0)), + ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])), + ])]), + ), + ], + stretch(), + ] + .concat(), + ), + node( + 40, + Some((1, "#")), + "INSTANCE", + "Wide slider", + vec![("size", xy(300.0, 50.0)), ("transform", at(0.0, 200.0)), ("symbolData", o(vec![("symbolID", g(30))]))], + ), + ]; + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + let width = |outer: &str| { + let inner = doc.node(named(&doc, outer).children[0]).unwrap(); + (inner.width, doc.node(inner.children[0]).unwrap().width) + }; + // In its main the derived layout holds; stretched to 250 it no longer does. + assert_eq!(width("Slider"), (150.0, 150.0)); + assert_eq!(width("Wide slider"), (250.0, 250.0)); + // What else the derived data gives (the colour a variable resolves to, say) still holds. + let fill = doc.node(doc.node(named(&doc, "Wide slider").children[0]).unwrap().children[0]).unwrap(); + assert!(matches!(fill.fills.first().map(|p| &p.kind), Some(figcraft_doc::color::PaintKind::Solid { color }) if color.r == 1.0 && color.g == 0.0)); +} + +#[path = "tail_tests.rs"] +mod tail; + +#[path = "image_filter_tests.rs"] +mod image_filters; + +#[path = "text_list_tests.rs"] +mod text_list; + +/// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset). +pub(super) fn samples() -> Vec { + let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() }; + let dir = std::fs::read_dir(dir).into_iter().flatten().flatten(); + dir.map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "fig")).collect() +} + +/// Local only (`FIGCRAFT_FIG_SAMPLES`): every file imports without warnings. +#[test] +fn local_samples_import() { + for p in samples() { + let bytes = std::fs::read(&p).unwrap(); + let (doc, warnings) = import(&bytes, "sample").unwrap_or_else(|e| panic!("{}: {e}", p.display())); + assert!(warnings.is_empty(), "{}: {warnings:?}", p.display()); + assert!(doc.node_count() > 5, "{}", p.display()); + } +} + +/// Local only: dumps each sample's schema, decoded message and REST JSON when `FIG_DUMP` names a +/// folder (for working on the importer). +#[test] +fn dump_samples() { + let Ok(out) = std::env::var("FIG_DUMP") else { return }; + for p in samples() { + let bytes = std::fs::read(&p).unwrap(); + let a = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&a.canvas).unwrap(); + let stem = p.file_stem().unwrap().to_string_lossy().to_string(); + let mut txt = String::new(); + for d in &schema.defs { + txt += &format!("{:?} {}\n", d.kind, d.name); + for f in &d.fields { + let ty = match f.ty { + Some(Type::Def(i)) => schema.defs[i].name.clone(), + Some(t) => format!("{t:?}"), + None => String::new(), + }; + txt += &format!(" {} {}{} = {}\n", f.name, ty, if f.array { "[]" } else { "" }, f.value); + } + } + std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap(); + let (rest, ..) = map::to_rest(&msg, &stem, super::layer_budget(message.len())); + std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap(); + } +} + +#[path = "board_import_tests.rs"] +mod board; + +/// A file that took several updates of a library holds a copy of its collection and of each of +/// its variables per version it met (same asset key, another version). Layers and modes name any +/// of them by asset, never by guid: they all become the newest copy, which has every mode — a page +/// set to a mode only the newer copies have keeps it, and fills bound to an older copy of a +/// variable follow its newest values. +#[test] +fn library_copies_of_several_versions_become_the_newest() { + let mode = |id, name: &'static str, pos: &'static str| o(vec![("id", g(id)), ("name", s(name)), ("sortPosition", s(pos))]); + let by_asset = |key: &'static str, version: &'static str| o(vec![("assetRef", o(vec![("key", s(key)), ("version", s(version))]))]); + let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]); + let values = |pairs: Vec<(u64, Kv<'static>)>| { + o(vec![("entries", arr(pairs.into_iter().map(|(m, c)| o(vec![("modeID", g(m)), ("variableData", color_value(c))])).collect()))]) + }; + let asset_alias = + |key: &'static str, version: &'static str| o(vec![("value", o(vec![("alias", by_asset(key, version))])), ("dataType", e("ALIAS"))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + // The page is set to "Purple", a mode the oldest copy of the collection didn't have yet. + node( + 1, + Some((0, "!")), + "CANVAS", + "Page", + vec![( + "variableModeBySetMap", + o(vec![("entries", arr(vec![o(vec![("variableSetID", by_asset("products", "10:1")), ("variableModeID", g(92))])]))]), + )], + ), + node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + node( + 4, + Some((2, "!")), + "VARIABLE_SET", + "Products", + vec![("variableSetModes", arr(vec![mode(90, "Blue", "!")])), ("key", s("products")), ("version", s("10:1"))], + ), + node( + 6, + Some((2, "#")), + "VARIABLE_SET", + "Products", + vec![("variableSetModes", arr(vec![mode(90, "Blue", "!"), mode(92, "Purple", "\"")])), ("key", s("products")), ("version", s("20:1"))], + ), + node( + 5, + Some((2, "\"")), + "VARIABLE", + "button-bg", + vec![ + ("variableSetID", by_asset("products", "10:1")), + ("variableResolvedType", e("COLOR")), + ("variableDataValues", values(vec![(90, rgb(0.0, 0.0, 1.0))])), + ("key", s("button-bg")), + ("version", s("10:1")), + ], + ), + node( + 7, + Some((2, "$")), + "VARIABLE", + "button-bg", + vec![ + ("variableSetID", by_asset("products", "20:1")), + ("variableResolvedType", e("COLOR")), + ("variableDataValues", values(vec![(90, rgb(0.0, 0.0, 1.0)), (92, rgb(0.5, 0.0, 0.5))])), + ("key", s("button-bg")), + ("version", s("20:1")), + ], + ), + // The button names the older copy of the variable. + node( + 10, + Some((1, "!")), + "FRAME", + "Button", + vec![ + ("size", xy(100.0, 40.0)), + ( + "fillPaints", + arr(vec![o(vec![("type", e("SOLID")), ("color", rgb(0.0, 0.0, 1.0)), ("colorVar", asset_alias("button-bg", "10:1"))])]), + ), + ], + ), + ]; + let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert_eq!(doc.variables.collections.len(), 1, "{:?}", doc.variables.collections); + let products = &doc.variables.collections[0]; + assert_eq!(products.modes.iter().map(|m| m.name.as_str()).collect::>(), ["Blue", "Purple"]); + assert_eq!(doc.variables.variables.len(), 1); + let var = &doc.variables.variables[0]; + assert_eq!(var.collection, products.id); + assert_eq!(var.values.len(), 2, "the newest copy's values: {:?}", var.values); + let page = doc.nodes().find(|n| n.name == "Page").unwrap(); + assert_eq!(page.var_modes.get(&products.id).map(String::as_str), Some("1:92")); + let button = named(&doc, "Button"); + assert_eq!(button.bound.values().next(), Some(&var.id), "{:?}", button.bound); + assert!(!warnings.iter().any(|w| w.contains("variable modes of collections")), "{warnings:?}"); +} From a5ebc01d2c912f6308e5f10040880b816aac8445 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 13:50:48 +0330 Subject: [PATCH 04/16] Add files via upload --- crates/format/src/fig/map/document.rs | 59 ++++++++++++++++++-------- crates/format/src/fig/map/variables.rs | 4 +- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/crates/format/src/fig/map/document.rs b/crates/format/src/fig/map/document.rs index 69f221c1a..6868de485 100644 --- a/crates/format/src/fig/map/document.rs +++ b/crates/format/src/fig/map/document.rs @@ -259,28 +259,37 @@ impl<'a> Mapper<'a> { }; 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") { - order.push(json!(id)); - let mut modes: Vec<(&str, 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(""), mode)) - }) - .collect(); - modes.sort_by(|a, b| a.0.cmp(b.0)); - 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})); + 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") { - let Some(set) = n.get("variableSetID").and_then(|s| self.resource_id(s)).filter(|s| collections.contains_key(s)) else { continue }; + 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; @@ -306,3 +315,19 @@ impl<'a> Mapper<'a> { .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/format/src/fig/map/variables.rs b/crates/format/src/fig/map/variables.rs index 8d891ded7..12b433fc0 100644 --- a/crates/format/src/fig/map/variables.rs +++ b/crates/format/src/fig/map/variables.rs @@ -49,7 +49,7 @@ impl<'a> Mapper<'a> { 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.resource_id(set) { + match self.collection_id(set) { Some(id) => { modes.insert(id, json!(mode)); } @@ -143,7 +143,7 @@ pub(super) fn alias(d: Option<&Kv>) -> Option { if d.get("dataType").and_then(Kv::str) != Some("ALIAS") { return None; } - d.get("value").and_then(|v| v.get("alias")).and_then(ref_guid) + d.get("value").and_then(|v| v.get("alias")).and_then(super::VarCanon::resolve) } pub(super) fn variable_value(d: &Kv) -> Option { From 69692323267133138377d14e03fbc7b142fb0638 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 13:53:11 +0330 Subject: [PATCH 05/16] Add files via upload --- crates/format/src/fig/map.rs | 104 +++++++++++++++++++++++++++++++++ crates/format/src/fig/tests.rs | 99 +++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 3d410e07d..98239cd7b 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -48,6 +48,7 @@ const MAX_ASSET_PART: usize = 256; /// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]). pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec, Vec>) { let mut m = Mapper::new(msg, max_nodes); + 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. @@ -365,6 +366,30 @@ impl<'a> Mapper<'a> { .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. + pub(super) fn collection_id(&self, v: &Kv) -> Option { + let id = self.resource_id(v)?; + Some(self.canonical_collection(&id).unwrap_or(id)) + } + + /// 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())?; + let live_set = |n: &Kv| n.get("type").and_then(Kv::str) == Some("VARIABLE_SET") && n.get("isSoftDeleted").and_then(Kv::bool) != Some(true); + self.assets + .get(key)? + .iter() + .filter(|(_, nid)| self.nodes.get(nid.as_str()).is_some_and(|n| live_set(n))) + .max_by_key(|(ver, _)| version_rank(ver)) + .map(|(_, nid)| nid.clone()) + } + /// 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 @@ -384,3 +409,82 @@ impl<'a> Mapper<'a> { 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, +} + +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 { + fn drop(&mut self) { + VAR_CANON.with(|c| c.borrow_mut().take()); + } +} + +impl VarCanon { + fn install(m: &Mapper) -> VarCanonGuard { + let mut newest: HashMap = HashMap::new(); + let mut copies: Vec<(String, String)> = Vec::new(); + for (id, n) in &m.nodes { + if n.get("type").and_then(Kv::str) != Some("VARIABLE") || n.get("isSoftDeleted").and_then(Kv::bool) == Some(true) { + continue; + } + 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(); + VAR_CANON.with(|c| *c.borrow_mut() = Some(t)); + VarCanonGuard + } + + /// The variable a reference (`{guid}` or `{assetRef}`) names, as its newest copy. + 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 { + return Some(t.by_id.get(g).cloned().unwrap_or_else(|| g.clone())); + } + let key = r.get("assetRef")?.get("key").and_then(Kv::str)?; + t.by_key.get(key).cloned() + }) + } + + /// 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/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 657f2f81d..0e2c5016e 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -1487,3 +1487,102 @@ fn dump_samples() { #[path = "board_import_tests.rs"] mod board; + +/// A file that took several updates of a library holds a copy of its collection and of each of +/// its variables per version it met (same asset key, another version). Layers and modes name any +/// of them by asset, never by guid: they all become the newest copy, which has every mode — a page +/// set to a mode only the newer copies have keeps it, and fills bound to an older copy of a +/// variable follow its newest values. +#[test] +fn library_copies_of_several_versions_become_the_newest() { + let mode = |id, name: &'static str, pos: &'static str| o(vec![("id", g(id)), ("name", s(name)), ("sortPosition", s(pos))]); + let by_asset = |key: &'static str, version: &'static str| o(vec![("assetRef", o(vec![("key", s(key)), ("version", s(version))]))]); + let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]); + let values = |pairs: Vec<(u64, Kv<'static>)>| { + o(vec![("entries", arr(pairs.into_iter().map(|(m, c)| o(vec![("modeID", g(m)), ("variableData", color_value(c))])).collect()))]) + }; + let asset_alias = + |key: &'static str, version: &'static str| o(vec![("value", o(vec![("alias", by_asset(key, version))])), ("dataType", e("ALIAS"))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + // The page is set to "Purple", a mode the oldest copy of the collection didn't have yet. + node( + 1, + Some((0, "!")), + "CANVAS", + "Page", + vec![( + "variableModeBySetMap", + o(vec![("entries", arr(vec![o(vec![("variableSetID", by_asset("products", "10:1")), ("variableModeID", g(92))])]))]), + )], + ), + node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + node( + 4, + Some((2, "!")), + "VARIABLE_SET", + "Products", + vec![("variableSetModes", arr(vec![mode(90, "Blue", "!")])), ("key", s("products")), ("version", s("10:1"))], + ), + node( + 6, + Some((2, "#")), + "VARIABLE_SET", + "Products", + vec![("variableSetModes", arr(vec![mode(90, "Blue", "!"), mode(92, "Purple", "\"")])), ("key", s("products")), ("version", s("20:1"))], + ), + node( + 5, + Some((2, "\"")), + "VARIABLE", + "button-bg", + vec![ + ("variableSetID", by_asset("products", "10:1")), + ("variableResolvedType", e("COLOR")), + ("variableDataValues", values(vec![(90, rgb(0.0, 0.0, 1.0))])), + ("key", s("button-bg")), + ("version", s("10:1")), + ], + ), + node( + 7, + Some((2, "$")), + "VARIABLE", + "button-bg", + vec![ + ("variableSetID", by_asset("products", "20:1")), + ("variableResolvedType", e("COLOR")), + ("variableDataValues", values(vec![(90, rgb(0.0, 0.0, 1.0)), (92, rgb(0.5, 0.0, 0.5))])), + ("key", s("button-bg")), + ("version", s("20:1")), + ], + ), + // The button names the older copy of the variable. + node( + 10, + Some((1, "!")), + "FRAME", + "Button", + vec![ + ("size", xy(100.0, 40.0)), + ( + "fillPaints", + arr(vec![o(vec![("type", e("SOLID")), ("color", rgb(0.0, 0.0, 1.0)), ("colorVar", asset_alias("button-bg", "10:1"))])]), + ), + ], + ), + ]; + let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert_eq!(doc.variables.collections.len(), 1, "{:?}", doc.variables.collections); + let products = &doc.variables.collections[0]; + assert_eq!(products.modes.iter().map(|m| m.name.as_str()).collect::>(), ["Blue", "Purple"]); + assert_eq!(doc.variables.variables.len(), 1); + let var = &doc.variables.variables[0]; + assert_eq!(var.collection, products.id); + assert_eq!(var.values.len(), 2, "the newest copy's values: {:?}", var.values); + let page = doc.nodes().find(|n| n.name == "Page").unwrap(); + assert_eq!(page.var_modes.get(&products.id).map(String::as_str), Some("1:92")); + let button = named(&doc, "Button"); + assert_eq!(button.bound.values().next(), Some(&var.id), "{:?}", button.bound); + assert!(!warnings.iter().any(|w| w.contains("variable modes of collections")), "{warnings:?}"); +} From f282a30164b642346e265fb412030d0ca66b0f69 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 13:54:49 +0330 Subject: [PATCH 06/16] Delete map.rs --- map.rs | 490 --------------------------------------------------------- 1 file changed, 490 deletions(-) delete mode 100644 map.rs diff --git a/map.rs b/map.rs deleted file mode 100644 index 98239cd7b..000000000 --- a/map.rs +++ /dev/null @@ -1,490 +0,0 @@ -//! Decoded `.fig` message → REST API file JSON, which [`crate::rest`] then imports. -//! -//! 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 import 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 import looks as saved. - -use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::rc::Rc; - -use figcraft_doc::VectorNetwork; -use figcraft_doc::geom::{Affine, Point}; -use figcraft_doc::proto::{MAX_ACTION_DEPTH, MAX_EXPR_DEPTH}; -use serde_json::{Map, Value, json}; - -use super::kiwi::Kv; -use super::network; - -mod component; -mod connector; -mod document; -mod layer; -mod layout; -mod paint; -mod proto; -mod text; -mod variables; - -use component::OverrideScope; -#[cfg(test)] -use paint::paint; - -type Obj = Map; - -/// Deepest nesting followed. -const MAX_DEPTH: usize = 256; -/// Most path commands read from one geometry blob. -const MAX_COMMANDS: usize = 200_000; -const MAX_GRID_TRACKS: usize = 1_000; -pub(super) const MAX_GRID_TRACK_VALUE: f64 = 1e6; -/// 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; - -/// The REST API file JSON for message `msg`, with the warnings about what was skipped and the -/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]). -pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec, Vec>) { - let mut m = Mapper::new(msg, max_nodes); - 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[crate::rest::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 (#981). - 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[crate::rest::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); - } - (out, m.warnings.finish(), m.networks) -} - -#[cfg(test)] -mod image_filter_tests; - -/// REST importer warnings reworded for a `.fig` import (`None`: drop it). -pub(super) fn adapt_warning(w: String) -> Option { - if w.contains("geometry=paths") { - return None; - } - Some(w.replace("the variables JSON", "the file").replace("the file JSON", "the file")) -} - -// ---- 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)) -} - -#[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)); - } -} - -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>>, - /// 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 { layers: vec![n], fitted: None } - } - - fn size(&self) -> Option<(f64, f64)> { - self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(size)) - } - - fn transform(&self) -> Option { - self.fitted.map(|f| f.1).or_else(|| self.get("transform").and_then(matrix)) - } - - fn with(&self, top: impl IntoIterator>) -> Self { - let mut v = self.clone(); - v.layers.extend(top); - v - } - - /// 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); - v.layers.insert(at.min(v.layers.len()), s); - } - v - } - - fn get(&self, k: &str) -> Option<&'a Kv<'a>> { - self.layers.iter().rev().find_map(|l| l.get(k)) - } - - 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: crate::rest::Warnings, - 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 [`super::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 what a network is made from: its blob, its scale and its style table. - network_ids: HashMap<(usize, u64, u64, usize), 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>, max_nodes: usize) -> Self { - 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: crate::rest::Warnings::default(), - 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. - pub(super) fn collection_id(&self, v: &Kv) -> Option { - let id = self.resource_id(v)?; - Some(self.canonical_collection(&id).unwrap_or(id)) - } - - /// 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())?; - let live_set = |n: &Kv| n.get("type").and_then(Kv::str) == Some("VARIABLE_SET") && n.get("isSoftDeleted").and_then(Kv::bool) != Some(true); - self.assets - .get(key)? - .iter() - .filter(|(_, nid)| self.nodes.get(nid.as_str()).is_some_and(|n| live_set(n))) - .max_by_key(|(ver, _)| version_rank(ver)) - .map(|(_, nid)| nid.clone()) - } - - /// 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, -} - -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 { - fn drop(&mut self) { - VAR_CANON.with(|c| c.borrow_mut().take()); - } -} - -impl VarCanon { - fn install(m: &Mapper) -> VarCanonGuard { - let mut newest: HashMap = HashMap::new(); - let mut copies: Vec<(String, String)> = Vec::new(); - for (id, n) in &m.nodes { - if n.get("type").and_then(Kv::str) != Some("VARIABLE") || n.get("isSoftDeleted").and_then(Kv::bool) == Some(true) { - continue; - } - 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(); - VAR_CANON.with(|c| *c.borrow_mut() = Some(t)); - VarCanonGuard - } - - /// The variable a reference (`{guid}` or `{assetRef}`) names, as its newest copy. - 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 { - return Some(t.by_id.get(g).cloned().unwrap_or_else(|| g.clone())); - } - let key = r.get("assetRef")?.get("key").and_then(Kv::str)?; - t.by_key.get(key).cloned() - }) - } - - /// 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)) - } -} From 08537cf425c201be99db433e9d5f2e909de87c8d Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 13:55:16 +0330 Subject: [PATCH 07/16] Delete tests.rs --- tests.rs | 1588 ------------------------------------------------------ 1 file changed, 1588 deletions(-) delete mode 100644 tests.rs diff --git a/tests.rs b/tests.rs deleted file mode 100644 index 0e2c5016e..000000000 --- a/tests.rs +++ /dev/null @@ -1,1588 +0,0 @@ -//! `.fig` import tests on fixtures built in code: a small schema written in a compact text form, -//! a message encoded with our own kiwi encoder, compressed (deflate and zstd), framed and zipped. - -use std::io::Write; - -use figcraft_doc::{NodeKind, VariableType}; -use serde_json::Value; - -use super::kiwi::{Def, Field, Kind, Kv, Schema, Type, encode}; -use super::*; - -/// 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 ConnectorLineStyle ELBOWED STRAIGHT CURVED -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 -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 -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 -message Blob byte[] bytes -message Message NodeChange[] nodeChanges, Blob[] blobs -"; - -pub(super) fn schema() -> Schema { - Schema::parse(SCHEMA).unwrap() -} - -pub(super) fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { - Kv::Object(fields) -} -pub(super) fn f(x: f64) -> Kv<'static> { - Kv::Float(x as f32) -} -pub(super) fn u(x: u64) -> Kv<'static> { - Kv::Uint(x) -} -pub(super) 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) -} -pub(super) fn xy(x: f64, y: f64) -> Kv<'static> { - o(vec![("x", f(x)), ("y", f(y))]) -} -pub(super) 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 -} - -/// A 2×1 PNG. -pub(super) fn png() -> Vec { - let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255])); - let mut out = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); - out -} - -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()))])]))]) -} - -/// `canvas.fig`: magic, version, deflated schema, zstd message. -pub(super) fn canvas(schema: &Schema, msg: &Kv) -> Vec { - let root = schema.find("Message").unwrap(); - let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); - z.write_all(&encode::schema(schema)).unwrap(); - let schema_chunk = z.finish().unwrap(); - let msg_chunk = ruzstd::encoding::compress_to_vec(&encode::message(schema, root, msg).unwrap()[..], ruzstd::encoding::CompressionLevel::Fastest); - let mut out = b"fig-kiwi".to_vec(); - out.extend_from_slice(&101u32.to_le_bytes()); - for c in [schema_chunk, msg_chunk] { - out.extend_from_slice(&(c.len() as u32).to_le_bytes()); - out.extend_from_slice(&c); - } - out -} - -fn zip_file(canvas: &[u8]) -> Vec { - let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); - let image_name = format!("images/{}", HASH.iter().map(|b| format!("{b:02x}")).collect::()); - for (name, bytes) in [("canvas.fig", canvas.to_vec()), ("meta.json", br#"{"file_name":"Fixture"}"#.to_vec()), (image_name.as_str(), png())] { - z.start_file(name, stored).unwrap(); - z.write_all(&bytes).unwrap(); - } - z.finish().unwrap().into_inner() -} - -fn fixture() -> Vec { - zip_file(&canvas(&schema(), &message())) -} - -fn named<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::Node { - doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}")) -} - -/// The shared schema plus the grid sizing definitions only these tests use. -fn grid_schema() -> Schema { - const NODE_FIELDS: &str = ", GridTrackMap gridColumns, GridTrackMap gridRows, GridTrackSizeMap gridColumnsSizing, GridTrackSizeMap gridRowsSizing, GUID gridColumnAnchor, GUID gridRowAnchor, uint gridColumnSpan, uint gridRowSpan, float gridColumnGap, float gridRowGap"; - const TYPES: &str = " -enum GridTrackSizingType FLEX FIXED HUG UNKNOWN -message GridTrackSizeFunction GridTrackSizingType type, float value -message GridTrackSize GridTrackSizeFunction minSizing, GridTrackSizeFunction maxSizing -message GridTrackSizeEntry GUID id, GridTrackSize trackSize -message GridTrackSizeMap GridTrackSizeEntry[] entries -message GridTrackEntry GUID id, string position -message GridTrackMap GridTrackEntry[] entries -message Blob "; - let text = SCHEMA.replace("enum StackMode NONE HORIZONTAL VERTICAL\n", "enum StackMode NONE HORIZONTAL VERTICAL GRID\n").replacen( - "\nmessage Blob ", - &format!("{NODE_FIELDS}{TYPES}"), - 1, - ); - Schema::parse(&text).unwrap() -} - -fn track_size(kind: &'static str, value: f64) -> Kv<'static> { - let sizing = || o(vec![("type", e(kind)), ("value", f(value))]); - o(vec![("minSizing", sizing()), ("maxSizing", sizing())]) -} - -fn ranged_track_size(min: Kv<'static>, max: Kv<'static>) -> Kv<'static> { - o(vec![("minSizing", min), ("maxSizing", max)]) -} - -fn track_map_entry(id: u64, size: Kv<'static>) -> Kv<'static> { - o(vec![("id", g(id)), ("trackSize", size)]) -} - -fn positioned_track(id: u64, position: &'static str) -> Kv<'static> { - o(vec![("id", g(id)), ("position", s(position))]) -} - -fn unpositioned_track(id: u64) -> Kv<'static> { - o(vec![("id", g(id))]) -} - -#[test] -fn grid_tracks_import_by_guid_and_keep_track_order() { - let cols = arr(vec![positioned_track(13, "$"), unpositioned_track(10), positioned_track(12, "#"), unpositioned_track(11)]); - let rows = arr(vec![positioned_track(21, "\""), positioned_track(20, "!")]); - // Sizing entries intentionally differ from track order. Their GUIDs define the join. - let col_sizes = arr(vec![ - track_map_entry(12, track_size("FLEX", 1.0)), - track_map_entry(10, track_size("FIXED", 120.0)), - track_map_entry(13, track_size("FLEX", 2.0)), - track_map_entry(11, track_size("HUG", 80.0)), - ]); - let row_sizes = arr(vec![track_map_entry(21, track_size("HUG", 30.0)), track_map_entry(20, track_size("FIXED", 50.0))]); - let nodes = vec![ - node(0, None, "DOCUMENT", "Document", vec![]), - node(1, Some((0, "!")), "CANVAS", "Page", vec![]), - node( - 2, - Some((1, "!")), - "FRAME", - "Grid", - vec![ - ("size", xy(600.0, 300.0)), - ("transform", at(0.0, 0.0)), - ("stackMode", e("GRID")), - ("gridColumns", o(vec![("entries", cols)])), - ("gridRows", o(vec![("entries", rows)])), - ("gridColumnsSizing", o(vec![("entries", col_sizes)])), - ("gridRowsSizing", o(vec![("entries", row_sizes)])), - ("gridColumnGap", f(10.0)), - ("gridRowGap", f(12.0)), - ], - ), - node( - 3, - Some((2, "!")), - "RECTANGLE", - "Hug child", - vec![ - ("size", xy(80.0, 30.0)), - ("transform", at(0.0, 0.0)), - ("gridColumnAnchor", g(11)), - ("gridRowAnchor", g(21)), - ("gridColumnSpan", u(2)), - ("gridRowSpan", u(1)), - ], - ), - ]; - let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); - let (doc, warnings) = import(&bytes, "Grid tracks").unwrap(); - let grid = named(&doc, "Grid").layout.as_ref().unwrap(); - assert_eq!((grid.mode, grid.grid_columns, grid.grid_rows, grid.gap, grid.cross_gap), (figcraft_doc::LayoutMode::Grid, 4, 2, 10.0, 12.0)); - assert_eq!( - grid.column_tracks, - vec![ - figcraft_doc::GridTrack::fixed(120.0), - figcraft_doc::GridTrack::hug(), - figcraft_doc::GridTrack::fill(1.0), - figcraft_doc::GridTrack::fill(2.0), - ] - ); - assert_eq!(grid.row_tracks, vec![figcraft_doc::GridTrack::fixed(50.0), figcraft_doc::GridTrack::hug()]); - let child = &named(&doc, "Hug child").child_layout; - assert_eq!((child.grid_column, child.grid_row, child.grid_column_span, child.grid_row_span), (Some(1), Some(1), 2, 1)); - assert!(!warnings.iter().any(|w| w.contains("grid track")), "{warnings:?}"); -} - -#[test] -fn malformed_grid_tracks_warn_and_keep_equal_track_fallbacks() { - let tracks = arr(vec![positioned_track(30, "!"), positioned_track(31, "\""), positioned_track(32, "#"), positioned_track(33, "$")]); - let sizing = arr(vec![ - track_map_entry(30, track_size("FIXED", -1.0)), - track_map_entry(31, ranged_track_size(o(vec![("type", e("FLEX")), ("value", f(1.0))]), o(vec![("type", e("FLEX")), ("value", f(2.0))]))), - track_map_entry(33, track_size("UNKNOWN", 5.0)), - track_map_entry(34, track_size("FLEX", 3.0)), - ]); - let nodes = vec![ - node(0, None, "DOCUMENT", "Document", vec![]), - node(1, Some((0, "!")), "CANVAS", "Page", vec![]), - node( - 2, - Some((1, "!")), - "FRAME", - "Grid", - vec![ - ("size", xy(100.0, 100.0)), - ("transform", at(0.0, 0.0)), - ("stackMode", e("GRID")), - ("gridColumns", o(vec![("entries", tracks)])), - ("gridColumnsSizing", o(vec![("entries", sizing)])), - ], - ), - ]; - let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); - let (doc, warnings) = import(&bytes, "Malformed grid tracks").unwrap(); - let layout = named(&doc, "Grid").layout.as_ref().unwrap(); - assert_eq!(layout.column_tracks, vec![figcraft_doc::GridTrack::default(); 4]); - assert!(warnings.iter().any(|w| w.contains("grid track sizing is unsupported or malformed")), "{warnings:?}"); - assert!(warnings.iter().any(|w| w.contains("stale or invalid grid track sizing entries")), "{warnings:?}"); -} - -#[test] -fn oversized_grid_track_lists_warn_and_use_default_tracks() { - let oversized: Vec> = (0..1_001).map(|i| unpositioned_track(100 + i)).collect(); - let sizing = vec![track_map_entry(100, track_size("FIXED", 40.0))]; - let nodes = vec![ - node(0, None, "DOCUMENT", "Document", vec![]), - node(1, Some((0, "!")), "CANVAS", "Page", vec![]), - node( - 2, - Some((1, "!")), - "FRAME", - "Grid", - vec![ - ("size", xy(100.0, 100.0)), - ("transform", at(0.0, 0.0)), - ("stackMode", e("GRID")), - ("gridColumns", o(vec![("entries", arr(oversized))])), - ("gridColumnsSizing", o(vec![("entries", arr(sizing))])), - ], - ), - ]; - let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); - let (doc, warnings) = import(&bytes, "Oversized grid tracks").unwrap(); - let layout = named(&doc, "Grid").layout.as_ref().unwrap(); - assert!(layout.column_tracks.is_empty(), "{:?}", layout.column_tracks.len()); - assert!(warnings.iter().any(|w| w.contains("grid track list exceeds 1000 entries")), "{warnings:?}"); -} - -#[test] -fn kiwi_round_trip() { - let sc = schema(); - let back = Schema::decode(&encode::schema(&sc)).unwrap(); - assert_eq!(back.defs.len(), sc.defs.len()); - for (a, b) in sc.defs.iter().zip(&back.defs) { - assert_eq!((a.name.as_str(), a.kind), (b.name.as_str(), b.kind)); - let fields = |d: &Def| d.fields.iter().map(|f| (f.name.clone(), f.ty, f.array, f.value)).collect::>(); - assert_eq!(fields(a), fields(b)); - } - let msg = message(); - let root = sc.find("Message").unwrap(); - assert_eq!(back.decode_message(root, &encode::message(&sc, root, &msg).unwrap()).unwrap(), msg); -} - -#[test] -fn kiwi_scalars() { - let names = ["f", "i", "u", "i64", "u64", "s", "b"]; - let types = [Type::Float, Type::Int, Type::Uint, Type::Int64, Type::Uint64, Type::Str, Type::Bool]; - let fields = names - .iter() - .zip(types) - .enumerate() - .map(|(i, (n, t))| Field { name: n.to_string(), ty: Some(t), array: false, value: i as u32 + 1 }) - .collect(); - let sc = Schema { defs: vec![Def { name: "M".into(), kind: Kind::Message, fields }] }; - let cases = [ - (0.0f32, 0i32, 0u32, 0i64, 0u64), - (-1.5, -7, 300, i64::MIN, u64::MAX), - (3.25e7, i32::MAX, u32::MAX, i64::MAX, 1 << 60), - (1e-40, i32::MIN, 1, -1, 127), - ]; - for (fv, iv, uv, i64v, u64v) in cases { - // Written by hand: the generic encoder goes through f64, which can't hold every 64-bit value. - let mut w = encode::Writer::default(); - w.var_uint(1); - w.var_float(fv); - w.var_uint(2); - w.var_int(iv); - w.var_uint(3); - w.var_uint(uv); - w.var_uint(4); - w.var_int64(i64v); - w.var_uint(5); - w.var_uint64(u64v); - w.var_uint(6); - w.string("héllo"); - w.var_uint(7); - w.byte(1); - w.var_uint(0); - let got = sc.decode_message(0, &w.0).unwrap(); - // Denormals are stored as zero. - let want_f = if fv.abs() < f32::MIN_POSITIVE { 0.0 } else { fv }; - assert_eq!(got.get("f"), Some(&Kv::Float(want_f))); - assert_eq!(got.get("i"), Some(&Kv::Int(i64::from(iv)))); - assert_eq!(got.get("u"), Some(&Kv::Uint(u64::from(uv)))); - assert_eq!(got.get("i64"), Some(&Kv::Int(i64v))); - assert_eq!(got.get("u64"), Some(&Kv::Uint(u64v))); - assert_eq!(got.get("s"), Some(&s("héllo"))); - assert_eq!(got.get("b"), Some(&Kv::Bool(true))); - } -} - -#[test] -fn imports_the_fixture() { - let bytes = fixture(); - assert!(is_fig(&bytes)); - let (doc, warnings) = import(&bytes, "fallback").unwrap(); - assert_eq!(doc.name, "Fixture"); - assert_fixture_layers(&doc); - assert_fixture_styles(&doc); - assert_fixture_variables(&doc); - assert_fixture_components(&doc); - assert_fixture_prototype(&doc, &warnings); -} - -fn assert_fixture_layers(doc: &Document) { - // The internal page is skipped. - assert_eq!(doc.pages.len(), 1); - let page = doc.node(doc.pages[0]).unwrap(); - assert_eq!(page.name, "Page A"); - // Siblings by position: the frame comes first even though it's listed after the component. - assert_eq!(doc.node(page.children[0]).unwrap().name, "Screen"); - let screen = named(doc, "Screen"); - let names: Vec<&str> = screen.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); - assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy", "Note"]); - // Image fill from the archive. - let photo = named(doc, "Photo"); - let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") }; - assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1))); - assert_eq!((photo.corners.radii[0], photo.stroke.weight), (6.0, 2.0)); - // Text with a bold run over "the". - let NodeKind::Text(t) = &named(doc, "Title").kind else { panic!("not text") }; - assert_eq!(t.characters, "Hi there"); - assert_eq!(t.style.font_size, 16.0); - assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)"); - assert_eq!(t.runs.len(), 1); - assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold")); - // The group's children keep their place on the page (group at 100, 100). - assert!(matches!(named(doc, "Group").kind, NodeKind::Group)); - let tri = named(doc, "Tri"); - assert!(matches!(&tri.kind, NodeKind::Vector { network } if !network.regions.is_empty())); - let abs = doc.world_transform(tri.id).translation(); - assert!((abs.x - 105.0).abs() < 1e-6 && (abs.y - 106.0).abs() < 1e-6, "{abs:?}"); - assert!(matches!(named(doc, "Star").kind, NodeKind::Star { count: 6, .. })); -} - -fn assert_fixture_styles(doc: &Document) { - let styled = named(doc, "Styled"); - assert_eq!(styled.styles.fill.as_deref(), Some("1:3")); - assert!(doc.styles.iter().any(|s| s.name == "Brand")); - let figcraft_doc::color::PaintKind::Solid { color } = styled.fills[0].kind else { panic!("not solid") }; - assert_eq!((color.r, color.g, color.b), (0.0, 0.0, 1.0)); -} - -fn assert_fixture_variables(doc: &Document) { - let screen = named(doc, "Screen"); - assert_eq!(doc.variables.collections.len(), 1); - assert_eq!(doc.variables.variables[0].kind, VariableType::Color); - assert_eq!(named(doc, "Card").bound.get("fill").map(String::as_str), Some("1:5")); - assert_eq!(screen.var_modes.get("1:4").map(String::as_str), Some("1:91")); -} - -fn assert_fixture_components(doc: &Document) { - let button = named(doc, "Button"); - assert!(matches!(&button.kind, NodeKind::Component { props, .. } if props.contains_key("Show icon"))); - let inst = named(doc, "Button copy"); - assert!( - matches!(&inst.kind, NodeKind::Instance { component, props } if *component == button.id && props.get("Show icon") == Some(&Value::Bool(false))) - ); - let icon = doc.node(inst.children[0]).unwrap(); - assert_eq!(icon.name, "Renamed icon"); - assert!(!icon.visible); -} - -fn assert_fixture_prototype(doc: &Document, warnings: &[String]) { - let button = named(doc, "Button"); - let screen = named(doc, "Screen"); - let it = &named(doc, "Photo").interactions[0]; - assert!(matches!(it.actions[0], figcraft_doc::proto::Action::Navigate { dest, .. } if dest == button.id)); - assert_eq!(it.duration_ms, 250); - let Some(NodeKind::Page { flows, .. }) = doc.node(doc.pages[0]).map(|p| &p.kind) else { panic!("no page") }; - assert_eq!((flows[0].name.as_str(), flows[0].start), ("Main flow", screen.id)); - // The sticky note comes in as a frame, with a warning. - assert!(matches!(named(doc, "Note").kind, NodeKind::Frame)); - assert!(warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{warnings:?}"); -} - -#[test] -fn bare_canvas_and_zlib_chunks_open() { - let sc = schema(); - let c = canvas(&sc, &message()); - let (doc, w) = import(&c, "Bare").unwrap(); - assert_eq!(doc.name, "Bare"); - // Without the archive the image is a placeholder, reported. - assert!(w.iter().any(|w| w.contains("isn't in the file")), "{w:?}"); - // A zlib-wrapped schema chunk reads too. - let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); - z.write_all(&encode::schema(&sc)).unwrap(); - let zl = z.finish().unwrap(); - let parts = chunks(&c).unwrap(); - let mut out = c[..12].to_vec(); - for ch in [zl.as_slice(), parts[1]] { - out.extend_from_slice(&(ch.len() as u32).to_le_bytes()); - out.extend_from_slice(ch); - } - assert!(import(&out, "Z").is_ok()); -} - -/// The fixture message with the photo's image bytes in its own blobs, as copies written by plugins -/// and tools carry them: the PNG goes at the end of `blobs` and `image.dataBlob` points `shift` -/// places past it (0 is the PNG itself). -fn message_with_image_in_blobs(shift: u64) -> Kv<'static> { - fn point_images_at(value: &mut Kv<'static>, blob: u64) { - match value { - Kv::Object(fields) => { - for (name, field) in fields.iter_mut() { - match field { - Kv::Object(image) if *name == "image" => image.push(("dataBlob", u(blob))), - _ => point_images_at(field, blob), - } - } - } - Kv::Array(items) => items.iter_mut().for_each(|item| point_images_at(item, blob)), - _ => {} - } - } - let mut msg = message(); - let index = { - let Kv::Object(fields) = &mut msg else { panic!("the message is an object") }; - let Some((_, Kv::Array(blobs))) = fields.iter_mut().find(|(name, _)| *name == "blobs") else { panic!("no blobs") }; - blobs.push(o(vec![("bytes", Kv::Bytes(png()))])); - blobs.len() as u64 - 1 - }; - point_images_at(&mut msg, index + shift); - msg -} - -#[test] -fn a_bare_canvas_reads_images_carried_in_its_blobs() { - // The clipboard holds a bare canvas (no `images/`); the image comes from `image.dataBlob`. - let c = canvas(&schema(), &message_with_image_in_blobs(0)); - let (doc, warnings) = import(&c, "Pasted").unwrap(); - let photo = named(&doc, "Photo"); - let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") }; - assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1))); - assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); -} - -#[test] -fn a_data_blob_past_the_blobs_stays_a_placeholder() { - let c = canvas(&schema(), &message_with_image_in_blobs(5)); - let (_, warnings) = import(&c, "Pasted").unwrap(); - assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); -} - -/// A file can point any number of image hashes at one large blob; each used to get its own copy -/// of it (#1059). -#[test] -fn hashes_naming_one_blob_share_one_copy_of_it() { - let image = |hash: u8, blob: u64| o(vec![("image", o(vec![("hash", Kv::Bytes(vec![hash; 20])), ("dataBlob", u(blob))]))]); - let msg = o(vec![ - ("blobs", Kv::Array(vec![o(vec![("bytes", Kv::Bytes(png()))]), o(vec![("bytes", Kv::Bytes(vec![7; 16]))])])), - ("nodeChanges", Kv::Array((1..=50).map(|h| image(h, 0)).chain([image(99, 1), image(98, 2)]).collect())), - ]); - let copies = embedded_copies(&msg); - assert_eq!(copies.len(), 51, "the hash naming no blob is left out"); - let first = &copies[&"01".repeat(20)]; - assert_eq!(**first, *png()); - assert_eq!(copies.values().filter(|c| Arc::ptr_eq(c, first)).count(), 50); - assert_eq!(*copies[&"63".repeat(20)], [7; 16]); -} - -mod image_budget { - use super::*; - - fn image(hash: u8) -> Vec { - let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([hash, 0, 0, 255])); - let mut out = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); - out - } - - fn file(references: &[u8], members: &[(u8, Vec)]) -> Vec { - let mut nodes = vec![node(0, None, "DOCUMENT", "Document", vec![]), node(1, Some((0, "!")), "CANVAS", "Page", vec![])]; - for (i, hash) in references.iter().enumerate() { - nodes.push(node( - i as u64 + 2, - Some((1, &format!("{i:04}"))), - "RECTANGLE", - &format!("Image {i}"), - vec![ - ("size", xy(10.0, 10.0)), - ("fillPaints", arr(vec![o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(vec![*hash; 20]))]))])])), - ], - )); - } - let canvas = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])); - let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); - z.start_file("canvas.fig", deflated).unwrap(); - z.write_all(&canvas).unwrap(); - for (hash, bytes) in members { - z.start_file(format!("images/{}", format!("{hash:02x}").repeat(20)), deflated).unwrap(); - z.write_all(bytes).unwrap(); - } - z.finish().unwrap().into_inner() - } - - fn bytes(doc: &Document, index: usize) -> &[u8] { - let node = named(doc, &format!("Image {index}")); - let figcraft_doc::color::PaintKind::Image(fill) = &node.fills[0].kind else { panic!("not an image fill") }; - &doc.images[&fill.image].bytes - } - - fn budget_warnings(warnings: &[String]) -> Vec<&str> { - warnings.iter().filter(|w| w.contains("total ZIP image byte budget")).map(String::as_str).collect() - } - - fn change_member_field(zip: &mut [u8], hash: u8, offset: usize, value: u32) { - let start = { - let mut z = zip::ZipArchive::new(std::io::Cursor::new(&*zip)).unwrap(); - z.by_name(&format!("images/{}", format!("{hash:02x}").repeat(20))).unwrap().central_header_start() as usize - }; - zip[start + offset..start + offset + 4].copy_from_slice(&value.to_le_bytes()); - } - - #[test] - fn exact_total_limit_keeps_all_images_and_each_import_gets_its_own_budget() { - let members = [(1, image(1)), (2, image(2))]; - let limit = members.iter().map(|(_, b)| b.len() as u64).sum(); - let zip = file(&[1, 2], &members); - for _ in 0..2 { - let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap(); - assert_eq!(bytes(&doc, 0), members[0].1); - assert_eq!(bytes(&doc, 1), members[1].1); - assert!(warnings.is_empty(), "{warnings:?}"); - } - } - - #[test] - fn individually_acceptable_images_overflow_the_total_and_leave_placeholders() { - let members = [(1, image(1)), (2, image(2))]; - let limit = members.iter().map(|(_, b)| b.len() as u64).sum::() - 1; - assert!(members.iter().all(|(_, b)| b.len() as u64 <= limit)); - let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap(); - assert_eq!(bytes(&doc, 0), members[0].1); - assert_ne!(bytes(&doc, 1), members[1].1); - assert!(!doc.images.contains_key(&crate::image_hash(&members[1].1))); - let notes = budget_warnings(&warnings); - assert_eq!(notes.len(), 1, "{warnings:?}"); - assert!(notes[0].contains("1 ZIP image") && notes[0].contains(&limit.to_string()), "{warnings:?}"); - } - - #[test] - fn repeated_references_do_not_charge_or_count_a_rejection_twice() { - let members = [(1, image(1)), (2, image(2))]; - let (doc, warnings) = import_with_image_limit(&file(&[1, 1, 2, 2, 1], &members), "Images", members[0].1.len() as u64).unwrap(); - for i in [0, 1, 4] { - assert_eq!(bytes(&doc, i), members[0].1); - } - assert_eq!(bytes(&doc, 2), bytes(&doc, 3)); - assert_ne!(bytes(&doc, 2), members[1].1); - let notes = budget_warnings(&warnings); - assert_eq!(notes.len(), 1, "{warnings:?}"); - assert!(notes[0].contains("1 ZIP image"), "{warnings:?}"); - } - - #[test] - fn distinct_members_with_identical_contents_still_use_the_read_budget() { - let png = image(1); - let members = [(1, png.clone()), (2, png.clone())]; - let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap(); - assert_eq!(bytes(&doc, 0), png); - assert_ne!(bytes(&doc, 1), png); - assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); - } - - #[test] - fn rejecting_a_member_before_reading_leaves_room_for_a_smaller_one() { - let png = image(1); - let members = [(1, vec![0; png.len() + 1]), (2, png.clone())]; - let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap(); - assert_ne!(bytes(&doc, 0), members[0].1); - assert_eq!(bytes(&doc, 1), png); - assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); - } - - #[test] - fn failed_crc_reads_still_consume_the_bytes_read() { - let members = [(1, image(1)), (2, image(2))]; - let mut zip = file(&[1, 2], &members); - change_member_field(&mut zip, 1, 16, 0); - let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64 + 1).unwrap(); - assert_ne!(bytes(&doc, 0), members[0].1); - assert_ne!(bytes(&doc, 1), members[1].1); - assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); - } - - #[test] - fn a_truncated_deflate_read_keeps_its_partial_charge() { - let members = [(1, image(1)), (2, image(2))]; - let mut zip = file(&[1, 2], &members); - // Shorten the first member's compressed size, cutting its deflate stream mid-image. - change_member_field(&mut zip, 1, 20, 15); - let limit = members[0].1.len() as u64; - let archive = Archive::open(&zip).unwrap(); - archive.image_budget.remaining.set(limit); - assert!(archive.image(&"01".repeat(20)).is_none()); - let remaining = archive.image_budget.remaining.get(); - assert!(remaining > 0 && remaining < limit, "partial read left {remaining} of {limit} bytes"); - assert!(archive.image(&"02".repeat(20)).is_none()); - assert_eq!(archive.image_budget.remaining.get(), remaining); - let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap(); - assert_ne!(bytes(&doc, 1), members[1].1); - assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); - } - - #[test] - fn the_individual_image_cap_is_preserved_without_reading_a_large_member() { - let members = [(1, image(1)), (2, image(2))]; - let mut zip = file(&[1, 2], &members); - change_member_field(&mut zip, 1, 24, (MAX_IMAGE + 1) as u32); - let (doc, warnings) = import_with_image_limit(&zip, "Images", members[1].1.len() as u64).unwrap(); - assert_ne!(bytes(&doc, 0), members[0].1); - assert_eq!(bytes(&doc, 1), members[1].1); - assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); - } - - #[test] - fn crc_is_checked_even_when_the_read_ends_exactly_at_the_budget() { - let members = [(1, image(1))]; - let mut zip = file(&[1], &members); - change_member_field(&mut zip, 1, 16, 0); - let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64).unwrap(); - assert_ne!(bytes(&doc, 0), members[0].1); - assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); - assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); - } - - #[test] - fn an_understated_size_cannot_read_past_the_remaining_budget() { - let members = [(1, image(1)), (2, image(2))]; - let mut zip = file(&[1, 2], &members); - change_member_field(&mut zip, 1, 24, 1); - let archive = Archive::open(&zip).unwrap(); - archive.image_budget.remaining.set(members[0].1.len() as u64 - 1); - assert!(archive.image(&"01".repeat(20)).is_none()); - assert_eq!(archive.image_budget.remaining.get(), 0); - assert_eq!(archive.image_budget.skipped.get(), 1); - assert!(archive.image(&"02".repeat(20)).is_none()); - assert_eq!(archive.image_budget.remaining.get(), 0); - assert_eq!(archive.image_budget.skipped.get(), 2); - } - - #[test] - fn invalid_image_contents_are_charged_before_image_validation() { - let members = [(1, vec![0; 8]), (2, image(2))]; - let limit = members[1].1.len() as u64 + 7; - let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap(); - assert_ne!(bytes(&doc, 1), members[1].1); - assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); - } - - #[test] - fn missing_and_unreferenced_members_do_not_consume_the_budget() { - let members = [(1, image(1)), (2, vec![0; 128])]; - let (doc, warnings) = import_with_image_limit(&file(&[9, 1], &members), "Images", members[0].1.len() as u64).unwrap(); - assert_eq!(bytes(&doc, 1), members[0].1); - assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); - assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); - } - - #[test] - fn embedded_image_fallback_is_independent_of_the_zip_budget() { - let canvas = canvas(&schema(), &message_with_image_in_blobs(0)); - for bytes in [&canvas, &zip_file(&canvas)] { - let (doc, warnings) = import_with_image_limit(bytes, "Images", 0).unwrap(); - let photo = named(&doc, "Photo"); - let figcraft_doc::color::PaintKind::Image(fill) = &photo.fills[0].kind else { panic!("not an image fill") }; - assert_eq!(*doc.images[&fill.image].bytes, png()); - assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); - assert_eq!(budget_warnings(&warnings).len(), usize::from(bytes.starts_with(b"PK")), "{warnings:?}"); - } - } - - #[test] - fn default_import_preserves_image_bytes_dimensions_and_existing_warnings() { - let members = [(1, image(1)), (2, image(2))]; - let (doc, warnings) = import(&file(&[1, 2], &members), "Images").unwrap(); - assert_eq!(bytes(&doc, 0), members[0].1); - assert_eq!(bytes(&doc, 1), members[1].1); - assert!(doc.images.values().all(|img| (img.width, img.height) == (2, 1))); - assert!(warnings.is_empty(), "{warnings:?}"); - let (doc, warnings) = import(&fixture(), "Fixture").unwrap(); - assert!(doc.images.values().any(|img| *img.bytes == png() && (img.width, img.height) == (2, 1))); - assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); - } -} - -#[test] -fn other_files_are_not_fig() { - assert!(try_import(b"{\"document\": {}}", "x").is_none()); - assert!(try_import(b"", "x").is_none()); - let native = crate::save_bytes(&figcraft_doc::Document::new("N"), None).unwrap(); - assert!(try_import(&native, "x").is_none()); -} - -/// A cheap deterministic generator (mangled inputs, generated fixtures). -pub(super) struct Lcg(pub(super) u64); -impl Lcg { - pub(super) fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - self.0 >> 33 - } -} - -#[test] -fn mangled_files_never_panic() { - let bytes = fixture(); - let c = canvas(&schema(), &message()); - let mut r = Lcg(7); - for src in [&bytes, &c] { - for cut in (0..src.len()).step_by(37) { - let _ = import(&src[..cut], "x"); - } - for _ in 0..300 { - let mut m = src.clone(); - for _ in 0..1 + r.next() % 8 { - let i = (r.next() as usize) % m.len(); - m[i] ^= 1 << (r.next() % 8); - } - let _ = import(&m, "x"); - } - } -} - -#[test] -fn mangled_kiwi_never_panics() { - let sc = schema(); - let root = sc.find("Message").unwrap(); - let schema_bytes = encode::schema(&sc); - let msg = encode::message(&sc, root, &message()).unwrap(); - let mut r = Lcg(11); - for cut in 0..schema_bytes.len() { - let _ = Schema::decode(&schema_bytes[..cut]); - } - for cut in (0..msg.len()).step_by(3) { - if let Ok(kv) = sc.decode_message(root, &msg[..cut]) { - let _ = map::to_rest(&kv, "x", crate::rest::MAX_NODES); - } - } - for _ in 0..2000 { - let mut m = msg.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 (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES); - let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES); - } - let mut sbytes = schema_bytes.clone(); - let i = (r.next() as usize) % sbytes.len(); - sbytes[i] = r.next() as u8; - if let Ok(s2) = Schema::decode(&sbytes) - && let Some(rt) = s2.find("Message") - { - let _ = s2.decode_message(rt, &msg); - } - } - // Pure junk, bare and framed. - for _ in 0..500 { - let junk: Vec = (0..(r.next() % 200)).map(|_| r.next() as u8).collect(); - let _ = Schema::decode(&junk); - let _ = sc.decode_message(root, &junk); - let mut framed = b"fig-kiwi\0\0\0\0".to_vec(); - framed.extend_from_slice(&junk); - let _ = import(&framed, "x"); - } -} - -#[test] -fn large_files_get_a_larger_value_budget() { - // Small files keep the fixed floor: a few bytes can't expand into a huge tree. - assert_eq!(kiwi::value_budget(0), 20_000_000); - assert_eq!(kiwi::value_budget(1 << 20), 20_000_000); - // A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds - // 30.5 million values was refused as "too many values". - assert!(kiwi::value_budget(83_491_232) > 30_514_899); - // A real 194 MB design-system file (UX4G): a 39.5 MB canvas holding 29.7 million values. - assert!(kiwi::value_budget(39_528_420) > 29_703_730); - // Still bounded by the largest canvas read. - assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); - // Counting every scalar field, a dense file may reach about 2.4 values per compressed byte (the - // 15 MB text-heavy fixture in `value_budget_tests` measures 1.8): below the ceiling the budget must - // cover that, the floor still holds up to 5 MB of canvas, and 32 MiB is enough for the ceiling. - assert!(kiwi::value_budget(8 << 20) >= (8 << 20) * 12 / 5); - #[cfg(not(target_pointer_width = "32"))] - assert!(kiwi::value_budget(30_000_000) >= 30_000_000 * 12 / 5); - assert_eq!(kiwi::value_budget(5_000_000), 20_000_000); - assert_eq!(kiwi::value_budget(32 << 20), kiwi::value_budget(MAX_CANVAS as usize)); -} - -#[test] -fn the_layer_cap_follows_the_file_size() { - // A small (or broken) file keeps the REST import's cap: instances expand into a layer per - // layer of their component, and a few bytes mustn't claim millions of layers. - assert_eq!(layer_budget(0), crate::rest::MAX_NODES); - assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES); - // A real 894 004-layer file (an 83.5 MB canvas) stopped at 200 000 and came in with 27 of - // its 30 pages empty. - assert!(layer_budget(83_491_232) > 894_004); - // No file gets more than the ceiling. - assert_eq!(layer_budget(MAX_CANVAS as usize), MAX_FIG_NODES); -} - -#[test] -fn a_small_file_that_unpacks_large_gets_no_larger_budgets() { - // 40 MB of zeros in a blob pack into a few KB. Keyed to the unpacked message, they lifted the - // layer cap above the 231 231 layers its instances expand into; keyed to the file, the cap - // stays at the REST import's. - let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; - nodes.push(node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(10.0, 10.0))])); - nodes.extend((10..1010).map(|i| node(i, Some((5, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); - nodes.extend((2000..2230).map(|i| node(i, Some((1, "!")), "INSTANCE", "I", vec![("symbolData", o(vec![("symbolID", g(5))]))]))); - let blob = o(vec![("bytes", Kv::Bytes(vec![0; 40 << 20]))]); - let file = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![blob]))])); - assert!(file.len() < 1 << 20, "{} bytes", file.len()); - let (doc, warnings) = import(&file, "x").unwrap(); - assert!(warnings.iter().any(|w| w.contains("more than 200000 layers")), "{warnings:?}"); - assert!(doc.node_count() <= crate::rest::MAX_NODES + 10); -} - -#[test] -fn the_mapper_stops_at_the_layer_cap_it_is_given() { - 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 (_, warnings, _) = map::to_rest(&msg, "x", 5); - assert!(warnings.iter().any(|w| w.contains("more than 5 layers")), "{warnings:?}"); - let (_, warnings, _) = map::to_rest(&msg, "x", crate::rest::MAX_NODES); - assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); -} - -#[test] -fn scalar_fields_count_against_the_value_budget() { - // A message of 1000 scalar fields (id 1, `true`) is 1001 values: a budget of 10 refuses it. - let fields = vec![Field { name: "b".into(), ty: Some(Type::Bool), array: false, value: 1 }]; - let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] }; - let mut data = [1u8, 1].repeat(1000); - data.push(0); - let e = sc.decode_message_within(0, &data, 10).unwrap_err(); - assert_eq!(e.0, "message: too many values"); - assert!(sc.decode_message_within(0, &data, 1001).is_ok()); -} - -#[test] -fn hostile_input_is_refused() { - // A message that contains itself, nested past the depth cap. - let field = |ty, array| vec![Field { name: "m".into(), ty: Some(ty), array, value: 1 }]; - let nested = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields: field(Type::Def(0), false) }] }; - assert!(nested.decode_message(0, &[1u8; 10_000]).is_err()); - // An array count larger than the data. - let array = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields: field(Type::Uint, true) }] }; - assert!(array.decode_message(0, &[1, 0xff, 0xff, 0xff, 0xff, 0x0f]).is_err()); - // A field type naming a missing definition. - let mut w = encode::Writer::default(); - w.var_uint(1); - w.string("Message"); - w.byte(2); - w.var_uint(1); - w.string("x"); - w.var_int(5); - w.byte(0); - w.var_uint(1); - assert!(Schema::decode(&w.0).is_err()); - // A schema without a Message definition. - let c = canvas(&Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields: vec![] }] }, &Kv::Object(vec![])); - let renamed: Vec = { - let sc = Schema { defs: vec![Def { name: "Other".into(), kind: Kind::Message, fields: vec![] }] }; - let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); - z.write_all(&encode::schema(&sc)).unwrap(); - let sch = z.finish().unwrap(); - let mut out = c[..12].to_vec(); - out.extend_from_slice(&(sch.len() as u32).to_le_bytes()); - out.extend_from_slice(&sch); - out.extend_from_slice(&c[12 + 4 + chunks(&c).unwrap()[0].len()..]); - out - }; - assert!(import(&c, "x").is_ok()); - assert!(import(&renamed, "x").is_err()); -} - -#[test] -fn instances_inside_their_own_main_stop() { - let sym = node(5, Some((1, "!")), "SYMBOL", "Loop", vec![("size", xy(10.0, 10.0))]); - let inner = node(6, Some((5, "!")), "INSTANCE", "Self", vec![("symbolData", o(vec![("symbolID", g(5))]))]); - let doc_nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![]), sym, inner]; - let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(doc_nodes))])), "x").unwrap(); - assert!(doc.nodes().any(|n| n.name == "Self")); -} - -/// A collection from a library is named by its asset key, not a guid — also in the explicit mode -/// map of the frames, components and instances set it on, and in the variables' collection link. -#[test] -fn modes_of_collections_named_by_asset_import() { - let modes = arr(vec![ - o(vec![("id", g(90)), ("name", s("Dark")), ("sortPosition", s("!"))]), - o(vec![("id", g(91)), ("name", s("Light")), ("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(0.0, 0.0, 0.0)))]), - o(vec![("modeID", g(91)), ("variableData", color_value(rgb(1.0, 1.0, 1.0)))]), - ]), - )]); - let by_asset = |key: &'static str, version: Option<&'static str>| { - let mut r = vec![("key", s(key))]; - r.extend(version.map(|v| ("version", s(v)))); - o(vec![("assetRef", o(r))]) - }; - let mode_map = |set: Kv<'static>| o(vec![("entries", arr(vec![o(vec![("variableSetID", set), ("variableModeID", g(91))])]))]); - 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))]), - // The collection itself says the asset key its references name. - node(4, Some((2, "!")), "VARIABLE_SET", "Schema", vec![("variableSetModes", modes), ("key", s("schema-collection")), ("version", s("7"))]), - node( - 5, - Some((2, "\"")), - "VARIABLE", - "surface", - vec![("variableSetID", by_asset("schema-collection", Some("7"))), ("variableResolvedType", e("COLOR")), ("variableDataValues", values)], - ), - node( - 10, - Some((1, "!")), - "FRAME", - "Light section", - vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7"))))], - ), - node( - 20, - Some((1, "\"")), - "SYMBOL", - "Button", - vec![("size", xy(100.0, 40.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", None)))], - ), - node( - 21, - Some((1, "#")), - "INSTANCE", - "Button copy", - vec![ - ("size", xy(100.0, 40.0)), - ("symbolData", o(vec![("symbolID", g(20))])), - ("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7")))), - ], - ), - // One names a collection that isn't in the file: its mode is skipped, with a warning. - node(11, Some((1, "$")), "FRAME", "Gone", vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("gone", None)))]), - ]; - let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); - assert_eq!(doc.variables.collections.len(), 1); - assert_eq!(doc.variables.variables.len(), 1); - assert_eq!(doc.variables.variables[0].collection, "1:4"); - for name in ["Light section", "Button", "Button copy"] { - let n = named(&doc, name); - assert_eq!(n.var_modes.get("1:4").map(String::as_str), Some("1:91"), "{name}"); - } - assert!(named(&doc, "Gone").var_modes.is_empty()); - assert!(warnings.iter().any(|w| w.contains("variable modes of collections that aren't in the file")), "{warnings:?}"); -} - -#[test] -fn resized_instances_lay_their_layers_out_by_their_constraints() { - let pin = |h: &'static str, v: &'static str| vec![("horizontalConstraint", e(h)), ("verticalConstraint", e(v))]; - let layer = |id, pos, ty, name, w, h, x, y, c: Vec<(&'static str, Kv<'static>)>| { - node(id, Some((5, pos)), ty, name, [vec![("size", xy(w, h)), ("transform", at(x, y))], c].concat()) - }; - let nodes = vec![ - node(0, None, "DOCUMENT", "D", vec![]), - node(1, Some((0, "!")), "CANVAS", "P", vec![]), - node(5, Some((1, "!")), "SYMBOL", "Logo", vec![("size", xy(132.0, 22.0)), ("transform", at(0.0, 0.0))]), - layer(6, "!", "ROUNDED_RECTANGLE", "Mark", 132.0, 22.0, 0.0, 0.0, pin("SCALE", "SCALE")), - layer(7, "\"", "ROUNDED_RECTANGLE", "Badge", 32.0, 10.0, 100.0, 12.0, pin("MAX", "MAX")), - layer(8, "#", "ROUNDED_RECTANGLE", "Bar", 112.0, 2.0, 10.0, 0.0, pin("STRETCH", "MIN")), - // A group scales its layers with it. - layer(11, "$", "FRAME", "Wordmark", 66.0, 22.0, 66.0, 0.0, [pin("SCALE", "SCALE"), vec![("resizeToFit", Kv::Bool(true))]].concat()), - node(12, Some((11, "!")), "ROUNDED_RECTANGLE", "Letter", vec![("size", xy(66.0, 22.0)), ("transform", at(0.0, 0.0))]), - node( - 9, - Some((1, "\"")), - "INSTANCE", - "Small logo", - vec![("size", xy(90.0, 15.0)), ("transform", at(0.0, 100.0)), ("symbolData", o(vec![("symbolID", g(5))]))], - ), - // Derived layout the file gives wins over the constraints. - node( - 10, - Some((1, "#")), - "INSTANCE", - "Derived logo", - vec![ - ("size", xy(90.0, 15.0)), - ("transform", at(0.0, 200.0)), - ("symbolData", o(vec![("symbolID", g(5))])), - ("derivedSymbolData", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("size", xy(50.0, 5.0))])])), - ], - ), - // A size the instance overrides: the place still follows the constraints. - node( - 13, - Some((1, "$")), - "INSTANCE", - "Resized badge", - vec![ - ("size", xy(90.0, 15.0)), - ("transform", at(0.0, 300.0)), - ( - "symbolData", - o(vec![ - ("symbolID", g(5)), - ("symbolOverrides", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(7)]))])), ("size", xy(20.0, 10.0))])])), - ]), - ), - ], - ), - ]; - let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); - let box_of = |inst: &str, name: &str| { - let i = named(&doc, inst); - let n = i.children.iter().map(|c| doc.node(*c).unwrap()).find(|n| n.name == name).unwrap(); - let t = n.transform.translation(); - [t.x, t.y, n.width, n.height].map(|x| (x * 1e3).round() / 1e3) - }; - assert_eq!(box_of("Small logo", "Mark"), [0.0, 0.0, 90.0, 15.0]); - assert_eq!(box_of("Small logo", "Badge"), [58.0, 5.0, 32.0, 10.0]); - assert_eq!(box_of("Small logo", "Bar"), [10.0, 0.0, 70.0, 2.0]); - // The main's own, then the instances'. - let mut letters: Vec<[f64; 2]> = - doc.nodes().filter(|n| n.name == "Letter").map(|n| [n.width, n.height].map(|x| (x * 1e3).round() / 1e3)).collect(); - letters.sort_by(|a, b| a[0].total_cmp(&b[0])); - assert_eq!(letters, [[45.0, 15.0], [45.0, 15.0], [45.0, 15.0], [66.0, 22.0]]); - assert_eq!(box_of("Derived logo", "Mark")[2..], [50.0, 5.0]); - assert_eq!(box_of("Resized badge", "Badge"), [58.0, 5.0, 20.0, 10.0]); -} - -#[test] -fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { - let stretch = || vec![("horizontalConstraint", e("STRETCH")), ("verticalConstraint", e("MIN"))]; - let nodes = vec![ - node(0, None, "DOCUMENT", "D", vec![]), - node(1, Some((0, "!")), "CANVAS", "P", vec![]), - node(20, Some((1, "!")), "SYMBOL", "Track", vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))]), - node(21, Some((20, "!")), "ROUNDED_RECTANGLE", "Fill", [vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))], stretch()].concat()), - node(30, Some((1, "\"")), "SYMBOL", "Slider", vec![("size", xy(200.0, 50.0)), ("transform", at(0.0, 100.0))]), - // The nested instance is 150 wide in its main, with the layout derived for that. - node( - 31, - Some((30, "!")), - "INSTANCE", - "Inner", - [ - vec![ - ("size", xy(150.0, 10.0)), - ("transform", at(0.0, 0.0)), - ("symbolData", o(vec![("symbolID", g(20))])), - ( - "derivedSymbolData", - arr(vec![o(vec![ - ("guidPath", o(vec![("guids", arr(vec![g(21)]))])), - ("size", xy(150.0, 10.0)), - ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])), - ])]), - ), - ], - stretch(), - ] - .concat(), - ), - node( - 40, - Some((1, "#")), - "INSTANCE", - "Wide slider", - vec![("size", xy(300.0, 50.0)), ("transform", at(0.0, 200.0)), ("symbolData", o(vec![("symbolID", g(30))]))], - ), - ]; - let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); - let width = |outer: &str| { - let inner = doc.node(named(&doc, outer).children[0]).unwrap(); - (inner.width, doc.node(inner.children[0]).unwrap().width) - }; - // In its main the derived layout holds; stretched to 250 it no longer does. - assert_eq!(width("Slider"), (150.0, 150.0)); - assert_eq!(width("Wide slider"), (250.0, 250.0)); - // What else the derived data gives (the colour a variable resolves to, say) still holds. - let fill = doc.node(doc.node(named(&doc, "Wide slider").children[0]).unwrap().children[0]).unwrap(); - assert!(matches!(fill.fills.first().map(|p| &p.kind), Some(figcraft_doc::color::PaintKind::Solid { color }) if color.r == 1.0 && color.g == 0.0)); -} - -#[path = "tail_tests.rs"] -mod tail; - -#[path = "image_filter_tests.rs"] -mod image_filters; - -#[path = "text_list_tests.rs"] -mod text_list; - -/// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset). -pub(super) fn samples() -> Vec { - let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() }; - let dir = std::fs::read_dir(dir).into_iter().flatten().flatten(); - dir.map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "fig")).collect() -} - -/// Local only (`FIGCRAFT_FIG_SAMPLES`): every file imports without warnings. -#[test] -fn local_samples_import() { - for p in samples() { - let bytes = std::fs::read(&p).unwrap(); - let (doc, warnings) = import(&bytes, "sample").unwrap_or_else(|e| panic!("{}: {e}", p.display())); - assert!(warnings.is_empty(), "{}: {warnings:?}", p.display()); - assert!(doc.node_count() > 5, "{}", p.display()); - } -} - -/// Local only: dumps each sample's schema, decoded message and REST JSON when `FIG_DUMP` names a -/// folder (for working on the importer). -#[test] -fn dump_samples() { - let Ok(out) = std::env::var("FIG_DUMP") else { return }; - for p in samples() { - let bytes = std::fs::read(&p).unwrap(); - let a = Archive::open(&bytes).unwrap(); - let (schema, message) = decode_canvas(&a.canvas).unwrap(); - let stem = p.file_stem().unwrap().to_string_lossy().to_string(); - let mut txt = String::new(); - for d in &schema.defs { - txt += &format!("{:?} {}\n", d.kind, d.name); - for f in &d.fields { - let ty = match f.ty { - Some(Type::Def(i)) => schema.defs[i].name.clone(), - Some(t) => format!("{t:?}"), - None => String::new(), - }; - txt += &format!(" {} {}{} = {}\n", f.name, ty, if f.array { "[]" } else { "" }, f.value); - } - } - std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap(); - let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); - std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap(); - let (rest, ..) = map::to_rest(&msg, &stem, super::layer_budget(message.len())); - std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap(); - } -} - -#[path = "board_import_tests.rs"] -mod board; - -/// A file that took several updates of a library holds a copy of its collection and of each of -/// its variables per version it met (same asset key, another version). Layers and modes name any -/// of them by asset, never by guid: they all become the newest copy, which has every mode — a page -/// set to a mode only the newer copies have keeps it, and fills bound to an older copy of a -/// variable follow its newest values. -#[test] -fn library_copies_of_several_versions_become_the_newest() { - let mode = |id, name: &'static str, pos: &'static str| o(vec![("id", g(id)), ("name", s(name)), ("sortPosition", s(pos))]); - let by_asset = |key: &'static str, version: &'static str| o(vec![("assetRef", o(vec![("key", s(key)), ("version", s(version))]))]); - let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]); - let values = |pairs: Vec<(u64, Kv<'static>)>| { - o(vec![("entries", arr(pairs.into_iter().map(|(m, c)| o(vec![("modeID", g(m)), ("variableData", color_value(c))])).collect()))]) - }; - let asset_alias = - |key: &'static str, version: &'static str| o(vec![("value", o(vec![("alias", by_asset(key, version))])), ("dataType", e("ALIAS"))]); - let nodes = vec![ - node(0, None, "DOCUMENT", "Document", vec![]), - // The page is set to "Purple", a mode the oldest copy of the collection didn't have yet. - node( - 1, - Some((0, "!")), - "CANVAS", - "Page", - vec![( - "variableModeBySetMap", - o(vec![("entries", arr(vec![o(vec![("variableSetID", by_asset("products", "10:1")), ("variableModeID", g(92))])]))]), - )], - ), - node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), - node( - 4, - Some((2, "!")), - "VARIABLE_SET", - "Products", - vec![("variableSetModes", arr(vec![mode(90, "Blue", "!")])), ("key", s("products")), ("version", s("10:1"))], - ), - node( - 6, - Some((2, "#")), - "VARIABLE_SET", - "Products", - vec![("variableSetModes", arr(vec![mode(90, "Blue", "!"), mode(92, "Purple", "\"")])), ("key", s("products")), ("version", s("20:1"))], - ), - node( - 5, - Some((2, "\"")), - "VARIABLE", - "button-bg", - vec![ - ("variableSetID", by_asset("products", "10:1")), - ("variableResolvedType", e("COLOR")), - ("variableDataValues", values(vec![(90, rgb(0.0, 0.0, 1.0))])), - ("key", s("button-bg")), - ("version", s("10:1")), - ], - ), - node( - 7, - Some((2, "$")), - "VARIABLE", - "button-bg", - vec![ - ("variableSetID", by_asset("products", "20:1")), - ("variableResolvedType", e("COLOR")), - ("variableDataValues", values(vec![(90, rgb(0.0, 0.0, 1.0)), (92, rgb(0.5, 0.0, 0.5))])), - ("key", s("button-bg")), - ("version", s("20:1")), - ], - ), - // The button names the older copy of the variable. - node( - 10, - Some((1, "!")), - "FRAME", - "Button", - vec![ - ("size", xy(100.0, 40.0)), - ( - "fillPaints", - arr(vec![o(vec![("type", e("SOLID")), ("color", rgb(0.0, 0.0, 1.0)), ("colorVar", asset_alias("button-bg", "10:1"))])]), - ), - ], - ), - ]; - let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); - assert_eq!(doc.variables.collections.len(), 1, "{:?}", doc.variables.collections); - let products = &doc.variables.collections[0]; - assert_eq!(products.modes.iter().map(|m| m.name.as_str()).collect::>(), ["Blue", "Purple"]); - assert_eq!(doc.variables.variables.len(), 1); - let var = &doc.variables.variables[0]; - assert_eq!(var.collection, products.id); - assert_eq!(var.values.len(), 2, "the newest copy's values: {:?}", var.values); - let page = doc.nodes().find(|n| n.name == "Page").unwrap(); - assert_eq!(page.var_modes.get(&products.id).map(String::as_str), Some("1:92")); - let button = named(&doc, "Button"); - assert_eq!(button.bound.values().next(), Some(&var.id), "{:?}", button.bound); - assert!(!warnings.iter().any(|w| w.contains("variable modes of collections")), "{warnings:?}"); -} From 5d4a5082ec8188f03ea6107184c771fa0dc7e7c0 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 14:54:48 +0330 Subject: [PATCH 08/16] Add files via upload --- crates/format/src/fig/map.rs | 16 +++++++++---- crates/format/src/fig/tests.rs | 42 +++++++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 98239cd7b..e24a93b2d 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -427,6 +427,8 @@ fn version_rank(v: &str) -> (u64, u64) { pub(super) struct VarCanon { by_key: HashMap, by_id: HashMap, + /// Every live variable node of the file, with a key or not. + local: BTreeSet, } thread_local! { @@ -446,10 +448,12 @@ impl VarCanon { fn install(m: &Mapper) -> VarCanonGuard { let mut newest: HashMap = HashMap::new(); let mut copies: Vec<(String, String)> = Vec::new(); + let mut local = BTreeSet::new(); for (id, n) in &m.nodes { if n.get("type").and_then(Kv::str) != Some("VARIABLE") || n.get("isSoftDeleted").and_then(Kv::bool) == Some(true) { 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())); @@ -465,21 +469,25 @@ impl VarCanon { } } t.by_key = newest.into_iter().map(|(k, (_, id))| (k, id)).collect(); + t.local = local; VAR_CANON.with(|c| *c.borrow_mut() = Some(t)); VarCanonGuard } - /// The variable a reference (`{guid}` or `{assetRef}`) names, as its newest copy. + /// 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 { + 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 key = r.get("assetRef")?.get("key").and_then(Kv::str)?; - t.by_key.get(key).cloned() + 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) }) } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 0e2c5016e..d0cea1932 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -1522,7 +1522,8 @@ fn library_copies_of_several_versions_become_the_newest() { Some((2, "!")), "VARIABLE_SET", "Products", - vec![("variableSetModes", arr(vec![mode(90, "Blue", "!")])), ("key", s("products")), ("version", s("10:1"))], + // "Legacy" was removed from the library since: only this older copy has it. + vec![("variableSetModes", arr(vec![mode(90, "Blue", "!"), mode(93, "Legacy", "\"")])), ("key", s("products")), ("version", s("10:1"))], ), node( 6, @@ -1557,6 +1558,37 @@ fn library_copies_of_several_versions_become_the_newest() { ("version", s("20:1")), ], ), + // A pasted layer names the variable by the library's own guid (not a node of this file) + // and by its asset: the asset wins. + node( + 11, + Some((1, "\"")), + "FRAME", + "Pasted button", + vec![ + ("size", xy(100.0, 40.0)), + ( + "fillPaints", + arr(vec![o(vec![ + ("type", e("SOLID")), + ("color", rgb(0.0, 0.0, 1.0)), + ( + "colorVar", + o(vec![ + ( + "value", + o(vec![( + "alias", + o(vec![("guid", g(777)), ("assetRef", o(vec![("key", s("button-bg")), ("version", s("20:1"))]))]), + )]), + ), + ("dataType", e("ALIAS")), + ]), + ), + ])]), + ), + ], + ), // The button names the older copy of the variable. node( 10, @@ -1575,14 +1607,18 @@ fn library_copies_of_several_versions_become_the_newest() { let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); assert_eq!(doc.variables.collections.len(), 1, "{:?}", doc.variables.collections); let products = &doc.variables.collections[0]; - assert_eq!(products.modes.iter().map(|m| m.name.as_str()).collect::>(), ["Blue", "Purple"]); + assert_eq!(products.modes.iter().map(|m| m.name.as_str()).collect::>(), ["Blue", "Purple", "Legacy"]); assert_eq!(doc.variables.variables.len(), 1); let var = &doc.variables.variables[0]; assert_eq!(var.collection, products.id); - assert_eq!(var.values.len(), 2, "the newest copy's values: {:?}", var.values); + // The newest copy's values: Purple included (a mode only it has), Legacy filled from the default. + assert_eq!(var.values.len(), 3, "{:?}", var.values); + assert!(matches!(var.values.get("1:92"), Some(figcraft_doc::VariableValue::Color(c)) if (c.r - 0.5).abs() < 1e-6), "{:?}", var.values); let page = doc.nodes().find(|n| n.name == "Page").unwrap(); assert_eq!(page.var_modes.get(&products.id).map(String::as_str), Some("1:92")); let button = named(&doc, "Button"); assert_eq!(button.bound.values().next(), Some(&var.id), "{:?}", button.bound); + let pasted = named(&doc, "Pasted button"); + assert_eq!(pasted.bound.values().next(), Some(&var.id), "{:?}", pasted.bound); assert!(!warnings.iter().any(|w| w.contains("variable modes of collections")), "{warnings:?}"); } From c411152b877508613bee62f332c91f77c0a480b0 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 15:34:41 +0330 Subject: [PATCH 09/16] Add files via upload --- crates/format/src/fig/map.rs | 45 +++++++++++++++++++++------- crates/format/src/fig/tests.rs | 55 +++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index e24a93b2d..97e758e7f 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -372,22 +372,23 @@ impl<'a> Mapper<'a> { /// 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. + /// 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 id = self.resource_id(v)?; - Some(self.canonical_collection(&id).unwrap_or(id)) + 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())?; - let live_set = |n: &Kv| n.get("type").and_then(Kv::str) == Some("VARIABLE_SET") && n.get("isSoftDeleted").and_then(Kv::bool) != Some(true); - self.assets - .get(key)? - .iter() - .filter(|(_, nid)| self.nodes.get(nid.as_str()).is_some_and(|n| live_set(n))) - .max_by_key(|(ver, _)| version_rank(ver)) - .map(|(_, nid)| nid.clone()) + VarCanon::newest_set(key) } /// A note about what this file's mapping left out or changed. Counted, as the REST importer @@ -429,6 +430,10 @@ pub(super) struct VarCanon { 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! { @@ -449,8 +454,20 @@ impl VarCanon { 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 { - if n.get("type").and_then(Kv::str) != Some("VARIABLE") || n.get("isSoftDeleted").and_then(Kv::bool) == Some(true) { + 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()); @@ -470,6 +487,7 @@ impl VarCanon { } 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 } @@ -491,6 +509,11 @@ impl VarCanon { }) } + /// 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 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/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index d0cea1932..d365ff9f7 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -28,6 +28,7 @@ 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 ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQUALS enum NavigationType NAVIGATE OVERLAY @@ -74,7 +75,7 @@ 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 +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, NodePhase phase message Blob byte[] bytes message Message NodeChange[] nodeChanges, Blob[] blobs "; @@ -1589,6 +1590,23 @@ fn library_copies_of_several_versions_become_the_newest() { ), ], ), + // A page section set by a versionless reference, while the file holds several copies. + node( + 12, + Some((1, "#")), + "FRAME", + "Versionless", + vec![ + ("size", xy(100.0, 40.0)), + ( + "variableModeBySetMap", + o(vec![( + "entries", + arr(vec![o(vec![("variableSetID", o(vec![("assetRef", o(vec![("key", s("products"))]))])), ("variableModeID", g(92))])]), + )]), + ), + ], + ), // The button names the older copy of the variable. node( 10, @@ -1618,7 +1636,42 @@ fn library_copies_of_several_versions_become_the_newest() { assert_eq!(page.var_modes.get(&products.id).map(String::as_str), Some("1:92")); let button = named(&doc, "Button"); assert_eq!(button.bound.values().next(), Some(&var.id), "{:?}", button.bound); + assert_eq!(named(&doc, "Versionless").var_modes.get(&products.id).map(String::as_str), Some("1:92")); let pasted = named(&doc, "Pasted button"); assert_eq!(pasted.bound.values().next(), Some(&var.id), "{:?}", pasted.bound); assert!(!warnings.iter().any(|w| w.contains("variable modes of collections")), "{warnings:?}"); } + +/// A copy of a library collection that a later change removed, followed by a live node for the +/// same asset key and version: the live one is the collection, not the removed one the asset +/// index met first. +#[test] +fn removed_library_collection_copies_give_way_to_live_ones() { + let mode = |id, name: &'static str, pos: &'static str| o(vec![("id", g(id)), ("name", s(name)), ("sortPosition", s(pos))]); + let by_asset = |version: &'static str| o(vec![("assetRef", o(vec![("key", s("products")), ("version", s(version))]))]); + let set = |id, version: &'static str, modes: Vec>| { + node(id, Some((2, "!")), "VARIABLE_SET", "Products", vec![("variableSetModes", arr(modes)), ("key", s("products")), ("version", s(version))]) + }; + let removed = o(vec![("guid", g(8)), ("phase", e("REMOVED"))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node( + 1, + Some((0, "!")), + "CANVAS", + "Page", + vec![("variableModeBySetMap", o(vec![("entries", arr(vec![o(vec![("variableSetID", by_asset("30:1")), ("variableModeID", g(92))])]))]))], + ), + node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + set(4, "10:1", vec![mode(90, "Blue", "!")]), + set(8, "30:1", vec![mode(90, "Blue", "!"), mode(92, "Purple", "\"")]), + removed, + set(9, "30:1", vec![mode(90, "Blue", "!"), mode(92, "Purple", "\"")]), + ]; + let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert_eq!(doc.variables.collections.len(), 1, "{:?}", doc.variables.collections); + let products = &doc.variables.collections[0]; + assert_eq!(products.id, "1:9"); + let page = doc.nodes().find(|n| n.name == "Page").unwrap(); + assert_eq!(page.var_modes.get("1:9").map(String::as_str), Some("1:92"), "{warnings:?}"); +} From 7f4b295983ab8527ae09e535aedc22a4fa411747 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 16:17:10 +0330 Subject: [PATCH 10/16] Add files via upload --- crates/format/src/fig/map.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 97e758e7f..e125028c8 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -444,12 +444,15 @@ thread_local! { 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(); From ef52759ebc3c794b6c318a8aeff94613fd7f6b24 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 16:17:46 +0330 Subject: [PATCH 11/16] Add files via upload --- crates/format/src/fig/map/variables.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/format/src/fig/map/variables.rs b/crates/format/src/fig/map/variables.rs index 12b433fc0..0794ef155 100644 --- a/crates/format/src/fig/map/variables.rs +++ b/crates/format/src/fig/map/variables.rs @@ -2,6 +2,9 @@ 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(); @@ -137,7 +140,8 @@ pub(super) fn variable_data(d: &Kv, depth: usize) -> Option { Some(out) } -/// The variable an `ALIAS` variable data points at. +/// 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") { From 5c9bb79b1abe44e57ec919499127b37138a9d14a Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Mon, 2 Nov 2026 10:34:09 +0330 Subject: [PATCH 12/16] Add files via upload --- crates/format/src/fig/map.rs | 6 +++++ crates/format/src/fig/tests.rs | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index e125028c8..927c233a2 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -517,6 +517,12 @@ impl VarCanon { 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)] + pub(crate) 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/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index d365ff9f7..787c70865 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -1675,3 +1675,51 @@ fn removed_library_collection_copies_give_way_to_live_ones() { let page = doc.nodes().find(|n| n.name == "Page").unwrap(); assert_eq!(page.var_modes.get("1:9").map(String::as_str), Some("1:92"), "{warnings:?}"); } + +/// Two copies of a library collection with the same version: the choice doesn't depend on the order +/// of the node changes (the higher guid wins), and the thread's lookup table is gone after the import. +#[test] +fn same_version_library_copies_resolve_deterministically_and_leave_no_table() { + let mode = |id, name: &'static str| o(vec![("id", g(id)), ("name", s(name)), ("sortPosition", s("!"))]); + let set = |id, modes: Vec>| { + node(id, Some((2, "!")), "VARIABLE_SET", "Products", vec![("variableSetModes", arr(modes)), ("key", s("products")), ("version", s("20:1"))]) + }; + let page = node( + 1, + Some((0, "!")), + "CANVAS", + "Page", + vec![( + "variableModeBySetMap", + o(vec![( + "entries", + arr(vec![o(vec![("variableSetID", o(vec![("assetRef", o(vec![("key", s("products"))]))])), ("variableModeID", g(90))])]), + )]), + )], + ); + let base = || { + vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + page.clone(), + node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + ] + }; + let mut picked = Vec::new(); + for swap in [false, true] { + let (a, b) = (set(4, vec![mode(90, "Blue")]), set(6, vec![mode(90, "Blue")])); + let mut nodes = base(); + if swap { + nodes.extend([b, a]); + } else { + nodes.extend([a, b]); + } + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert_eq!(doc.variables.collections.len(), 1, "{:?}", doc.variables.collections); + let id = doc.variables.collections[0].id.clone(); + let page = doc.nodes().find(|n| n.name == "Page").unwrap(); + assert_eq!(page.var_modes.get(&id).map(String::as_str), Some("1:90")); + picked.push(id); + assert!(!super::map::VarCanon::installed()); + } + assert_eq!(picked, ["1:6", "1:6"]); +} From 14431850ca4c532c0676b1cb0eb23dfafe2e101e Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 3 Nov 2026 09:34:08 -0500 Subject: [PATCH 13/16] M1: Layers panel as wide as its widest name, scrolling sideways (#1904) The Layers panel cut a name that didn't fit with an ellipsis and had no horizontal scrollbar, so an imported component set's variants all read "Size=small, Type=prim..." and a deeply nested name lost its room to the indent. The row cache now also keeps the width the widest row needs (a few names measured per rebuild, picked by a bound on their width), the list takes that width and scrolls both ways, revealing a layer scrolls up or down only, and a name past 1200 pt still elides (#1650). Tests: layers_wide_names_tests. Docs: ui-parity Layers panel row, status line and revision history. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/layers_wide_names_tests.rs | 115 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/layers.rs | 104 +++++++++++++--- docs/ui-parity.md | 4 +- 4 files changed, 207 insertions(+), 18 deletions(-) create mode 100644 crates/ui-egui/src/layers_wide_names_tests.rs diff --git a/crates/ui-egui/src/layers_wide_names_tests.rs b/crates/ui-egui/src/layers_wide_names_tests.rs new file mode 100644 index 000000000..2a4ae8999 --- /dev/null +++ b/crates/ui-egui/src/layers_wide_names_tests.rs @@ -0,0 +1,115 @@ +//! Long and deeply nested names in the Layers panel (#1904), drawn headless: the list is as wide +//! as its widest name needs and scrolls sideways, so a name reads in full without widening the +//! sidebar, as the reference app's Layers panel does with its horizontal scrollbar; names that +//! fit leave the list as wide as the panel; a name past the width cap (#1650) still elides. + +use egui::{Event, MouseWheelUnit, Pos2, Shape, TouchPhase, Vec2, vec2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A rectangle named `name` on the page, or inside `parent`. +fn named(s: &mut Session, name: &str, parent: Option) -> NodeId { + let mut p = json!({"x": 0, "y": 0, "width": 20, "height": 20, "props": {"name": name}}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(s.execute("shape.rectangle", &p).unwrap()) +} + +/// The painted text starting with `prefix`: (whether it was elided, its position, its size). +fn painted(u: &mut Ui, prefix: &str) -> (bool, Pos2, Vec2) { + let shapes = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + shapes + .iter() + .find_map(|s| match s { + Shape::Text(t) if t.galley.text().starts_with(prefix) => Some((t.galley.elided, t.pos, t.galley.size())), + _ => None, + }) + .unwrap_or_else(|| panic!("no text starting {prefix:?}")) +} + +/// Turn the wheel sideways by `dx` points over `at`, and let the scroll settle. +fn wheel_sideways(u: &mut Ui, at: Pos2, dx: f32) { + let wheel = Event::MouseWheel { + unit: MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, + delta: vec2(-dx, 0.0), + phase: TouchPhase::Move, + modifiers: Default::default(), + }; + u.frame(vec![Event::PointerMoved(at), wheel]); + for _ in 0..30 { + u.frame(vec![]); + } +} + +const LONG: &str = "Size=small, Type=primary, State=default, Icon=leading, Width=full"; + +#[test] +fn a_long_name_is_painted_in_full_and_the_list_scrolls_sideways() { + let mut s = Session::new(); + let long = named(&mut s, LONG, None); + named(&mut s, "Short", None); + let mut u = Ui::new(s); + let (elided, p, size) = painted(&mut u, "Size=small"); + assert!(!elided, "the whole name, not an elided one"); + // The panel is 240 pt wide: the name runs past it, into the space a sideways scroll shows. + assert!(p.x + size.x > 320.0, "the name runs past the panel: {} + {}", p.x, size.x); + let at = u.row_at(long); + wheel_sideways(&mut u, at, 120.0); + let (_, scrolled, _) = painted(&mut u, "Size=small"); + assert!(scrolled.x <= p.x - 100.0, "the list scrolled sideways: {} -> {}", p.x, scrolled.x); + // Short names move with it (one list), not on their own. + let (_, short, _) = painted(&mut u, "Short"); + assert!(short.x < 100.0, "the short name moved with the list: {}", short.x); +} + +#[test] +fn a_deeply_nested_name_gets_its_indent_too() { + let mut s = Session::new(); + // A name that fits at the top level, under five frames: the indent pushes it past the panel. + let mut parent = None; + for i in 0..5 { + let f = id(s + .execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": parent, "props": {"name": format!("Frame {i}")}})) + .unwrap()); + parent = Some(f); + } + let leaf = named(&mut s, "Component set original (não usar)", parent); + s.execute("select.set", &json!({"ids": [leaf]})).unwrap(); + let mut u = Ui::new(s); + let (elided, p, size) = painted(&mut u, "Component set original"); + assert!(!elided, "the nested name reads in full"); + assert!(p.x + size.x > 320.0, "it runs past the panel: {} + {}", p.x, size.x); +} + +#[test] +fn names_that_fit_leave_the_list_as_wide_as_the_panel() { + let mut s = Session::new(); + let short = named(&mut s, "Short", None); + let mut u = Ui::new(s); + let (elided, p, _) = painted(&mut u, "Short"); + assert!(!elided); + let at = u.row_at(short); + wheel_sideways(&mut u, at, 120.0); + let (_, after, _) = painted(&mut u, "Short"); + assert_eq!(after.x, p.x, "nothing to scroll to sideways"); +} + +#[test] +fn a_name_past_the_room_cap_still_elides() { + let mut s = Session::new(); + let name = "Layer ".repeat(500); + named(&mut s, &name, None); + let mut u = Ui::new(s); + let (elided, _, size) = painted(&mut u, "Layer Layer"); + assert!(elided, "a 3000-character name elides"); + assert!(size.x <= crate::layers::NAME_ROOM + 1.0, "at the cap, not thousands of points wide: {}", size.x); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e9969c106..371a72215 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -142,6 +142,8 @@ mod layers_keys_tests; #[cfg(test)] mod layers_rename_tests; #[cfg(test)] +mod layers_wide_names_tests; +#[cfg(test)] mod library_beacon_tests; #[cfg(test)] mod live_chat_tests; diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 164ec7e4a..a34b43e77 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -486,6 +486,8 @@ struct RowCache { expanded: u64, filter: String, rows: Arc>, + /// The width the widest row needs to show its whole name ([`widest_row`]). + width: f32, } fn hash_expanded(expanded: &BTreeSet) -> u64 { @@ -498,15 +500,76 @@ fn hash_expanded(expanded: &BTreeSet) -> u64 { /// The rows to list: the expanded tree, or the layers matching `filter` (lowercase). pub(crate) fn rows_for(ui: &Ui, doc: &Arc, page: NodeId, expanded: &BTreeSet, filter: &str) -> Arc> { let ex = if filter.is_empty() { hash_expanded(expanded) } else { 0 }; + let id = Id::unique("layer_rows"); + let fresh = ui + .data(|d| d.get_temp::(id)) + .filter(|c| c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page) && c.expanded == ex && c.filter == filter); + if let Some(c) = fresh { + return c.rows; + } + let rows = Arc::new(listed_rows(doc, page, expanded, filter)); + // Outside the context's data lock: laying out the names takes the context too. + let width = widest_row(ui, doc, page, &rows); ui.data_mut(|d| { - let c = d.get_temp_mut_or_default::(Id::unique("layer_rows")); - let fresh = c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page) && c.expanded == ex && c.filter == filter; - if !fresh { - let rows = listed_rows(doc, page, expanded, filter); - *c = RowCache { doc: Some(doc.clone()), page: Some(page), expanded: ex, filter: filter.to_string(), rows: Arc::new(rows) }; + d.insert_temp(id, RowCache { doc: Some(doc.clone()), page: Some(page), expanded: ex, filter: filter.to_string(), rows: rows.clone(), width }) + }); + rows +} + +/// The width the rows [`rows_for`] last listed need to show their names in full, from the +/// panel's left edge (0 before any were listed). +pub(crate) fn list_width(ui: &Ui) -> f32 { + ui.data(|d| d.get_temp::(Id::unique("layer_rows"))).map_or(0.0, |c| c.width) +} + +/// A name's room on a row stops growing here: a name of thousands of characters (#1650) elides +/// past it instead of making the list that wide. +pub(crate) const NAME_ROOM: f32 = 1200.0; + +/// A row's name: the layer's, or its kind's label when it has none. +fn row_name(n: &figcraft_doc::Node) -> &str { + if n.name.is_empty() { tr(n.kind.label()) } else { &n.name } +} + +/// The font a row's name is drawn in: top-level frames and sections stand out. +fn name_font(doc: &Document, page: NodeId, n: &figcraft_doc::Node) -> egui::FontId { + let top_level = doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page()); + if top_level && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) { semibold(11.0) } else { regular(11.0) } +} + +/// The width the widest of `rows` needs to show its whole name, from the panel's left edge: the +/// row's margins, its indent, the disclosure triangle and icon, the name in its font (up to +/// [`NAME_ROOM`]) and the eye and padlock's room. The list is made this wide, so it scrolls +/// sideways when a name doesn't fit the panel (#1904). Only the rows whose name could be the +/// widest are laid out: a name's bytes at the widest glyph bound its width, and the few rows with +/// the largest bounds are measured, largest first, until the bound falls under the widest found; +/// a 10 000-row list lays out a handful of names. +fn widest_row(ui: &Ui, doc: &Document, page: NodeId, rows: &[Row]) -> f32 { + // What sits left of the name (the row's margin, the indent's start, the triangle and icon) + // and right of it (the eye and padlock, the margin): see the row loop in `layers`. + const AROUND: f32 = 8.0 + 4.0 + 36.0 + 56.0 + 8.0; + // No glyph of the 11 pt fonts is wider than this: a name's byte count bounds its width. + const GLYPH: f32 = 12.0; + // The rows laid out at most: enough that the widest is among them whatever the letters. + const MEASURED: usize = 48; + let indent = |r: &Row| r.depth as f32 * 16.0; + let mut bounds: Vec<(f32, usize)> = + rows.iter().enumerate().filter_map(|(i, r)| doc.node(r.id).map(|n| (indent(r) + row_name(n).len() as f32 * GLYPH, i))).collect(); + if bounds.len() > MEASURED { + bounds.select_nth_unstable_by(MEASURED, |a, b| b.0.total_cmp(&a.0)); + bounds.truncate(MEASURED); + } + bounds.sort_by(|a, b| b.0.total_cmp(&a.0)); + let mut widest = 0.0f32; + for (bound, i) in bounds { + if bound <= widest { + break; } - c.rows.clone() - }) + let Some((r, n)) = rows.get(i).and_then(|r| doc.node(r.id).map(|n| (r, n))) else { continue }; + let w = ui.painter().layout_no_wrap(row_name(n).to_string(), name_font(doc, page, n), Color32::WHITE).size().x; + widest = widest.max(indent(r) + w.min(NAME_ROOM)); + } + widest + AROUND } /// The rows to list, uncached: the expanded tree, or the layers matching `filter` (lowercase). @@ -1005,6 +1068,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let reveal = reveal_selection(app, &doc, page, &sel); let filter = app.ui.layer_filter.trim().to_lowercase(); let rows = rows_for(ui, &doc, page, &app.ui.expanded, &filter); + // The rows are as wide as the panel, or as the widest name needs: the list then scrolls + // sideways too, so long and deeply nested names read in full without widening the sidebar, + // as the reference app's Layers panel does with its horizontal scrollbar (#1904). + let row_w = w.max(list_width(ui)); let mut scroll_to = if reveal { sel.first().and_then(|s| rows.iter().position(|r| r.id == *s)) } else { None }; // A rename in progress ends when its layer isn't listed any more; Tab / Shift+Tab commit it and // go on to the next / previous row. @@ -1046,13 +1113,17 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // The drag is over a row that takes no drop: the not-allowed cursor shows. let mut refused = false; // Only the rows in view are laid out and drawn; the rest is empty scroll space. - egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { + egui::ScrollArea::both().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { let pitch = ROW + ui.spacing().item_spacing.y; let top = ui.max_rect().top(); + let left = ui.max_rect().left(); let total = rows.len() as f32 * pitch; + let row_w = row_w.max(viewport.width()); ui.set_height(total.max(viewport.height())); + ui.set_width(row_w); if let Some(i) = scroll_to { - ui.scroll_to_rect(Rect::from_min_size(pos2(ui.max_rect().left(), top + i as f32 * pitch), vec2(w, ROW)), None); + // The row at the width in view: revealing a layer scrolls up or down, not sideways. + ui.scroll_to_rect(Rect::from_min_size(pos2(left + viewport.min.x, top + i as f32 * pitch), vec2(viewport.width(), ROW)), None); } if rows.is_empty() { ui.add_space(8.0); @@ -1065,14 +1136,14 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { drag_autoscroll(ui, dragging.is_some()); let first = ((viewport.min.y / pitch).floor().max(0.0) as usize).min(rows.len()); let last = ((viewport.max.y / pitch).ceil().max(0.0) as usize).saturating_add(1).min(rows.len()); - let band = Rect::from_x_y_ranges(ui.max_rect().x_range(), (top + first as f32 * pitch)..=(top + last as f32 * pitch)); + let band = Rect::from_x_y_ranges(left..=(left + row_w), (top + first as f32 * pitch)..=(top + last as f32 * pitch)); let mut band_ui = ui.new_child(egui::UiBuilder::new().max_rect(band)); // Stable widget ids for a row wherever the list is scrolled. band_ui.skip_ahead_auto_ids(first); let ui = &mut band_ui; for row in rows.iter().take(last).skip(first) { let Some(n) = doc.node(row.id) else { continue }; - let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag()); + let (r, resp) = ui.allocate_exact_size(vec2(row_w, ROW), Sense::click_and_drag()); let selected = sel.contains(&row.id); a11y::layer_row(&mut app.ui.nav, &resp); if sel.last() == Some(&row.id) { @@ -1124,11 +1195,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } } else { let col = row_color(is_comp, shown, t.text, t.text_tertiary); - let top_level = doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page()); - let font = - if top_level && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) { semibold(11.0) } else { regular(11.0) }; - let name = if n.name.is_empty() { tr(n.kind.label()).to_string() } else { n.name.clone() }; - // Room left of the lock and eye buttons. + let font = name_font(&doc, page, n); + let name = row_name(n).to_string(); + // Room left of the lock and eye buttons (the row is wide enough for the whole + // name, up to `NAME_ROOM`). let max_w = (rr.max.x - 56.0 - name_x).max(20.0); crate::bidi::paint_name(ui.painter(), pos2(name_x, r.center().y), &name, font, col, max_w); } @@ -1177,7 +1247,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if let Some((i, id, depth)) = rename_row.filter(|(i, ..)| !(first..last).contains(i)) && let Some(n) = doc.node(id) { - let r = Rect::from_min_size(pos2(band.min.x, top + i as f32 * pitch), vec2(w, ROW)); + let r = Rect::from_min_size(pos2(band.min.x, top + i as f32 * pitch), vec2(row_w, ROW)); if let Some(new) = rename_field(app, ui, id, depth, &n.name, r) { rename_done = Some((id, new)); } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cc0fc9db8..71d32a14b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (the Layers panel is as wide as its widest name needs and scrolls sideways, so long and deeply nested names read in full (#1904); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -170,6 +170,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Screen-reader names, roles, values, states (AccessKit) | done on desktop | browser: eframe doesn't send the tree yet | | Keyboard navigation: F6 regions, Tab within, focus ring | done | | | Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` | +| Layers panel: long or deeply nested names | done | the list is as wide as its widest name needs and scrolls sideways, so a name reads in full without widening the sidebar (the reference app's Layers panel has a horizontal scrollbar); a name past 1200 pt still elides (`layers_wide_names_tests`, #1904) | | Reduced motion | done | system setting or preference | | WCAG AA contrast of secondary and accent text | done | disabled and dim text at least 3:1 against panels, fields and selected rows, selected text at least 3:1 on its highlight, in both themes (`theme::tests`, #890) | | Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA | @@ -178,6 +179,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-03 | patch | M1, #1904: the Layers panel cut a name that didn't fit with an ellipsis and had no horizontal scrollbar, so the variants of an imported component set all read `Size=small, Type=prim…` and a deeply nested name lost its room to the indent. The list is now as wide as its widest name needs (the row cache measures the few names that could be the widest, so a 10 000-row list lays out a handful) and scrolls sideways, as the reference app's Layers panel does with its horizontal scrollbar; revealing a layer scrolls up or down only; the eye and padlock sit at the end of the row and scroll with it; a name past 1200 pt still elides (#1650). Tests: `layers_wide_names_tests`. Numbers unchanged. | | 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. | | 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. | | 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` | From 4ad80fa7bee3bfc6b3a5cd1ce99de69d347808c2 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 3 Nov 2026 09:57:59 -0500 Subject: [PATCH 14/16] M1: Layers panel width: measure every candidate as painted, cap painting, no drops past the list (#1904) Review: the widest name is found by laying rows out in descending order of a bound on their width until the bound falls under the widest found, with no cut at 48 rows; each name is laid out as it is painted (one line, cut at NAME_ROOM); the painted room is capped at NAME_ROOM too; and a drag over a wide row counts only inside the list's clip rect, so a layer released over the canvas drops nowhere. Tests: a_wide_name_among_many_long_narrow_ones_is_still_measured, a_name_with_line_breaks_is_measured_as_painted, a_drag_released_over_the_canvas_drops_nowhere. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/layers_wide_names_tests.rs | 46 +++++++++++++++++++ crates/ui-egui/src/panels/layers.rs | 29 ++++++------ docs/ui-parity.md | 4 +- 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/layers_wide_names_tests.rs b/crates/ui-egui/src/layers_wide_names_tests.rs index 2a4ae8999..eeb833e42 100644 --- a/crates/ui-egui/src/layers_wide_names_tests.rs +++ b/crates/ui-egui/src/layers_wide_names_tests.rs @@ -113,3 +113,49 @@ fn a_name_past_the_room_cap_still_elides() { assert!(elided, "a 3000-character name elides"); assert!(size.x <= crate::layers::NAME_ROOM + 1.0, "at the cap, not thousands of points wide: {}", size.x); } + +/// Review: the widest name isn't always the longest in bytes. Fifty rows of narrow letters outrank +/// one row of wide ones by the bound; it is laid out all the same, so it reads in full. +#[test] +fn a_wide_name_among_many_long_narrow_ones_is_still_measured() { + let mut s = Session::new(); + for _ in 0..50 { + named(&mut s, &"i".repeat(100), None); + } + named(&mut s, &"W".repeat(70), None); + let mut u = Ui::new(s); + let (elided, ..) = painted(&mut u, "WWWW"); + assert!(!elided, "the wide name reads in full"); +} + +/// Review: a name is laid out as it is painted, on one line, so line breaks in it don't make it +/// measure narrower than it paints. +#[test] +fn a_name_with_line_breaks_is_measured_as_painted() { + let mut s = Session::new(); + named(&mut s, "Size=small,\nType=primary,\nState=default,\nIcon=leading,\nWidth=full", None); + let mut u = Ui::new(s); + let (elided, ..) = painted(&mut u, "Size=small,"); + assert!(!elided, "the whole name on one line"); +} + +/// Review: a wide row runs on under the canvas; a dragged layer released there is no drop. +#[test] +fn a_drag_released_over_the_canvas_drops_nowhere() { + let mut s = Session::new(); + let wide = named(&mut s, &"Size=small, Type=primary, ".repeat(6), None); + let short = named(&mut s, "Short", None); + let page = s.page().unwrap(); + let order = s.doc().unwrap().node(page).unwrap().children.clone(); + let mut u = Ui::new(s); + let from = u.row_at(short); + let level = u.row_at(wide).y; + // Over the canvas, level with the wide row (which runs on under it). + let over_canvas = Pos2::new(800.0, level); + u.drag_to(from, over_canvas); + u.button(over_canvas, false); + for _ in 0..5 { + u.frame(vec![]); + } + assert_eq!(u.app.session.doc().unwrap().node(page).unwrap().children, order, "no reorder"); +} diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index a34b43e77..54682c683 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -540,25 +540,20 @@ fn name_font(doc: &Document, page: NodeId, n: &figcraft_doc::Node) -> egui::Font /// The width the widest of `rows` needs to show its whole name, from the panel's left edge: the /// row's margins, its indent, the disclosure triangle and icon, the name in its font (up to /// [`NAME_ROOM`]) and the eye and padlock's room. The list is made this wide, so it scrolls -/// sideways when a name doesn't fit the panel (#1904). Only the rows whose name could be the -/// widest are laid out: a name's bytes at the widest glyph bound its width, and the few rows with -/// the largest bounds are measured, largest first, until the bound falls under the widest found; -/// a 10 000-row list lays out a handful of names. +/// sideways when a name doesn't fit the panel (#1904). A name's bytes at the widest glyph bound +/// its width, and the rows are laid out in descending order of that bound until the bound falls +/// under the widest found, so no name that could be the widest is skipped: a list with one long +/// name lays out a handful, a list of names all alike lays them all out, through egui's galley +/// cache. A name is laid out as it is painted: on one line, cut at [`NAME_ROOM`]. fn widest_row(ui: &Ui, doc: &Document, page: NodeId, rows: &[Row]) -> f32 { // What sits left of the name (the row's margin, the indent's start, the triangle and icon) // and right of it (the eye and padlock, the margin): see the row loop in `layers`. const AROUND: f32 = 8.0 + 4.0 + 36.0 + 56.0 + 8.0; // No glyph of the 11 pt fonts is wider than this: a name's byte count bounds its width. const GLYPH: f32 = 12.0; - // The rows laid out at most: enough that the widest is among them whatever the letters. - const MEASURED: usize = 48; let indent = |r: &Row| r.depth as f32 * 16.0; let mut bounds: Vec<(f32, usize)> = rows.iter().enumerate().filter_map(|(i, r)| doc.node(r.id).map(|n| (indent(r) + row_name(n).len() as f32 * GLYPH, i))).collect(); - if bounds.len() > MEASURED { - bounds.select_nth_unstable_by(MEASURED, |a, b| b.0.total_cmp(&a.0)); - bounds.truncate(MEASURED); - } bounds.sort_by(|a, b| b.0.total_cmp(&a.0)); let mut widest = 0.0f32; for (bound, i) in bounds { @@ -566,8 +561,8 @@ fn widest_row(ui: &Ui, doc: &Document, page: NodeId, rows: &[Row]) -> f32 { break; } let Some((r, n)) = rows.get(i).and_then(|r| doc.node(r.id).map(|n| (r, n))) else { continue }; - let w = ui.painter().layout_no_wrap(row_name(n).to_string(), name_font(doc, page, n), Color32::WHITE).size().x; - widest = widest.max(indent(r) + w.min(NAME_ROOM)); + let w = crate::bidi::name_galley(ui.painter(), row_name(n), name_font(doc, page, n), Color32::WHITE, NAME_ROOM).size().x; + widest = widest.max(indent(r) + w); } widest + AROUND } @@ -1198,8 +1193,8 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let font = name_font(&doc, page, n); let name = row_name(n).to_string(); // Room left of the lock and eye buttons (the row is wide enough for the whole - // name, up to `NAME_ROOM`). - let max_w = (rr.max.x - 56.0 - name_x).max(20.0); + // name, up to `NAME_ROOM`: a row widened by a deeper name gives no more). + let max_w = (rr.max.x - 56.0 - name_x).clamp(20.0, NAME_ROOM); crate::bidi::paint_name(ui.painter(), pos2(name_x, r.center().y), &name, font, col, max_w); } let (v, l) = eye_lock(ui, &t, [r, rr], row, n, over && !dev, dev, &mut sweep); @@ -1223,7 +1218,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if resp.drag_started() && !crate::menus::dev_mode_refuses(app, "node.reparent") { ui.data_mut(|d| d.insert_temp(drag_id, row.id)); } - let over = dragging.is_some() && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p)); + // In the row and in the list's view: a wide row runs on under the canvas (#1904), and a + // drag released there lands nowhere. + let clip = ui.clip_rect(); + let over = dragging.is_some() && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p) && clip.contains(p)); // What is inside the dragged layers, and inside an instance, takes no drop. refused |= over && (doc.ancestors(row.id).iter().any(|a| moving.contains(a)) @@ -1232,6 +1230,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { && !blocked(row.id) && let Some(p) = ui.input(|i| i.pointer.hover_pos()) && r.contains(p) + && clip.contains(p) { let collapsed = row.has_kids && !app.ui.expanded.contains(&row.id); let (zone, open, released) = drop_zone(ui, p, [r, rr], x0, row.id, collapsed, n); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 71d32a14b..facf91b39 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -170,7 +170,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Screen-reader names, roles, values, states (AccessKit) | done on desktop | browser: eframe doesn't send the tree yet | | Keyboard navigation: F6 regions, Tab within, focus ring | done | | | Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` | -| Layers panel: long or deeply nested names | done | the list is as wide as its widest name needs and scrolls sideways, so a name reads in full without widening the sidebar (the reference app's Layers panel has a horizontal scrollbar); a name past 1200 pt still elides (`layers_wide_names_tests`, #1904) | +| Layers panel: long or deeply nested names | done | the list is as wide as its widest name needs and scrolls sideways, so a name reads in full without widening the sidebar (the reference app's Layers panel has a horizontal scrollbar); a drag released over the canvas drops nowhere; a name past 1200 pt still elides (`layers_wide_names_tests`, #1904) | | Reduced motion | done | system setting or preference | | WCAG AA contrast of secondary and accent text | done | disabled and dim text at least 3:1 against panels, fields and selected rows, selected text at least 3:1 on its highlight, in both themes (`theme::tests`, #890) | | Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA | @@ -179,7 +179,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-11-03 | patch | M1, #1904: the Layers panel cut a name that didn't fit with an ellipsis and had no horizontal scrollbar, so the variants of an imported component set all read `Size=small, Type=prim…` and a deeply nested name lost its room to the indent. The list is now as wide as its widest name needs (the row cache measures the few names that could be the widest, so a 10 000-row list lays out a handful) and scrolls sideways, as the reference app's Layers panel does with its horizontal scrollbar; revealing a layer scrolls up or down only; the eye and padlock sit at the end of the row and scroll with it; a name past 1200 pt still elides (#1650). Tests: `layers_wide_names_tests`. Numbers unchanged. | +| 2026-11-03 | patch | M1, #1904: the Layers panel cut a name that didn't fit with an ellipsis and had no horizontal scrollbar, so the variants of an imported component set all read `Size=small, Type=prim…` and a deeply nested name lost its room to the indent. The list is now as wide as its widest name needs (the row cache lays names out in descending order of a bound on their width until the bound falls under the widest found: a list with one long name lays out a handful, names all alike all of them, through egui's galley cache) and scrolls sideways, as the reference app's Layers panel does with its horizontal scrollbar; revealing a layer scrolls up or down only; the eye and padlock sit at the end of the row and scroll with it; a drag released past the list, over the canvas, drops nowhere; a name past 1200 pt still elides (#1650). Tests: `layers_wide_names_tests`. Numbers unchanged. | | 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. | | 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. | | 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` | From 04455c489968f6e47cf791649db736558c8757a7 Mon Sep 17 00:00:00 2001 From: fluid-chey Date: Wed, 4 Nov 2026 12:23:09 -0700 Subject: [PATCH 15/16] M9: joining a live session again merges what you did while disconnected Joining again after a session couldn't go on opened your offline changes in a copy beside the file. They now go on top as one undoable edit, through the three-way merge branches use. The rejoin asks to resume as the same client, so layers made offline are yours to send. What can't go in (the same thing changed both ways, or layers a new client can't make) leaves the server's version, and your whole version opens beside it so nothing is lost. A server that no longer knows you merges the same way instead of discarding unconfirmed work. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/collab_live_tests.rs | 104 ++++++++++++++++++----- crates/engine/src/live_tests.rs | 7 ++ crates/engine/src/sharing/live.rs | 24 +++--- crates/engine/src/sharing/live/events.rs | 59 +++++++++---- crates/engine/src/sharing/live/join.rs | 2 +- crates/engine/src/sharing/live/sync.rs | 22 ++++- docs/collab-protocol.md | 11 ++- 7 files changed, 175 insertions(+), 54 deletions(-) diff --git a/crates/engine/src/collab_live_tests.rs b/crates/engine/src/collab_live_tests.rs index 6499c1895..f221420dc 100644 --- a/crates/engine/src/collab_live_tests.rs +++ b/crates/engine/src/collab_live_tests.rs @@ -319,9 +319,10 @@ fn a_dropped_connection_keeps_editing_and_catches_up() { } impl Room { - /// Person `i` has been told to join again: the server takes them as someone new. - fn welcome_anew(&mut self, i: usize) { - let ClientMsg::Hello(h) = Client::hello("f", "t", "Person", "") else { panic!("not a hello") }; + /// Person `i` has been told to join again: the server takes them as `resume` says. + fn welcome_as(&mut self, i: usize, resume: Option) { + let ClientMsg::Hello(mut h) = Client::hello("f", "t", "Person", "") else { panic!("not a hello") }; + h.resume = resume; let (id, out) = self.server.join(h).unwrap(); let w = welcome_of(&out, id); let p = &mut self.people[i]; @@ -351,7 +352,7 @@ fn reconnecting_can_be_told_to_try_now_and_shows_as_disconnected() { } #[test] -fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() { +fn a_session_that_cant_go_on_stays_on_its_tab_and_merges_when_it_joins_again() { let mut room = Room::new(base(), 2, 7); let (f, r) = frame_and_rect(room.server.document()); let uid = room.s(0).ds().unwrap().uid; @@ -363,7 +364,8 @@ fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() { let live = room.s(0).live(); assert!(live.disconnected() && live.rejoinable() && live.doc_uid == Some(uid)); assert!(room.s(0).live_read_only(uid).is_none()); - room.s(0).execute("node.set", &json!({"id": f, "props": {"name": "Offline too"}})).unwrap(); + let made = room.s(0).execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + let made = NodeId(made["id"].as_u64().unwrap()); // A try that fails leaves it just as it was. assert_eq!(room.s(0).execute("collab.reconnect", &json!({})).unwrap()["status"], "connecting"); assert!(room.s(0).live().disconnected() && !room.s(0).live().rejoinable()); @@ -371,23 +373,85 @@ fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() { room.event(0, Event::Status(Status::Failed { reason: "Couldn't join the live session: no answer".into() })); assert!(room.s(0).live().rejoinable()); // Someone else's edit lands meanwhile. - room.s(1).execute("transform.move", &json!({"ids": [r], "dx": 5, "dy": 5})).unwrap(); + room.s(1).execute("transform.move", &json!({"ids": [f], "dx": 5, "dy": 5})).unwrap(); + let theirs = |d: &Document| d.node(f).unwrap().transform; room.settle(); - // The next try joins in the same tab, and what was done offline opens beside it. + let moved = theirs(room.server.document()); + // The next try joins in the same tab, as the same client, and everything goes on top. room.s(0).execute("collab.reconnect", &json!({})).unwrap(); - room.welcome_anew(0); + let resumed = room.people[0].script.lock().unwrap().resumed.expect("as who you were"); + assert_eq!((resumed.client_id, resumed.seq), (room.people[0].id, u64::MAX)); + room.welcome_as(0, Some(resumed)); + room.settle(); + room.check(); let s = room.s(0); assert_eq!(s.live().status, Status::Live); assert!(!s.live().disconnected()); - assert_eq!(s.docs.len(), tabs + 1, "the copy's is the only new tab"); - let (tab, copy) = (s.docs.iter().find(|d| d.uid == uid).unwrap(), s.docs.last().unwrap()); - assert_eq!(s.live().doc_uid, Some(uid)); - assert_ne!(tab.doc.node(r).unwrap().name, "Offline", "the session's tab shows the server's version"); - assert_eq!(copy.doc.name, format!("{} (offline copy)", tab.doc.name)); - assert_eq!(copy.doc.node(r).unwrap().name, "Offline"); - assert_eq!(copy.doc.node(f).unwrap().name, "Offline too"); - assert!(copy.is_dirty(), "closing the copy asks first"); - assert!(s.message.as_deref().is_some_and(|m| m.contains("offline copy"))); + assert_eq!(s.docs.len(), tabs, "no copy: it all went in"); + let doc = room.server.document(); + assert_eq!(doc.node(r).unwrap().name, "Offline"); + assert!(doc.contains(made), "layers you made too"); + assert_eq!(theirs(doc), moved, "and the other edit is still there"); + assert!(room.s(0).message.as_deref().is_some_and(|m| m.contains("is in the file"))); +} + +#[test] +fn the_same_thing_changed_both_ways_keeps_the_others_and_opens_your_version_beside_it() { + let mut room = Room::new(base(), 2, 7); + let (f, r) = frame_and_rect(room.server.document()); + let tabs = room.s(0).docs.len(); + room.disconnect(0); + room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Mine"}})).unwrap(); + room.s(0).execute("node.set", &json!({"id": f, "props": {"name": "Only mine"}})).unwrap(); + room.event(0, Event::Status(Status::Failed { reason: "gone".into() })); + room.s(1).execute("node.set", &json!({"id": r, "props": {"name": "Theirs"}})).unwrap(); + room.settle(); + room.s(0).execute("collab.reconnect", &json!({})).unwrap(); + let resumed = room.people[0].script.lock().unwrap().resumed; + room.welcome_as(0, resumed); + room.settle(); + room.check(); + let doc = room.server.document(); + assert_eq!(doc.node(r).unwrap().name, "Theirs", "what conflicts isn't written over"); + assert_eq!(doc.node(f).unwrap().name, "Only mine", "what doesn't goes in"); + let s = room.s(0); + assert_eq!(s.docs.len(), tabs + 1); + let copy = s.docs.last().unwrap(); + assert!(copy.doc.name.ends_with("(offline copy)") && copy.is_dirty(), "closing the copy asks first"); + assert_eq!(copy.doc.node(r).unwrap().name, "Mine"); +} + +#[test] +fn layers_the_new_client_cant_make_open_beside_the_file() { + let mut room = Room::new(base(), 1, 7); + let tabs = room.s(0).docs.len(); + room.disconnect(0); + let made = room.s(0).execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + let made = NodeId(made["id"].as_u64().unwrap()); + room.event(0, Event::Status(Status::Failed { reason: "gone".into() })); + room.s(0).execute("collab.reconnect", &json!({})).unwrap(); + // The server takes you as someone new. + room.welcome_as(0, None); + room.settle(); + room.check(); + assert!(!room.server.document().contains(made)); + let s = room.s(0); + assert_eq!(s.docs.len(), tabs + 1); + assert!(s.docs.last().unwrap().doc.contains(made), "it isn't lost"); +} + +#[test] +fn a_server_that_forgot_you_keeps_your_offline_edits() { + let mut room = Room::new(base(), 1, 7); + let (_, r) = frame_and_rect(room.server.document()); + room.disconnect(0); + room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Offline"}})).unwrap(); + // The same file under a new session: it knows no one. + room.server = Server::new(room.server.document().clone(), "t", Limits::default()); + room.welcome_as(0, None); + room.settle(); + assert_eq!(room.server.document().node(r).unwrap().name, "Offline"); + assert_eq!(room.s(0).live().status, Status::Live); } #[test] @@ -399,7 +463,8 @@ fn joining_again_with_nothing_changed_opens_no_copy() { assert!(room.s(0).live().rejoinable(), "the end of a session you were in shows on its tab too"); assert_eq!(room.s(0).message.as_deref(), Some("Sharing was stopped")); room.s(0).execute("collab.reconnect", &json!({})).unwrap(); - room.welcome_anew(0); + let resumed = room.people[0].script.lock().unwrap().resumed; + room.welcome_as(0, resumed); assert_eq!(room.s(0).docs.len(), tabs); assert_eq!(room.s(0).live().status, Status::Live); } @@ -414,7 +479,8 @@ fn a_session_out_of_step_joins_again_by_itself() { assert_eq!(room.s(0).live().status, Status::Connecting); assert!(room.s(0).live().disconnected()); assert_eq!(room.people[0].script.lock().unwrap().joined.len(), joins + 1); - room.welcome_anew(0); + let resumed = room.people[0].script.lock().unwrap().resumed; + room.welcome_as(0, resumed); assert_eq!(room.s(0).live().status, Status::Live); assert_eq!(room.s(0).docs.len(), tabs); } diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index 0b2b7c993..1e2f22f4f 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -18,6 +18,8 @@ pub(crate) struct Script { pub(crate) sent: Vec, pub(crate) hosted: Vec, pub(crate) joined: Vec, + /// What the last join asked to resume as. + pub(crate) resumed: Option, /// The sessions told to reconnect now, in order. pub(crate) retried: Vec, pub(crate) left: usize, @@ -48,6 +50,11 @@ impl LiveLink for Mock { i.url = format!("ws://{}/?file=f&token=t", i.address); Ok(i) } + fn join_resuming(&mut self, session: u64, link: &str, name: &str, resume: Option) -> Result<(), String> { + self.0.lock().unwrap().resumed = resume; + self.join(session, link, name) + } + fn retry(&mut self, session: u64) { self.0.lock().unwrap().retried.push(session); } diff --git a/crates/engine/src/sharing/live.rs b/crates/engine/src/sharing/live.rs index b54331520..ef0a7f0ba 100644 --- a/crates/engine/src/sharing/live.rs +++ b/crates/engine/src/sharing/live.rs @@ -152,6 +152,8 @@ struct Stranded { link: String, /// The server's state when it stopped: what's different from it in the tab is your own work. confirmed: Box, + /// Who you were: the layers you made are in that client's id space. + resume: Resume, } impl Live { @@ -170,17 +172,19 @@ impl Live { *self = Live { id: self.id, tab: self.tab, ..Live::default() }; } - /// Keep this session on its tab for joining again (see [`Stranded`]): a session you joined, or - /// one that already is; any other goes back to no session. - fn strand(&mut self) { + /// Remember what joining this session again takes, if it is one you joined (see [`Stranded`]). + fn hold(&mut self) { let joined = self.info.as_ref().filter(|i| i.role == Role::Guest && !i.link.is_empty()); - let held = self.stranded.take().or_else(|| { - let (client, info) = (self.client.as_ref()?, joined?); - Some(Stranded { link: info.link.clone(), confirmed: Box::new(client.confirmed().clone()) }) - }); - if held.is_none() { - return self.reset(); + if let (None, Some(client), Some(info)) = (&self.stranded, &self.client, joined) { + self.stranded = Some(Stranded { link: info.link.clone(), confirmed: Box::new(client.confirmed().clone()), resume: client.resume() }); } + } + + /// Keep this session on its tab for joining again: a session you joined, or one that already + /// is; any other goes back to no session. + fn strand(&mut self) { + self.hold(); + let Some(held) = self.stranded.take() else { return self.reset() }; *self = Live { id: self.id, tab: self.tab, @@ -189,7 +193,7 @@ impl Live { server: std::mem::take(&mut self.server), published: self.published.take(), share: self.share.take(), - stranded: held, + stranded: Some(held), ..Live::default() }; } diff --git a/crates/engine/src/sharing/live/events.rs b/crates/engine/src/sharing/live/events.rs index 5bc94c0f4..86473ea52 100644 --- a/crates/engine/src/sharing/live/events.rs +++ b/crates/engine/src/sharing/live/events.rs @@ -1,8 +1,11 @@ use super::host::{clean_hosting, clean_shared}; +use figcraft_collab::Change; +use figcraft_collab::merge::{self, Side}; + use super::types::{Event, MAX_PEERS, PageInfo, Role, SessionId, SessionInfo, Status}; use super::{ - Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal, - ToolId, display_name, editing_hint, editing_of, limited_by, sanitize, tool_left, + Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, Resume, ServerMsg, Session, + Signal, ToolId, display_name, editing_hint, editing_of, limited_by, sanitize, tool_left, }; /// What a live session's document holding components that contain each other's instances shows @@ -371,11 +374,12 @@ impl Session { /// Session `id`'s welcome: start the sync client; the host keeps editing the document it /// shared, a guest opens the host's file as a new tab (or gets it again in that tab after a /// reconnect). - fn live_welcome(&mut self, id: SessionId, welcome: ServerMsg) { - let client = match Client::join(welcome) { + pub(super) fn live_welcome(&mut self, id: SessionId, welcome: ServerMsg) { + let mut client = match Client::join(welcome) { Ok(c) => c, Err(e) => return self.live_fail(id, format!("Couldn't open the shared file: {e}")), }; + let mut merged = None; let Some(live) = self.live.sessions.get(&id) else { return }; let existing = live.doc_uid.filter(|u| self.docs.iter().any(|d| d.uid == *u)); let (uid, synced) = match (live.role(), existing) { @@ -396,7 +400,7 @@ impl Session { } (Some(Role::Guest), Some(uid)) => { // Joining again in this tab, or the file you published arriving from the server. - self.live_keep_offline_work(id, uid); + merged = self.live_merge_offline_work(id, uid, &mut client); let doc = Arc::new(client.document().clone()); let was_dirty = self.docs.iter().find(|d| d.uid == uid).is_some_and(DocState::is_dirty); self.apply_remote(uid, doc.clone()); @@ -444,31 +448,50 @@ impl Session { live.client = Some(client); live.deferred.clear(); live.stranded = None; + self.live.send(id, merged); } /// Session `id` is back on document `uid` after it couldn't go on, and the server's version is - /// about to replace the tab's: what you did since opens beside it, unsaved, so nothing is lost. - fn live_keep_offline_work(&mut self, id: SessionId, uid: u64) { - let Some(held) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()) else { return }; - let Some(local) = self.docs.iter().find(|d| d.uid == uid).map(|d| d.doc.clone()) else { return }; + /// about to replace the tab's: what you did since goes on top of it as one edit of `client`, + /// returned to send, the way two people's edits merge. What can't (the same thing changed both + /// ways, or layers `client` can't make) leaves the server's as it is, and your whole version opens + /// beside it, unsaved, so nothing is lost. + fn live_merge_offline_work(&mut self, id: SessionId, uid: u64, client: &mut Client) -> Option { + let held = self.live.sessions.get(&id)?.stranded.as_ref()?; + let local = self.docs.iter().find(|d| d.uid == uid)?.doc.clone(); let name = local.name.clone(); - // A document that can't be compared is kept too. - if figcraft_collab::diff(&held.confirmed, &local).is_ok_and(|d| d.changes.is_empty()) { + let plan = merge::plan(held.confirmed.document(), &local, client.document()).ok(); + let mine = plan.as_ref().map(|p| p.changes(Side::Branch)); + if mine.is_some_and(<[Change]>::is_empty) { self.message = Some(format!("Reconnected to {name}")); - return; + return None; + } + let me = u64::from(client.id()); + let own = mine.is_some_and(|m| m.iter().all(|c| !matches!(c, Change::Create { id, .. } if id.0 >> 32 != me))); + let merged = plan.as_ref().filter(|_| own).and_then(|p| p.merge(client.document(), Side::Branch, &|_| Some(Side::Main)).ok()); + let sent = merged.and_then(|(mut doc, touched)| { + doc.mark_dirty(touched); + client.edit_with(&doc, Some("Changes made while disconnected")).ok() + }); + if sent.is_some() && plan.is_some_and(|p| p.conflicts.is_empty()) { + self.message = Some(format!("Reconnected to {name}. What you changed while disconnected is in the file")); + return sent.flatten(); } let mut copy = DocState::new((*local).clone(), None); let copy_name = format!("{name} (offline copy)"); Arc::make_mut(&mut copy.doc).name.clone_from(©_name); self.docs.push(copy); - self.message = Some(format!("Reconnected to {name}. What you changed while disconnected is open beside it as “{copy_name}”")); + self.message = Some(format!( + "Reconnected to {name}. Some of what you changed while disconnected couldn't go in: your whole version is open beside it as “{copy_name}”" + )); + sent.flatten() } - /// Join session `id` again in its tab (see [`Live::strand`]), as someone new: a fresh join needs - /// nothing from the connection that was lost. + /// Join session `id` again in its tab (see [`Live::strand`]): nothing is needed from the + /// connection that was lost. pub(super) fn live_rejoin(&mut self, cmd: &str, id: SessionId) -> Result<()> { let name = display_name(&self.prefs); - let Some(link) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()).map(|s| s.link.clone()) else { + let Some((link, resume)) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()).map(|s| (s.link.clone(), s.resume)) else { return Err(crate::bad(cmd, "this file has no live session to join again")); }; self.live.leave(id); @@ -476,7 +499,9 @@ impl Session { return Err(EngineError::Disabled(cmd.to_string(), why)); } let Some(net) = self.live.link() else { return Err(super::unavailable(cmd)) }; - net.join_resuming(id, &link, &name, None).map_err(|e| crate::bad(cmd, e))?; + // As the client you were when the server still knows it, so the layers you made are yours to + // send; the whole document is asked for (no change is "after" this number). + net.join_resuming(id, &link, &name, Some(Resume { seq: u64::MAX, ..resume })).map_err(|e| crate::bad(cmd, e))?; if let Some(live) = self.live.sessions.get_mut(&id) { live.status = Status::Connecting; } diff --git a/crates/engine/src/sharing/live/join.rs b/crates/engine/src/sharing/live/join.rs index 75ade0c80..c1b8d9e22 100644 --- a/crates/engine/src/sharing/live/join.rs +++ b/crates/engine/src/sharing/live/join.rs @@ -213,7 +213,7 @@ pub fn specs() -> Vec { spec!( "collab.reconnect", "Reconnect live session", - "{} → {status}: for the open file's session when it is disconnected: a reconnect that is waiting tries now, and a session that couldn't go on (`failed`, the file open in its tab) is joined again there, what you changed since opening beside it. Refused when nothing was lost", + "{} → {status}: for the open file's session when it is disconnected: a reconnect that is waiting tries now, and a session that couldn't go on (`failed`, the file open in its tab) is joined again there, what you changed since going on top (your whole version opening beside it when some of it can't). Refused when nothing was lost", false, reconnect ), diff --git a/crates/engine/src/sharing/live/sync.rs b/crates/engine/src/sharing/live/sync.rs index 0930151dc..576ee501b 100644 --- a/crates/engine/src/sharing/live/sync.rs +++ b/crates/engine/src/sharing/live/sync.rs @@ -1,8 +1,8 @@ use super::host::hosting_json; use super::types::{NO_SESSION, SessionId, Share, SharedLink, Status}; use super::{ - Access, Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, Session, Value, ViewState, bad, busy, close, code, - editing_of, json, + Access, Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, ServerMsg, Session, Value, ViewState, bad, busy, + close, code, editing_of, json, }; impl Session { @@ -115,7 +115,23 @@ impl Session { let Some(live) = self.live.sessions.get_mut(&id) else { return }; let Some(client) = live.client.as_mut() else { return }; let mut out = Vec::new(); - for m in std::mem::take(&mut live.deferred) { + let mut waiting = std::mem::take(&mut live.deferred).into_iter(); + while let Some(m) = waiting.next() { + // The server no longer knows this client (it restarted without its saved state, say): + // it welcomed you as someone new. What you did goes on top like after any rejoin. + if let ServerMsg::Welcome { session, client_id, .. } = &m { + let me = client.resume(); + if (*session, *client_id) != (me.session, me.client_id) { + live.hold(); + live.client = None; + let rest: Vec<_> = waiting.collect(); + self.live_welcome(id, m); + if let Some(live) = self.live.sessions.get_mut(&id) { + live.deferred = rest; + } + return; + } + } match client.receive(m, &EngineRecompute) { Ok(more) => out.extend(more), Err(e) => { diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 9852a1686..79ea13763 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -546,10 +546,13 @@ resuming with its offline edits, and leaving the session going back to snapshot - Disconnected: while it reconnects, and after it can't go on, the tab shows a red mark and a strip across the top of the canvas says so, with **Retry now** (`collab.reconnect`). The tab can still be edited. A session you joined that ended stays on its tab with **Try again**, **Save a copy…** - and **Dismiss** (`collab.leave`). **Try again** joins it in the same tab as someone new; if the - tab changed since it lost the server, what changed opens beside it as an unsaved - "offline copy" and the tab shows the server's version. A session that gets out of step does the - same by itself. A reconnect resumes as the same + and **Dismiss** (`collab.leave`). **Try again** joins it in the same tab, as the same client when + the server still knows it, and what changed since it lost the server goes on top as one undoable + edit, through the three-way merge of `crates/collab/src/merge.rs`. The same thing changed both + ways keeps the other person's, and layers a new client can't make aren't sent; in those cases + your whole version also opens beside it as an unsaved "offline copy", so nothing is lost. A + session that gets out of step, or a server that no longer knows you, does the same by itself. A + reconnect resumes as the same client: the guest's connection keeps the session id, client id and last sequence number from the messages it read and sends them in its next `Hello` (see Offline and reconnect). - The browser build joins and publishes to a server (see *Built (the browser client)*); hosting From b28edd3c7a904c1653e5857cc54345d84ff2f2ad Mon Sep 17 00:00:00 2001 From: fluid-chey Date: Wed, 4 Nov 2026 12:49:13 -0700 Subject: [PATCH 16/16] M9: record the offline merge in the collaboration status Co-Authored-By: Claude Opus 5.5 --- docs/collaboration-parity.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 6c4aa9340..ff13dce70 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (M9: a joined live session reconnects until you leave and says when it is disconnected, with a red tab mark and a strip across the canvas; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); `figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (M9: a joined live session reconnects until you leave and says when it is disconnected, and joining it again merges what you did while disconnected; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); `figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -12,7 +12,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Feature | Figma | FigCraft | Hours | |---|---|---|---| -| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, a live connection reconnecting until you leave), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | +| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, a live connection reconnecting until you leave, and rejoining with offline edits merged), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. No accounts, teams or projects | 8–17 | @@ -26,7 +26,8 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| -| 2026-11-04 | minor | M9: a session you joined no longer turns into a local file by itself. A dropped connection reconnects until you leave (a minute's pause after each 12 attempts, a pause Retry now skips); only refusals that are for good stop it; queued messages go out paced. While disconnected the tab shows a red mark and a strip across the canvas says so; a session that ended stays on its tab with Try again, Save a copy and Dismiss, and what changed offline opens beside it when it joins again | +| 2026-11-04 | minor | M9: a session you joined no longer turns into a local file by itself. A dropped connection reconnects until you leave (a minute's pause after each 12 attempts, a pause Retry now skips); only refusals that are for good stop it; queued messages go out paced. While disconnected the tab shows a red mark and a strip across the canvas says so; a session that ended stays on its tab with Try again, Save a copy and Dismiss | +| 2026-11-04 | minor | M9: joining a session again merges what you did while disconnected as one undoable edit, through the three-way merge branches use, as the same client when the server still knows it. What conflicts keeps the other person's, and your whole version opens beside it as an unsaved copy. A server that no longer knows you merges the same way instead of discarding unconfirmed work | | 2026-10-31 | patch | M9, #1665: the document in a live session's welcome goes compressed (deflate) to clients whose hello announces `packed`, about seven times smaller on the wire (a synthetic file of 4,040 layers: 397 KB to 53 KB); older clients keep getting it uncompressed, and the client reads a packed document only up to the size an uncompressed welcome could have had. | | 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`#join=…`). A browser build served beside its server names it with ``, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged | | 2026-10-30 | minor | M9, #1130: `figcraft-server` compares the SHA-256 digests of the presented and the kept server key instead of the raw strings, as `docs/server.md` says; the key file, `figcraft-server key` and rotation are unchanged. Numbers unchanged. |