diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 5e27c572..f50c5edc 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1272,6 +1272,19 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result> { map.insert(id, doc.alloc_id()); } } + // Library copies a .fig import keeps (cached definitions) have no library record to follow: + // the layers of its store come along into the store of `doc`. Recorded copies are left to + // `merge_store`, which shares them with copies `doc` already has. + let cached: Vec = other + .library_store() + .and_then(|s| other.node(s)) + .map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect()) + .unwrap_or_default(); + for root in &cached { + for id in other.subtree(*root) { + map.insert(id, doc.alloc_id()); + } + } let mut new_pages = Vec::new(); for pg in &other.pages { let Some(pn) = other.node(*pg) else { continue }; @@ -1289,6 +1302,13 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result> { } new_pages.push(np); } + if !cached.is_empty() { + let store = doc.ensure_library_store(); + for root in &cached { + let nodes: Vec = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, *root, store, None)?; + } + } for new in map.values() { let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied()); if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) { @@ -1354,6 +1374,70 @@ mod rest_import_tests { doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap() } + /// What a .fig import with a cached library component leaves: the main in the hidden store, a + /// visible instance of it and a layer linked to the main's layer. + fn cached_import() -> Document { + let mut doc = Document::new("Cached"); + let page = doc.first_page().unwrap(); + let store = doc.ensure_library_store(); + let main = doc + .create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0) + .unwrap(); + let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap(); + let inst = + doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap(); + let mut copy = doc.node(body).unwrap().clone(); + copy.id = doc.alloc_id(); + copy.parent = None; + copy.source = Some(body); + let copy_id = copy.id; + doc.insert(copy, inst, None).unwrap(); + assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body)); + doc + } + + /// Pasting layers that use a cached main into another document leaves the main behind, as it + /// does for other mains that are not published: no link points at a layer of the target. + #[test] + fn pasting_instances_of_cached_components_leaves_no_dangling_links() { + let src = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id; + let ids = src.subtree(inst); + let map: std::collections::BTreeMap = ids.iter().map(|i| (*i, doc.alloc_id())).collect(); + let nodes: Vec = ids.iter().filter_map(|i| src.node(*i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, inst, page, None).unwrap(); + crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap(); + let pasted = doc.node(map[&inst]).unwrap(); + let NodeKind::Instance { component, .. } = pasted.kind else { panic!() }; + assert!(!doc.contains(component), "the main stays behind: {component:?}"); + assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s))); + } + + #[test] + fn appending_pages_with_cached_library_components_brings_the_components() { + let other = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + // Ids of the target that would collide with the other document's store. + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + append_pages(&mut doc, &other).unwrap(); + assert!(doc.validate().is_empty(), "{:?}", doc.validate()); + assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc)); + let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap(); + let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; + assert_eq!(doc.node(component).unwrap().name, "Cached main"); + assert!(doc.is_remote(component), "the main comes along into the store"); + let layer = doc.node(inst.children[0]).unwrap(); + assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied()); + } + #[test] fn rest_json_loads_with_linked_instances() { let bytes = serde_json::to_vec(&kit()).unwrap(); diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index da0b2d13..30f21b8c 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -1710,6 +1710,165 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti None } +/// Bounded source and parent lookups while a rebase reads an unchanged document. +struct RebaseSources<'a> { + doc: &'a Document, + chains: BTreeMap>>, + parents: BTreeMap<(NodeId, NodeId), Option>, + main_changes: BTreeMap, +} + +impl<'a> RebaseSources<'a> { + const PARENTS: usize = 512; + const CACHE: usize = 8192; + + fn new(doc: &'a Document) -> Self { + Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() } + } + + fn chain(&mut self, source: NodeId) -> Option> { + if let Some(chain) = self.chains.get(&source) { + return chain.clone(); + } + let read = || { + let mut chain = Vec::new(); + let mut seen = BTreeSet::new(); + let mut current = Some(source); + for _ in 0..MAX_DEPTH { + let Some(id) = current else { return Some(chain) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + chain.push(id); + current = n.source; + } + current.is_none().then_some(chain) + }; + let chain = read(); + if self.chains.len() < Self::CACHE { + self.chains.insert(source, chain.clone()); + } + chain + } + + /// Validate the complete parent chain, even if `main` was found before a cycle. + fn in_main(&mut self, id: NodeId, main: NodeId) -> Option { + if let Some(found) = self.parents.get(&(id, main)) { + return *found; + } + let read = || { + let mut current = Some(id); + let mut seen = BTreeSet::new(); + let mut found = false; + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(found) }; + if !seen.insert(id) { + return None; + } + found |= id == main; + current = self.doc.node(id)?.parent; + } + current.is_none().then_some(found) + }; + let found = read(); + if self.parents.len() < Self::CACHE { + self.parents.insert((id, main), found); + } + found + } + + fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option { + let chain = self.chain(source)?; + if chain.contains(&target) { + return None; + } + let mut member = None; + for id in chain { + if self.in_main(id, main)? && id != main && member.is_none() { + member = Some(id); + } + } + member + } + + /// Nearest enclosing instances and their current mains. Slots with own content end scope. + fn owners(&self, id: NodeId) -> Option> { + let mut owners = Vec::new(); + let mut current = self.doc.node(id)?.parent; + let mut seen = BTreeSet::from([id]); + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(owners) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + if holds_content(n) { + return Some(owners); + } + if let NodeKind::Instance { component, .. } = &n.kind { + owners.push((id, *component)); + } + current = n.parent; + } + current.is_none().then_some(owners) + } + + fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main) + } + + /// A contextual child belongs to the same placed slot as its enclosing instance. + fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + let Some((owner, main)) = owners.first() else { return false }; + if self.member(source, *main, target).is_none() { + return false; + } + if self.in_main(source, *main) == Some(true) { + return true; + } + let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false }; + matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main) + && self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner) + } + + fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option { + let source = self.doc.node(id)?.source?; + if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame) + || !self.valid(source, id, owners) + || !self.contextual(source, owners) + { + return None; + } + Some(source) + } + + fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool { + for (index, (id, main)) in owners.iter().enumerate() { + if *id == stop { + break; + } + let changed = if let Some(changed) = self.main_changes.get(id) { + *changed + } else { + let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => component != main, + Some(NodeKind::Frame) => true, + _ => false, + }); + if self.main_changes.len() < Self::CACHE { + self.main_changes.insert(*id, changed); + } + changed + }; + if changed { + return true; + } + } + false + } +} + /// Re-derive `source` links and overrides of instance `inst` against its own main by comparing /// values (after detaching an outer instance, restoring a main, or copying a nested instance out): /// the look is kept exactly. Values a component property sets aren't overrides. @@ -1722,22 +1881,28 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect() }; let mut updates = Vec::new(); + let mut sources = RebaseSources::new(doc); + let root_owners = sources.owners(inst).unwrap_or_default(); + let placed_source = sources.placed(inst, &root_owners); + let mut contexts = vec![(inst, component)]; + contexts.extend_from_slice(&root_owners); for (id, path) in &mine { let Some(n) = doc.node(*id) else { continue }; - // Follow the source chain into the main first (exact), then fall back to the name path. - let mut main = None; - let mut cur = n.source; - for _ in 0..MAX_DEPTH { - let Some(c) = cur else { break }; - if c != component && doc.contains(c) && doc.is_ancestor(component, c) { - main = Some(c); - break; - } - cur = doc.node(c).and_then(|x| x.source); + let owners = sources.owners(*id).unwrap_or_default(); + let nearest_main = owners.first().map(|(_, main)| *main); + let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners)); + let mut main = valid_source.and_then(|source| sources.member(source, component, *id)); + // A swapped or Frame-to-Instance subtree is compared to its current main by the inner + // rebase. An outer name path must not rebind those children to the old expanded copies. + if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) { + updates.push((*id, valid_source, n.overrides.clone())); + continue; } - let main = main.or_else(|| theirs.get(path).copied()); + main = main + .or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some()))); + let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main); let mut overrides: BTreeSet = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect(); - if let Some(m) = main.and_then(|m| doc.node(m)) { + if let Some(m) = source.and_then(|source| doc.node(source)) { let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect(); bound.extend(variable_groups(n, m)); overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str()))); @@ -1745,36 +1910,51 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { overrides.insert("component".into()); } } - updates.push((*id, main, overrides)); + updates.push((*id, source, overrides)); } - for (id, main, overrides) in updates { - if let Some(n) = doc.node_mut(id) { - n.source = main; - n.overrides = if main.is_some() { overrides } else { BTreeSet::new() }; - } - } - let root_groups = match (doc.node(component), doc.node(inst)) { + // A placed nested root compares to its actual slot. A detached root compares to its own main. + let root_base = placed_source.or_else(|| { + let chain = sources.chain(component)?; + (!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component) + }); + let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) { (Some(m), Some(n)) => { let from_variables = variable_groups(n, m); let mut groups: Vec = value_groups(m, n) .into_iter() .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) .collect(); - // A layer name of its own (a nested instance renamed in its old main) stays. - if n.name != default_instance_name(doc, component) { + let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; + if named { groups.push("name".into()); } + if placed_source.is_some() + && matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) + { + groups.push("component".into()); + } groups } _ => Vec::new(), }; + drop(sources); + for (id, main, overrides) in updates { + if let Some(n) = doc.node_mut(id) { + n.source = main; + n.overrides = if main.is_some() { overrides } else { BTreeSet::new() }; + } + } if let Some(n) = doc.node_mut(inst) { - n.source = Some(component); + n.source = root_base; n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position")); n.overrides.extend(root_groups); } } +#[cfg(test)] +#[path = "components_rebase_source_tests.rs"] +mod rebase_source_tests; + /// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then /// the left-most). pub fn default_variant(doc: &Document, set: NodeId) -> Option { diff --git a/crates/engine/src/components_rebase_source_tests.rs b/crates/engine/src/components_rebase_source_tests.rs new file mode 100644 index 00000000..b272b82f --- /dev/null +++ b/crates/engine/src/components_rebase_source_tests.rs @@ -0,0 +1,244 @@ +//! Contextual import sources survive rebasing; detached and swapped subtrees use their current mains. + +use super::*; + +fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str) -> NodeId { + doc.create(parent, kind, name, 0.0, 0.0, 20.0, 20.0).unwrap() +} + +fn component(doc: &mut Document, parent: NodeId, name: &str) -> NodeId { + add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name) +} + +fn mirror(doc: &mut Document, source: NodeId, parent: NodeId) -> NodeId { + let mut n = doc.node(source).unwrap().clone(); + let children = std::mem::take(&mut n.children); + n.id = doc.alloc_id(); + n.parent = None; + n.source = Some(source); + let id = n.id; + doc.insert(n, parent, None).unwrap(); + for child in children { + mirror(doc, child, id); + } + id +} + +fn instance(doc: &mut Document, main: NodeId, parent: NodeId, name: &str) -> NodeId { + let mut n = doc.node(main).unwrap().clone(); + let children = std::mem::take(&mut n.children); + n.id = doc.alloc_id(); + n.kind = NodeKind::Instance { component: main, props: BTreeMap::new() }; + n.name = name.into(); + n.parent = None; + n.source = None; + let id = n.id; + doc.insert(n, parent, None).unwrap(); + for child in children { + mirror(doc, child, id); + } + id +} + +struct Scene { + doc: Document, + inner: NodeId, + base: NodeId, + slot: NodeId, + context: NodeId, + root: NodeId, + nested: NodeId, + child: NodeId, +} + +fn scene() -> Scene { + let mut doc = Document::new("Source context"); + let page = doc.first_page().unwrap(); + let inner = component(&mut doc, page, "Inner"); + let base = add(&mut doc, inner, NodeKind::Rectangle, "Body"); + doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0)); + let outer = component(&mut doc, page, "Outer"); + let slot = instance(&mut doc, inner, outer, "Nested"); + doc.node_mut(slot).unwrap().source = Some(inner); + let context = doc.node(slot).unwrap().children[0]; + doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0)); + doc.node_mut(context).unwrap().opacity = 0.3; + let root = instance(&mut doc, outer, page, "Outer copy"); + let nested = doc.node(root).unwrap().children[0]; + let child = doc.node(nested).unwrap().children[0]; + doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0)); + doc.node_mut(child).unwrap().opacity = 0.2; + Scene { doc, inner, base, slot, context, root, nested, child } +} + +fn both(s: &mut Scene) { + rebase_instance(&mut s.doc, s.nested); + rebase_instance(&mut s.doc, s.root); +} + +#[test] +fn rebase_default_only_sources_promote_to_the_actual_outer_context() { + let mut s = scene(); + s.doc.node_mut(s.nested).unwrap().source = None; + s.doc.node_mut(s.child).unwrap().source = None; + both(&mut s); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); +} + +#[test] +fn rebase_rejects_a_context_source_from_another_nested_slot() { + let mut s = scene(); + let outer = s.doc.node(s.slot).unwrap().parent.unwrap(); + let sibling = instance(&mut s.doc, s.inner, outer, "Other nested slot"); + s.doc.node_mut(sibling).unwrap().source = Some(s.inner); + let sibling_child = s.doc.node(sibling).unwrap().children[0]; + mirror(&mut s.doc, sibling, s.root); + s.doc.node_mut(s.child).unwrap().source = Some(sibling_child); + both(&mut s); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); + assert_eq!(s.doc.node(sibling_child).unwrap().transform.translation().x, 2.0); + assert_eq!(s.doc.node(s.context).unwrap().transform.translation().x, 3.0); +} + +#[test] +fn rebase_does_not_mark_inherited_context_values_as_local_overrides() { + let mut s = scene(); + s.doc.node_mut(s.child).unwrap().opacity = 0.3; + both(&mut s); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); + assert!(!s.doc.node(s.child).unwrap().overrides.contains("opacity")); +} + +#[test] +fn rebase_preserves_multilevel_context_without_shortening_source_chains() { + let mut doc = Document::new("Nested source contexts"); + let page = doc.first_page().unwrap(); + let inner = component(&mut doc, page, "Inner"); + let base = add(&mut doc, inner, NodeKind::Rectangle, "Body"); + doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0)); + let wrapper = component(&mut doc, page, "Wrapper"); + let default_slot = instance(&mut doc, inner, wrapper, "Nested"); + doc.node_mut(default_slot).unwrap().source = Some(inner); + let outer = component(&mut doc, page, "Outer"); + let wrapper_slot = instance(&mut doc, wrapper, outer, "Wrapper slot"); + doc.node_mut(wrapper_slot).unwrap().source = Some(wrapper); + let source_slot = doc.node(wrapper_slot).unwrap().children[0]; + let context = doc.node(source_slot).unwrap().children[0]; + doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0)); + let root = instance(&mut doc, outer, page, "Outer copy"); + let wrapper_copy = doc.node(root).unwrap().children[0]; + let nested = doc.node(wrapper_copy).unwrap().children[0]; + let child = doc.node(nested).unwrap().children[0]; + doc.node_mut(nested).unwrap().name = "Renamed nested".into(); + doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0)); + for id in [nested, wrapper_copy, root] { + rebase_instance(&mut doc, id); + assert_eq!(doc.node(nested).unwrap().source, Some(source_slot)); + assert_eq!(doc.node(child).unwrap().source, Some(context)); + } + assert_eq!(doc.node(context).unwrap().source, Some(doc.node(default_slot).unwrap().children[0])); + assert_eq!(doc.node(context).unwrap().transform.translation().x, 3.0); + assert_eq!(doc.node(child).unwrap().transform.translation().x, 2.0); +} + +#[test] +fn rebase_preserves_placed_frame_to_instance_and_current_main_children() { + let mut doc = Document::new("Typed nested source"); + let page = doc.first_page().unwrap(); + let inner = component(&mut doc, page, "Inner"); + let body = add(&mut doc, inner, NodeKind::Rectangle, "Body"); + let outer = component(&mut doc, page, "Outer"); + let slot = add(&mut doc, outer, NodeKind::Frame, "Empty slot"); + let root = instance(&mut doc, outer, page, "Outer copy"); + let nested = doc.node(root).unwrap().children[0]; + doc.node_mut(nested).unwrap().kind = NodeKind::Instance { component: inner, props: BTreeMap::new() }; + let child = mirror(&mut doc, body, nested); + rebase_instance(&mut doc, nested); + rebase_instance(&mut doc, root); + assert_eq!(doc.node(nested).unwrap().source, Some(slot)); + assert_eq!(doc.node(child).unwrap().source, Some(body)); + assert!(matches!(doc.node(nested).unwrap().kind, NodeKind::Instance { component, .. } if component == inner)); +} + +#[test] +fn rebase_keeps_current_main_sources_and_component_delta_beneath_swap() { + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let replacement = component(&mut s.doc, page, "Replacement"); + let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body"); + s.doc.node_mut(body).unwrap().transform = Affine::translate((7.0, 0.0)); + s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() }; + s.doc.remove(s.child).unwrap(); + s.child = mirror(&mut s.doc, body, s.nested); + s.doc.node_mut(s.child).unwrap().transform = Affine::translate((8.0, 0.0)); + s.doc.node_mut(s.nested).unwrap().name = "Renamed swap".into(); + rebase_instance(&mut s.doc, s.nested); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert!(s.doc.node(s.nested).unwrap().overrides.contains("component")); + rebase_instance(&mut s.doc, s.root); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(body)); + assert!(s.doc.node(s.nested).unwrap().overrides.contains("component")); + assert_eq!(s.doc.node(s.child).unwrap().transform.translation().x, 8.0); +} + +#[test] +fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() { + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let detached = mirror(&mut s.doc, s.nested, page); + let child = s.doc.node(detached).unwrap().children[0]; + // The old source references still exist, but their Outer context is absent here. + s.doc.node_mut(detached).unwrap().source = Some(s.slot); + s.doc.node_mut(child).unwrap().source = Some(s.context); + s.doc.node_mut(child).unwrap().transform = Affine::translate((3.0, 0.0)); + rebase_instance(&mut s.doc, detached); + assert_eq!(s.doc.node(detached).unwrap().source, Some(s.inner)); + assert_eq!(s.doc.node(child).unwrap().source, Some(s.base)); + assert_eq!(s.doc.node(child).unwrap().transform.translation().x, 3.0); + assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0); +} + +#[test] +fn rebase_does_not_preserve_existing_foreign_or_stale_sources() { + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let foreign = component(&mut s.doc, page, "Foreign"); + let foreign_body = add(&mut s.doc, foreign, NodeKind::Rectangle, "Body"); + s.doc.node_mut(s.child).unwrap().source = Some(foreign_body); + both(&mut s); + assert_ne!(s.doc.node(s.child).unwrap().source, Some(foreign_body)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let replacement = component(&mut s.doc, page, "Replacement"); + let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body"); + s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() }; + s.doc.node_mut(s.child).unwrap().source = Some(s.context); + both(&mut s); + assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context), "the old default-main child is stale after a swap"); + assert!(s.doc.node(s.child).unwrap().source.is_none() || s.doc.node(s.child).unwrap().source == Some(body)); +} + +#[test] +fn rebase_rejects_source_and_parent_cycles_with_bounded_lookups() { + let mut s = scene(); + s.doc.node_mut(s.base).unwrap().source = Some(s.context); + both(&mut s); + assert_eq!(s.doc.node(s.child).unwrap().source, None); + let mut s = scene(); + s.doc.node_mut(s.context).unwrap().parent = Some(s.context); + both(&mut s); + assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context)); + let mut s = scene(); + s.doc.node_mut(s.nested).unwrap().parent = Some(s.child); + s.doc.node_mut(s.child).unwrap().parent = Some(s.nested); + let sources = RebaseSources::new(&s.doc); + assert!(sources.owners(s.child).is_none()); + let mut s = scene(); + s.doc.node_mut(s.root).unwrap().kind = NodeKind::Instance { component: s.root, props: BTreeMap::new() }; + rebase_instance(&mut s.doc, s.root); + assert_ne!(s.doc.node(s.root).unwrap().source, Some(s.root)); +} diff --git a/crates/engine/src/fig_import_tests.rs b/crates/engine/src/fig_import_tests.rs index d6241448..f0ea0886 100644 --- a/crates/engine/src/fig_import_tests.rs +++ b/crates/engine/src/fig_import_tests.rs @@ -50,3 +50,43 @@ fn local_samples_match_their_exports() { } } } + +/// Layers of an instance copy keep their link to the layer they stand for when the file is +/// opened, also after they were renamed in the copy (a name is no identity), and the override +/// that a rename is stays an override. +#[test] +fn opened_fig_links_instance_layers_by_identity_not_by_name() { + use base64::Engine as _; + let mut s = Session::new(); + let make = |s: &mut Session, cmd: &str, p: serde_json::Value| s.execute(cmd, &p).unwrap()["id"].as_u64().map(figcraft_doc::NodeId).unwrap(); + let body = make(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Inner"})); + let label = make(&mut s, "text.create", json!({"text": "Label", "x": 2, "y": 2, "parent": body, "props": {"name": "Label"}})); + let inner = make(&mut s, "component.create", json!({"ids": [body]})); + let frame = make(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 200, "height": 60, "name": "Outer"})); + let outer = make(&mut s, "component.create", json!({"ids": [frame]})); + let slot = make(&mut s, "instance.create", json!({"component": inner, "x": 0, "y": 0, "parent": outer})); + s.execute("node.set", &json!({"id": slot, "props": {"name": "Slot"}})).unwrap(); + let copy = make(&mut s, "instance.create", json!({"component": outer, "x": 0, "y": 200})); + let copy_slot = s.doc().unwrap().node(copy).unwrap().children[0]; + let copy_label = s.doc().unwrap().node(copy_slot).unwrap().children[0]; + s.execute("node.set", &json!({"id": copy_slot, "props": {"name": "Renamed slot"}})).unwrap(); + s.execute("node.set", &json!({"id": copy_label, "props": {"name": "Renamed label"}})).unwrap(); + let _ = label; + + let exported = s.execute("file.export", &json!({"format": "fig"})).unwrap(); + let path = std::env::temp_dir().join(format!("figcraft-slots-{}.fig", std::process::id())); + std::fs::write(&path, base64::engine::general_purpose::STANDARD.decode(exported["data"].as_str().unwrap()).unwrap()).unwrap(); + let mut opened = Session::new(); + let result = opened.execute("file.open", &json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + result.unwrap(); + let doc = opened.doc().unwrap(); + let outer = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Component { .. })).unwrap(); + let copy = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Instance { .. })).unwrap(); + let slot = doc.node(copy.children[0]).unwrap(); + let label = doc.node(slot.children[0]).unwrap(); + assert_eq!((slot.name.as_str(), label.name.as_str()), ("Renamed slot", "Renamed label")); + assert_eq!(slot.source, Some(outer.children[0])); + assert_eq!(label.source, Some(doc.node(outer.children[0]).unwrap().children[0])); + assert!(slot.overrides.contains("name") && label.overrides.contains("name")); +} diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 73c599a4..d7bafe82 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -1221,9 +1221,16 @@ pub fn check_read_only(cmd: &str, before: &Document, after: &Document) -> std::r if !variables_unchanged(before, after) { return Err(READ_ONLY.into()); } - let Some(store) = after.library_store().or_else(|| before.library_store()) else { return Ok(()) }; - let touched = |d: &Document, id: NodeId| d.contains(id) && d.page_of(id) == Some(store); - if after.dirty().iter().any(|id| touched(before, *id) || touched(after, *id)) { + let changed_remote = |id| { + if !before.is_remote(id) && !after.is_remote(id) { + return false; + } + match (before.node_arc(id), after.node_arc(id)) { + (Some(old), Some(new)) => !Arc::ptr_eq(old, new), + _ => true, + } + }; + if after.dirty().iter().any(|id| changed_remote(*id)) { return Err(READ_ONLY.into()); } Ok(()) diff --git a/crates/engine/src/library_tests.rs b/crates/engine/src/library_tests.rs index 23d5af09..c229e4e0 100644 --- a/crates/engine/src/library_tests.rs +++ b/crates/engine/src/library_tests.rs @@ -45,6 +45,38 @@ fn path_str(p: &std::path::Path) -> String { p.to_string_lossy().into_owned() } +fn dirty_library_document() -> (Document, NodeId) { + let mut doc = Document::new("Dirty library cache"); + let store = doc.ensure_library_store(); + let remote = doc.alloc_id(); + doc.insert(Node::new(remote, NodeKind::Frame, "Remote", 10.0, 10.0), store, None).unwrap(); + (doc, remote) +} + +/// An imported cache is dirty until the first edit; that alone is no edit of the library copies. +#[test] +fn read_only_allows_unchanged_predirty_remote_nodes() { + let (before, remote) = dirty_library_document(); + assert!(before.dirty().contains(&remote)); + + let mut after = before.clone(); + after.add_page("Unrelated page"); + assert!(after.dirty().contains(&remote), "the imported remote node remains in the pending dirty set"); + check_read_only("page.add", &before, &after).unwrap(); + + let mut edited = before.clone(); + edited.get_mut(remote).unwrap().opacity = 0.5; + assert_eq!(check_read_only("node.set", &before, &edited), Err(READ_ONLY.into())); + + let mut moved = before.clone(); + moved.reparent(remote, before.first_page().unwrap(), None).unwrap(); + assert_eq!(check_read_only("node.reparent", &before, &moved), Err(READ_ONLY.into())); + + let mut deleted = before.clone(); + deleted.remove(remote).unwrap(); + assert_eq!(check_read_only("edit.delete", &before, &deleted), Err(READ_ONLY.into())); +} + /// The library file's layers. struct Kit { button: NodeId, @@ -179,8 +211,16 @@ fn add_place_and_update_keep_overrides() { NodeKind::Instance { component, .. } => component, _ => panic!("not an instance"), }; + let unchanged = s.doc().unwrap().clone(); let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err(); assert!(e.to_string().contains("can't be edited"), "{e}"); + assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back"); + for (cmd, args) in + [("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))] + { + assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies"); + assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back"); + } let style = s.doc().unwrap().styles[0].id.clone(); assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err()); assert!(s.execute("style.delete", &json!({"style": style})).is_err()); @@ -281,10 +321,16 @@ fn library_styles_apply_and_import_with_pages() { let copy = doc.nodes().find(|n| n.name == node(&s, inst).name && matches!(n.kind, NodeKind::Instance { .. }) && !doc.is_remote(n.id)).unwrap(); let NodeKind::Instance { component, .. } = copy.kind else { panic!("not an instance") }; assert!(doc.is_remote(component)); + let store_roots = |s: &Session| { + let doc = s.doc().unwrap(); + doc.library_store().and_then(|id| doc.node(id)).map_or(0, |n| n.children.len()) + }; + assert_eq!(store_roots(&t), 2, "recorded copies are not copied twice"); // A second import shares the copies it already has. ex(&mut t, "file.import", json!({"path": path})); healthy(&t); assert_eq!(t.doc().unwrap().libraries.components.len(), 2); + assert_eq!(store_roots(&t), 2); let _ = std::fs::remove_dir_all(&d); } diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 66a00129..9f9bacab 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -6,7 +6,7 @@ //! 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; +use std::collections::{BTreeMap, BTreeSet}; use std::rc::Rc; use figcraft_doc::geom::{Affine, Point}; @@ -33,6 +33,9 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { let mut m = Mapper::new(msg); let doc = m.document(); let mut out = json!({"name": name, "document": doc}); + if !m.library_contexts.is_empty() { + out[crate::rest::FIG_LIBRARY_CONTEXTS_FIELD] = Value::Array(std::mem::take(&mut m.library_contexts)); + } if !m.styles.is_empty() { out["styles"] = Value::Object(std::mem::take(&mut m.styles)); } @@ -259,6 +262,12 @@ struct Mapper<'a> { /// Descriptions of components and component sets (REST `components` / `componentSets`). components: Obj, sets: Obj, + /// 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, + /// Most layers produced or scanned; the one place the cap comes from. + max_nodes: usize, warnings: Vec, count: usize, } @@ -268,9 +277,19 @@ impl<'a> Mapper<'a> { let mut nodes = BTreeMap::new(); let mut kids: BTreeMap> = BTreeMap::new(); let mut prop_defs = BTreeMap::new(); - for n in msg.get("nodeChanges").map(Kv::list).unwrap_or_default() { + 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; } if let Some(p) = n.get("parentIndex") @@ -285,7 +304,6 @@ impl<'a> Mapper<'a> { prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty)); } } - nodes.insert(id, n); } for list in kids.values_mut() { list.sort(); @@ -302,7 +320,21 @@ impl<'a> Mapper<'a> { 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, assets, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 } + Self { + nodes, + kids, + blobs, + prop_defs, + assets, + styles: Obj::new(), + components: Obj::new(), + sets: Obj::new(), + library_contexts: Vec::new(), + library_scan_count: 0, + max_nodes: MAX_NODES, + warnings: Vec::new(), + count: 0, + } } /// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`, @@ -345,6 +377,8 @@ impl<'a> Mapper<'a> { None => self.nodes.iter().filter(|(_, n)| n.get("type").and_then(Kv::str) == Some("CANVAS")).map(|(id, _)| id.clone()).collect(), }; let mut out = Vec::new(); + let mut internal = Vec::new(); + let mut visible_pages = Vec::new(); for p in pages { let Some(&n) = self.nodes.get(&p) else { continue }; if n.get("type").and_then(Kv::str) != Some("CANVAS") { @@ -353,16 +387,171 @@ impl<'a> Mapper<'a> { } // The hidden page that holds styles, variables and library copies. if n.get("internalOnly").and_then(Kv::bool) == Some(true) { + internal.push((p, n)); continue; } + visible_pages.push(p.clone()); out.push(self.page(&p, n)); } + let required_mains = self.referenced_component_mains(&visible_pages); + for (id, canvas) in internal { + if let Some(context) = self.library_context(&id, canvas, &required_mains) { + self.library_contexts.push(context); + } + } if out.is_empty() { self.warn("the file has no pages"); } json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT", "children": out}) } + /// The ids of the components the visible pages use, directly or through the components they + /// use in turn (nested instances, swap targets, overrides naming another main). + fn referenced_component_mains(&mut self, visible_pages: &[String]) -> BTreeSet { + let mut mains = BTreeSet::new(); + let mut visited = BTreeSet::new(); + let mut pending: Vec<(String, usize)> = visible_pages.iter().flat_map(|page| self.children(page)).map(|id| (id, 1)).collect(); + while let Some((id, depth)) = pending.pop() { + if depth > MAX_DEPTH { + self.warn(format!("component references below {MAX_DEPTH} layer levels were skipped")); + continue; + } + if !visited.insert(id.clone()) { + continue; + } + if visited.len() > self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the component reference scan reached the {max}-layer limit")); + break; + } + let Some(node) = self.nodes.get(&id) else { continue }; + for target in self.component_main_targets(node) { + if mains.insert(target.clone()) { + pending.push((target, 1)); + } + } + pending.extend(self.children(&id).into_iter().map(|child| (child, depth + 1))); + } + mains + } + + /// The mains a layer names: an instance's own, the ones its overrides swap in, the instance-swap + /// defaults and values of component properties. + fn component_main_targets(&self, node: &Kv<'a>) -> Vec { + let mut targets: Vec = node + .get("componentPropDefs") + .map(Kv::list) + .unwrap_or_default() + .iter() + .filter_map(|definition| { + self.is_instance_swap(definition.get("id")?)?; + definition.get("initialValue")?.get("guidValue").and_then(guid) + }) + .collect(); + if node.get("type").and_then(Kv::str) != Some("INSTANCE") { + return targets; + } + // The instance and the override rows below it (rows carry the same fields, nested). + let mut pending: Vec<(&Kv<'a>, usize)> = vec![(node, 0)]; + let mut rows = 0usize; + while let Some((row, depth)) = pending.pop() { + rows += 1; + if depth > MAX_DEPTH || rows > self.max_nodes { + break; + } + let data = row.get("symbolData"); + targets.extend(row.get("overriddenSymbolID").and_then(guid)); + targets.extend(data.and_then(|d| d.get("symbolID")).and_then(guid)); + for assignment in row.get("componentPropAssignments").map(Kv::list).unwrap_or_default() { + if assignment.get("defID").is_some_and(|def| self.is_instance_swap(def).is_some()) { + targets.extend(assignment.get("value").and_then(|v| v.get("guidValue")).and_then(guid)); + } + } + let overrides = data.and_then(|d| d.get("symbolOverrides")).map(Kv::list).unwrap_or_default(); + let derived = row.get("derivedSymbolData").map(Kv::list).unwrap_or_default(); + pending.extend(overrides.iter().chain(derived).map(|row| (row, depth + 1))); + } + targets + } + + fn is_instance_swap(&self, def: &Kv) -> Option<()> { + matches!(self.prop_defs.get(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(()) + } + + /// The branches of the internal canvas `id` that hold the `required_mains` (with the frames + /// they sit in), as a frame of its own: the cached definitions of components that come from a + /// library. `None` when it holds none. + fn library_context(&mut self, id: &str, canvas: &'a Kv<'a>, required_mains: &BTreeSet) -> Option { + let mut included = BTreeSet::new(); + let mut parents: BTreeMap = BTreeMap::new(); + // The canvas itself is never a layer of the walk, whatever the parent links say. + let mut visited = BTreeSet::from([id.to_string()]); + // Each entry: layer, depth, inside a required definition. + let mut pending: Vec<(String, usize, bool)> = self.children(id).into_iter().map(|child| (child, 1, false)).collect(); + while let Some((node_id, depth, in_definition)) = pending.pop() { + if depth > MAX_DEPTH { + self.warn(format!("internal library branches deeper than {MAX_DEPTH} levels were skipped")); + continue; + } + if !visited.insert(node_id.clone()) { + continue; + } + self.library_scan_count += 1; + if self.library_scan_count > self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the internal library scan reached the {max}-layer limit; the remaining branches were skipped")); + break; + } + let Some(node) = self.nodes.get(&node_id) else { continue }; + let kind = node.get("type").and_then(Kv::str); + let definition = (kind == Some("SYMBOL") || (kind == Some("FRAME") && node.get("isStateGroup").and_then(Kv::bool) == Some(true))) + && required_mains.contains(&node_id); + let in_definition = in_definition || definition; + if in_definition { + included.insert(node_id.clone()); + } + if definition { + // The frames around it keep the path its layers are addressed by. + let mut ancestor = parents.get(&node_id); + for _ in 0..=MAX_DEPTH { + let Some(parent) = ancestor else { break }; + if !included.insert(parent.clone()) { + break; + } + ancestor = parents.get(parent); + } + } + for child in self.children(&node_id).into_iter().rev() { + // A layer reached again keeps the parent it was first reached by, so the parents + // lead back to the canvas. + if visited.contains(&child) { + continue; + } + parents.entry(child.clone()).or_insert_with(|| node_id.clone()); + pending.push((child, depth + 1, in_definition)); + } + } + if included.is_empty() { + return None; + } + let mut context = Obj::new(); + context.insert("id".into(), json!(id)); + context.insert("type".into(), json!("FRAME")); + copy_str(&mut context, &View::of(canvas), "name", "name"); + context.insert("size".into(), json!({"x": 0.0, "y": 0.0})); + context.insert("relativeTransform".into(), rows(Affine::IDENTITY)); + context.insert(crate::rest::FIG_SOURCE_FIELD.into(), json!({"origin": id, "expanded": false, "wireFrame": true, "wireInstance": false})); + self.variable_bindings(&mut context, &View::of(canvas)); + let mut children = Vec::new(); + for child in self.children(id) { + children.extend(self.layer_filtered(&child, None, Affine::IDENTITY, None, None, 0, Some(&included))); + } + if !children.is_empty() { + context.insert("children".into(), Value::Array(children)); + } + Some(Value::Object(context)) + } + fn page(&mut self, id: &str, n: &'a Kv<'a>) -> Value { let v = View::of(n); let mut o = Obj::new(); @@ -405,6 +594,24 @@ impl<'a> Mapper<'a> { fit: Option, depth: usize, ) -> Option { + self.layer_filtered(id, inst, space, parent, fit, depth, None) + } + + /// [`Self::layer`], keeping only the layers in `included` (all when `None`) below the + /// top-level one: the cached library definitions of an internal canvas. + fn layer_filtered( + &mut self, + id: &str, + inst: Option<&Inst<'a>>, + space: Affine, + parent: Option<&View<'a>>, + fit: Option, + depth: usize, + included: Option<&BTreeSet>, + ) -> Option { + if included.is_some_and(|ids| !ids.contains(id)) { + return None; + } if depth > MAX_DEPTH { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; @@ -457,6 +664,11 @@ impl<'a> Mapper<'a> { self.count += 1; let mut o = Obj::new(); o.insert("id".into(), json!(rest_id)); + // Private provenance for the .fig importer; ordinary REST import ignores it. + o.insert( + crate::rest::FIG_SOURCE_FIELD.into(), + json!({"origin": id, "expanded": inst.is_some(), "wireFrame": own == "FRAME", "wireInstance": own == "INSTANCE"}), + ); o.insert("type".into(), json!(ty)); copy_str(&mut o, &view, "name", "name"); self.common(&mut o, &view, ty, space, parent); @@ -491,7 +703,8 @@ impl<'a> Mapper<'a> { let main = self.nodes.get(&sym).map(|m| View::of(m)); let fit = Fit::frame(main.and_then(|m| m.size()), view.size()); for c in self.children(&sym) { - kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), fit, depth + 1)); + // The main's branch can live outside the cached context being mapped. + kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None)); } } } else { @@ -503,7 +716,7 @@ impl<'a> Mapper<'a> { (Some(_), _) => Fit::frame(View::of(base).size(), view.size()), }; for c in self.children(id) { - kids.extend(self.layer(&c, inst, child_space, Some(&view), fit, depth + 1)); + kids.extend(self.layer_filtered(&c, inst, child_space, Some(&view), fit, depth + 1, included)); } } if stand.is_some_and(|s| s.0 == "FRAME") { diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index ec2a0e23..24f304a3 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -21,6 +21,8 @@ pub mod kiwi; mod map; mod network; #[cfg(test)] +mod source_identity_tests; +#[cfg(test)] mod tests; use std::io::Read; @@ -64,7 +66,7 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); let (json, mut warnings) = map::to_rest(&msg, file_name); let images = |r: &str| archive.image(r); - let (doc, more) = crate::rest::import(&json, &images)?; + let (doc, more) = crate::rest::import_fig(&json, &images)?; warnings.extend(more.into_iter().filter_map(map::adapt_warning)); Ok((doc, warnings)) } diff --git a/crates/format/src/fig/source_identity_tests.rs b/crates/format/src/fig/source_identity_tests.rs new file mode 100644 index 00000000..955df1da --- /dev/null +++ b/crates/format/src/fig/source_identity_tests.rs @@ -0,0 +1,938 @@ +//! Synthetic .fig source identity: cached library components, contextual sources, main switches +//! and invalid provenance. + +use figcraft_doc::color::PaintKind; +use figcraft_doc::{Document, NodeId, NodeKind, PropDef}; +use serde_json::{Value, json}; + +use super::kiwi::{Kv, Schema}; +use super::*; + +type Fields = Vec<(&'static str, Kv<'static>)>; + +fn o(fields: Fields) -> Kv<'static> { + Kv::Object(fields) +} + +fn g(id: u64) -> Kv<'static> { + o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))]) +} + +fn s(value: &str) -> Kv<'static> { + Kv::Str(value.into()) +} + +fn matrix(x: f32) -> Kv<'static> { + o(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(x)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]) +} + +fn node(id: u64, parent: Option, ty: &'static str, name: &str, more: Fields) -> Kv<'static> { + let mut fields = vec![("guid", g(id)), ("phase", Kv::Enum("CREATED")), ("type", Kv::Enum(ty)), ("name", s(name))]; + if let Some(parent) = parent { + fields.push(("parentIndex", o(vec![("guid", g(parent)), ("position", s(&format!("{id:04}")))]))); + } + fields.extend(more); + o(fields) +} + +fn symbol(main: u64, overrides: Vec>) -> Kv<'static> { + o(vec![("symbolID", g(main)), ("symbolOverrides", Kv::Array(overrides))]) +} + +fn row(path: &[u64], more: Fields) -> Kv<'static> { + let mut fields = vec![("guidPath", o(vec![("guids", Kv::Array(path.iter().map(|id| g(*id)).collect()))]))]; + fields.extend(more); + o(fields) +} + +fn solid(r: f32, g: f32, b: f32) -> Kv<'static> { + o(vec![ + ("type", Kv::Enum("SOLID")), + ("color", o(vec![("r", Kv::Float(r)), ("g", Kv::Float(g)), ("b", Kv::Float(b)), ("a", Kv::Float(1.0))])), + ("opacity", Kv::Float(1.0)), + ("visible", Kv::Bool(true)), + ]) +} + +fn schema() -> Schema { + Schema::parse(export::SCHEMA).unwrap() +} + +fn cached_component_message() -> Kv<'static> { + o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Visible page", vec![]), + node( + 3, + Some(1), + "CANVAS", + "Remote library", + vec![ + ("internalOnly", Kv::Bool(true)), + ( + "variableModeBySetMap", + o(vec![("entries", Kv::Array(vec![o(vec![("variableSetID", o(vec![("guid", g(30))])), ("variableModeID", g(52))])]))]), + ), + ], + ), + node( + 30, + Some(3), + "VARIABLE_SET", + "Remote theme", + vec![( + "variableSetModes", + Kv::Array(vec![ + o(vec![("id", g(51)), ("name", s("Light")), ("sortPosition", s("!"))]), + o(vec![("id", g(52)), ("name", s("Dark")), ("sortPosition", s("\""))]), + ]), + )], + ), + node( + 9, + Some(3), + "FRAME", + "Cached component set", + vec![("isStateGroup", Kv::Bool(true)), ("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), ("transform", matrix(0.0))], + ), + node( + 10, + Some(9), + "SYMBOL", + "Cached remote icon", + vec![("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), ("transform", matrix(0.0))], + ), + node( + 11, + Some(10), + "ROUNDED_RECTANGLE", + "Cached path", + vec![("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), ("transform", matrix(0.0))], + ), + node( + 12, + Some(10), + "INSTANCE", + "Nested cached instance", + vec![ + ("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), + ("transform", matrix(0.0)), + ("symbolData", symbol(22, vec![row(&[23], vec![("fillPaints", Kv::Array(vec![solid(0.2, 0.7, 0.1)]))])])), + ], + ), + node( + 13, + Some(9), + "SYMBOL", + "Nested cached main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 14, + Some(13), + "ROUNDED_RECTANGLE", + "Nested cached path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 15, + Some(10), + "INSTANCE", + "Nested internal instance", + vec![ + ("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), + ("transform", matrix(0.0)), + ("symbolData", symbol(13, vec![])), + ], + ), + node( + 20, + Some(2), + "INSTANCE", + "Visible icon instance", + vec![ + ("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), + ("transform", matrix(0.0)), + ("symbolData", symbol(10, vec![])), + ], + ), + node( + 22, + Some(2), + "SYMBOL", + "Visible nested main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 23, + Some(22), + "ROUNDED_RECTANGLE", + "Visible nested path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + ]), + )]) +} + +fn cached_component_fixture() -> Vec { + let schema = schema(); + super::tests::canvas(&schema, &cached_component_message()) +} + +fn cached_component_override_fixture() -> Vec { + let mut message = cached_component_message(); + let Kv::Object(fields) = &mut message else { panic!("cached component fixture has an unexpected wire shape") }; + let changes = &mut fields.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1; + let Kv::Array(nodes) = changes else { panic!("cached component fixture has an unexpected wire shape") }; + let instance = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Visible icon instance")).unwrap(); + let Kv::Object(fields) = instance else { panic!("cached component fixture has an unexpected wire shape") }; + fields.iter_mut().find(|(key, _)| *key == "symbolData").unwrap().1 = + symbol(10, vec![row(&[12], vec![("symbolData", symbol(22, vec![row(&[26], vec![("overriddenSymbolID", g(24))])]))])]); + nodes.extend([ + node( + 24, + Some(3), + "SYMBOL", + "Overridden cached main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 25, + Some(24), + "ROUNDED_RECTANGLE", + "Overridden cached path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 26, + Some(22), + "INSTANCE", + "Deep cached instance", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0)), ("symbolData", symbol(27, vec![]))], + ), + node( + 27, + Some(3), + "SYMBOL", + "Base deep main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 28, + Some(27), + "ROUNDED_RECTANGLE", + "Base deep path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + ]); + super::tests::canvas(&schema(), &message) +} + +fn cached_component_default_swap_fixture() -> Vec { + let mut message = cached_component_message(); + let Kv::Object(fields) = &mut message else { panic!("cached component fixture has an unexpected wire shape") }; + let changes = &mut fields.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1; + let Kv::Array(nodes) = changes else { panic!("cached component fixture has an unexpected wire shape") }; + let owner = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Visible nested main")).unwrap(); + let Kv::Object(fields) = owner else { panic!("cached component fixture has an unexpected wire shape") }; + fields.push(( + "componentPropDefs", + Kv::Array(vec![o(vec![ + ("id", g(90)), + ("name", s("Icon")), + ("type", Kv::Enum("INSTANCE_SWAP")), + ("initialValue", o(vec![("guidValue", g(24))])), + ])]), + )); + nodes.extend([ + node( + 24, + Some(3), + "SYMBOL", + "Default cached main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 25, + Some(24), + "ROUNDED_RECTANGLE", + "Default cached path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + ]); + super::tests::canvas(&schema(), &message) +} + +fn assert_default_swap(doc: &Document) { + let owner = named_node(doc, "Visible nested main"); + let target = named_node(doc, "Default cached main"); + let NodeKind::Component { props, .. } = &doc.node(owner).unwrap().kind else { panic!("visible component lost its property definition") }; + assert_eq!(props.get("Icon"), Some(&PropDef::InstanceSwap { default: target, preferred: Vec::new() }), "{props:?}"); + assert!(doc.is_remote(target)); +} + +fn named_node(doc: &Document, name: &str) -> NodeId { + doc.nodes().find(|node| node.name == name).unwrap().id +} + +fn assert_dark_remote_mode(doc: &Document, node: NodeId) { + let collection = doc.variables.collections.iter().find(|collection| collection.name == "Remote theme").unwrap(); + let dark = collection.modes.iter().find(|mode| mode.name == "Dark").unwrap(); + assert_eq!(doc.modes_for(node).get(&collection.id), Some(&dark.id)); +} + +fn assert_no_linked_library_records(doc: &Document) { + assert!(doc.libraries.enabled.is_empty()); + assert!(doc.libraries.components.is_empty()); + assert!(doc.libraries.styles.is_empty()); + assert!(doc.libraries.collections.is_empty()); + assert!(doc.libraries.variables.is_empty()); +} + +#[test] +fn hidden_cached_component_is_retained_and_linked() { + let (doc, warnings) = import(&cached_component_fixture(), "Cached library.fig").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(doc.pages.len(), 1, "hidden cache must not become a visible page"); + let check = |doc: &Document| { + let store = doc.library_store().expect("private .fig cache is retained"); + let component = named_node(doc, "Cached remote icon"); + let path = doc.node(component).unwrap().children[0]; + let set = doc.node(component).unwrap().parent.unwrap(); + assert!(matches!(doc.node(set).unwrap().kind, NodeKind::ComponentSet { .. })); + assert_eq!(doc.node(doc.node(set).unwrap().parent.unwrap()).unwrap().parent, Some(store)); + assert!(doc.is_remote(component) && doc.is_remote(path)); + assert!(matches!(doc.node(component).unwrap().kind, NodeKind::Component { .. })); + assert_dark_remote_mode(doc, component); + assert_no_linked_library_records(doc); + // The visible instance resolves to the cached main, and its layer to the main's layer. + let instance = named_node(doc, "Visible icon instance"); + assert!(matches!(doc.node(instance).unwrap().kind, NodeKind::Instance { component: linked, .. } if linked == component)); + assert_eq!(doc.node(doc.node(instance).unwrap().children[0]).unwrap().source, Some(path)); + // A cached main nested in another cached main is kept too, and so is an instance in the + // cache of a visible main, with the override its row carries. + let nested_main = named_node(doc, "Nested cached main"); + let nested = named_node(doc, "Nested internal instance"); + assert!(doc.is_remote(nested_main)); + assert!(matches!(doc.node(nested).unwrap().kind, NodeKind::Instance { component: linked, .. } if linked == nested_main)); + let external_main = named_node(doc, "Visible nested main"); + let external = doc + .node(component) + .unwrap() + .children + .iter() + .copied() + .find(|id| doc.node(*id).is_some_and(|n| n.name == "Nested cached instance")) + .unwrap(); + assert!(matches!(doc.node(external).unwrap().kind, NodeKind::Instance { component: linked, .. } if linked == external_main)); + let external_child = doc.node(external).unwrap().children[0]; + assert_eq!(doc.node(external_child).unwrap().source, Some(doc.node(external_main).unwrap().children[0])); + assert_eq!(fill_rgb_node(doc, external_child), Some([f64::from(0.2_f32), f64::from(0.7_f32), f64::from(0.1_f32)])); + }; + check(&doc); + let (saved, warnings) = crate::load_bytes(&crate::save_bytes(&doc, None).unwrap()).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + check(&saved); +} + +#[test] +fn visible_instance_override_retains_and_links_hidden_cached_main() { + let (doc, warnings) = import(&cached_component_override_fixture(), "Cached override.fig").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let nested = named_node(&doc, "Nested cached instance"); + let deep = named_node(&doc, "Deep cached instance"); + assert_eq!(doc.node(deep).unwrap().parent, Some(nested)); + let NodeKind::Instance { component: overridden, .. } = doc.node(deep).unwrap().kind else { panic!("nested-row override lost its instance") }; + let overridden = doc.node(overridden).unwrap(); + assert_eq!(overridden.name, "Overridden cached main"); + assert!(doc.is_remote(overridden.id)); + assert_eq!(doc.node(doc.node(deep).unwrap().children[0]).unwrap().source, Some(overridden.children[0])); +} + +#[test] +fn visible_component_default_swap_retains_hidden_cached_main() { + let (doc, warnings) = import(&cached_component_default_swap_fixture(), "Cached default swap.fig").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_default_swap(&doc); + let (saved, _) = crate::load_bytes(&crate::save_bytes(&doc, None).unwrap()).unwrap(); + assert_default_swap(&saved); +} + +#[test] +fn public_rest_import_ignores_private_fig_provenance() { + let value = json!({ + "name": "Public REST", + "document": {"type": "DOCUMENT", "children": [{"type": "CANVAS", "name": "Page", "children": []}]}, + "_figLibraryContexts": [{"type": "FRAME", "name": "Private cache", "children": [{"type": "COMPONENT", "name": "Must not import", "children": []}]}] + }); + let (doc, warnings) = crate::rest::import(&value, &|_| None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(doc.library_store().is_none() && doc.nodes().all(|node| node.name != "Must not import")); + // Valid provenance on mapped layers links nothing either. + for message in [message(false), external_namespace_message()] { + let (json, _) = super::map::to_rest(&message, "Identity"); + let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); + assert!(doc.nodes().all(|n| n.source.is_none())); + } +} + +#[test] +fn the_last_change_of_a_guid_wins_and_removed_changes_delete() { + let moved = node(12, Some(11), "FRAME", "Moved frame", vec![]); + let mut removed = node(13, Some(10), "FRAME", "Removed frame", vec![]); + let Kv::Object(fields) = &mut removed else { panic!() }; + fields.iter_mut().find(|(key, _)| *key == "phase").unwrap().1 = Kv::Enum("REMOVED"); + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(10, Some(2), "FRAME", "Left", vec![]), + node(11, Some(2), "FRAME", "Right", vec![]), + node(12, Some(10), "FRAME", "Moved frame", vec![]), + node(13, Some(10), "FRAME", "Removed frame", vec![]), + moved, + removed, + ]), + )]); + let (doc, _) = import(&super::tests::canvas(&schema(), &message), "Changes.fig").unwrap(); + assert_eq!(doc.node(named_node(&doc, "Moved frame")).unwrap().parent, Some(named_node(&doc, "Right"))); + assert!(doc.nodes().all(|n| n.name != "Removed frame")); + assert!(doc.node(named_node(&doc, "Left")).unwrap().children.is_empty()); +} + +#[test] +fn internal_library_parent_cycles_are_bounded() { + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "CANVAS", "Visible page", vec![]), + node(2, Some(1), "INSTANCE", "Visible cyclic main", vec![("symbolData", symbol(11, vec![]))]), + node(3, Some(9), "CANVAS", "Cyclic library", vec![("internalOnly", Kv::Bool(true))]), + node(9, Some(11), "FRAME", "Loop frame", vec![]), + node(10, Some(3), "SYMBOL", "Unrelated component", vec![]), + node(11, Some(3), "SYMBOL", "Loop component", vec![]), + ]), + )]); + let (doc, warnings) = import(&super::tests::canvas(&schema(), &message), "Cyclic library.fig").unwrap(); + assert!(warnings.iter().any(|warning| warning.contains("deeper than")), "{warnings:?}"); + assert!(doc.node_count() < 1000, "the loop is cut at the nesting limit"); +} + +#[test] +fn parent_loops_through_the_internal_canvas_end() { + // The canvas is a child of a frame inside one of its own symbols, and the used main sits below + // that frame: the walk up from the main reaches the canvas again. + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "CANVAS", "Visible page", vec![]), + node(2, Some(1), "INSTANCE", "Uses the main", vec![("symbolData", symbol(12, vec![]))]), + node(3, Some(9), "CANVAS", "Loop library", vec![("internalOnly", Kv::Bool(true))]), + node(9, Some(11), "FRAME", "Loop frame", vec![]), + node(11, Some(3), "SYMBOL", "Loop symbol", vec![]), + node(12, Some(9), "SYMBOL", "Used main", vec![]), + ]), + )]); + let (doc, _) = import(&super::tests::canvas(&schema(), &message), "Loop.fig").unwrap(); + assert!(doc.nodes().any(|n| n.name == "Used main")); +} + +fn fill_rgb_node(doc: &Document, id: NodeId) -> Option<[f64; 3]> { + let node = doc.node(id)?; + match &node.fills.first()?.kind { + PaintKind::Solid { color } => Some([f64::from(color.r), f64::from(color.g), f64::from(color.b)]), + _ => None, + } +} + +fn message(swapped: bool) -> Kv<'static> { + let shape = |id, parent, x| node(id, Some(parent), "ROUNDED_RECTANGLE", "Same layer", vec![("transform", matrix(x))]); + let mut changes = vec![ + node(1, None, "DOCUMENT", "Identity", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(10, Some(2), "SYMBOL", "Renamed Left main", vec![]), + node(11, Some(10), "FRAME", "Frame", vec![("transform", matrix(2.0))]), + shape(12, 11, 2.0), + shape(13, 11, 4.0), + shape(14, 11, 6.0), + node(15, Some(11), "FRAME", "Deep frame", vec![]), + node(16, Some(15), "FRAME", "Deeper frame", vec![]), + shape(17, 16, 8.0), + node( + 20, + Some(2), + "SYMBOL", + "State=Right, Renamed=Yes", + vec![( + "componentPropDefs", + Kv::Array(vec![o(vec![ + ("id", g(200)), + ("name", s("Enabled")), + ("type", Kv::Enum("BOOL")), + ("initialValue", o(vec![("boolValue", Kv::Bool(true))])), + ])]), + )], + ), + node(21, Some(20), "FRAME", "Frame", vec![("transform", matrix(if swapped { 23.0 } else { 2.0 }))]), + shape(22, 21, 2.0), + shape(23, 21, 4.0), + shape(24, 21, 6.0), + node(30, Some(2), "SYMBOL", "Outer", vec![]), + node(40, Some(30), "INSTANCE", "Original wrapper", vec![("symbolData", symbol(50, vec![row(&[60, 21], vec![("transform", matrix(3.0))])]))]), + node(50, Some(2), "SYMBOL", "Renamed wrapper main", vec![]), + node( + 60, + Some(50), + "INSTANCE", + "Same nested slot", + vec![ + ("symbolData", symbol(20, vec![])), + ("visible", Kv::Bool(false)), + ("opacity", Kv::Float(0.3)), + ("mask", Kv::Bool(true)), + ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(200)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ], + ), + node(61, Some(50), "INSTANCE", "Same nested slot", vec![("symbolData", symbol(20, vec![]))]), + ]; + let mut nested = vec![ + ("name", s("Renamed nested slot")), + ("visible", Kv::Bool(true)), + ("opacity", Kv::Float(1.0)), + ("mask", Kv::Bool(false)), + ("componentPropAssignments", Kv::Array(vec![])), + ]; + if swapped { + nested.push(("overriddenSymbolID", g(10))); + } + changes.push(node( + 70, + Some(2), + "INSTANCE", + "Copy", + vec![( + "symbolData", + symbol( + 30, + vec![ + row(&[40], vec![("name", s("Renamed wrapper"))]), + row(&[40, 60], nested), + row(&[40, 60, if swapped { 11 } else { 21 }], vec![("transform", matrix(2.0))]), + ], + ), + )], + )); + o(vec![("nodeChanges", Kv::Array(changes))]) +} + +fn import_message(swapped: bool) -> Document { + let schema = Schema::parse(export::SCHEMA).unwrap(); + let bytes = super::tests::canvas(&schema, &message(swapped)); + let (doc, notes) = import(&bytes, "Identity.fig").unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + doc +} + +fn named(doc: &Document, name: &str) -> NodeId { + doc.nodes().find(|n| n.name == name).unwrap().id +} + +fn child(doc: &Document, id: NodeId, index: usize) -> NodeId { + doc.node(id).unwrap().children[index] +} + +fn assert_contextual(doc: &Document) { + let outer = named(doc, "Outer"); + let right = named(doc, "State=Right, Renamed=Yes"); + let wrapper_main = named(doc, "Renamed wrapper main"); + let copy = named(doc, "Copy"); + let main_wrapper = child(doc, outer, 0); + let copy_wrapper = child(doc, copy, 0); + assert_eq!(doc.node(copy).unwrap().source, Some(outer)); + assert_eq!(doc.node(copy_wrapper).unwrap().source, Some(main_wrapper)); + assert!(matches!(&doc.node(copy_wrapper).unwrap().kind, NodeKind::Instance {component, ..} if *component == wrapper_main)); + for index in 0..2 { + let default_slot = child(doc, main_wrapper, index); + let copy_slot = child(doc, copy_wrapper, index); + assert_eq!(doc.node(copy_slot).unwrap().source, Some(default_slot), "slot {index}"); + assert!(matches!(&doc.node(default_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + assert!(matches!(&doc.node(copy_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + } + let default_slot = child(doc, main_wrapper, 0); + let copy_slot = child(doc, copy_wrapper, 0); + let inherited = child(doc, default_slot, 0); + let copy_frame = child(doc, copy_slot, 0); + assert!(matches!(doc.node(copy_frame).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(inherited).unwrap().transform.translation().x, 3.0); + assert_eq!(doc.node(copy_frame).unwrap().transform.translation().x, 2.0); + assert_eq!(doc.node(copy_frame).unwrap().source, Some(inherited)); + for index in 0..3 { + assert_eq!(doc.node(child(doc, copy_frame, index)).unwrap().source, Some(child(doc, inherited, index))); + assert_eq!(doc.node(child(doc, copy_frame, index)).unwrap().transform.translation().x, 2.0 + 2.0 * index as f64); + } + let a = doc.node(default_slot).unwrap(); + let b = doc.node(copy_slot).unwrap(); + assert!(!a.visible && a.is_mask && a.opacity < 1.0); + assert!(b.visible && !b.is_mask && b.opacity == 1.0); + assert!(matches!(&a.kind, NodeKind::Instance {props, ..} if props.len() == 1 && props.values().all(|p| p == &json!(false)))); + assert!(matches!(&b.kind, NodeKind::Instance {props, ..} if props.is_empty())); +} + +#[test] +fn contextual_sources_preserve_inherited_geometry_and_explicit_clears() { + assert_contextual(&import_message(false)); +} + +fn assert_swapped(doc: &Document) { + let outer = named(doc, "Outer"); + let right = named(doc, "State=Right, Renamed=Yes"); + let copy = named(doc, "Copy"); + let main_wrapper = child(doc, outer, 0); + let copy_wrapper = child(doc, copy, 0); + let copy_slot = child(doc, copy_wrapper, 0); + let default_slot = child(doc, main_wrapper, 0); + let left = named(doc, "Renamed Left main"); + assert_eq!(doc.node(copy).unwrap().source, Some(outer)); + assert_eq!(doc.node(copy_wrapper).unwrap().source, Some(main_wrapper)); + assert_eq!(doc.node(copy_slot).unwrap().source, Some(default_slot)); + assert!(matches!(&doc.node(copy_slot).unwrap().kind, NodeKind::Instance {component, props} if *component == left && props.is_empty())); + assert!(matches!(&doc.node(default_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + let current_frame = child(doc, copy_slot, 0); + let left_frame = child(doc, left, 0); + assert!(matches!(doc.node(current_frame).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(current_frame).unwrap().source, Some(left_frame)); + assert_eq!(doc.node(current_frame).unwrap().transform.translation().x, 2.0); + assert_eq!(doc.node(child(doc, right, 0)).unwrap().transform.translation().x, 23.0); + for index in 0..3 { + assert_eq!(doc.node(child(doc, current_frame, index)).unwrap().source, Some(child(doc, left_frame, index))); + assert_eq!(doc.node(child(doc, current_frame, index)).unwrap().transform.translation().x, 2.0 + 2.0 * index as f64); + } + let deep_copy = child(doc, child(doc, child(doc, current_frame, 3), 0), 0); + let deep_main = child(doc, child(doc, child(doc, left_frame, 3), 0), 0); + assert_eq!(doc.node(deep_copy).unwrap().source, Some(deep_main)); + assert_eq!(doc.node(deep_copy).unwrap().transform.translation().x, 8.0); + let other = child(doc, copy_wrapper, 1); + assert_eq!(doc.node(other).unwrap().source, Some(child(doc, main_wrapper, 1))); + assert!(matches!(&doc.node(other).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + assert!(doc.node(copy_slot).unwrap().visible && !doc.node(copy_slot).unwrap().is_mask && doc.node(copy_slot).unwrap().opacity == 1.0); +} + +#[test] +fn swapped_children_use_current_main_through_frames_and_multiple_siblings() { + assert_swapped(&import_message(true)); +} + +fn edit_node(value: &mut Value, id: &str, edit: &mut dyn FnMut(&mut Value)) { + if value.get("id").and_then(Value::as_str) == Some(id) { + edit(value); + } + if let Some(kids) = value.get_mut("children").and_then(Value::as_array_mut) { + for child in kids { + edit_node(child, id, edit); + } + } +} + +#[test] +fn missing_foreign_self_and_wrong_namespace_provenance_is_not_guessed() { + for (origin, id) in [("1:99999", "I1:70;1:40"), ("1:12", "I1:70;1:40"), ("1:70", "1:70"), ("1:40", "I1:70;1:40")] { + let (mut json, _) = super::map::to_rest(&message(false), "Identity"); + edit_node(&mut json["document"]["children"][0], id, &mut |node| { + node[crate::rest::FIG_SOURCE_FIELD]["origin"] = json!(origin); + if id == "1:70" { + node["componentId"] = json!("1:70"); // a root cannot be its own component + } else if origin == "1:40" { + node["id"] = json!("I1:999;1:40"); + } else { + node["id"] = json!(format!("I1:70;{origin}")); // valid namespace, but missing/foreign target + } + }); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let target = if id == "1:70" { named(&doc, "Copy") } else { named(&doc, "Renamed wrapper") }; + assert!(doc.node(target).unwrap().source.is_none(), "{origin}/{id}"); + } +} + +#[test] +fn forward_main_references_and_duplicate_ids_are_conservative() { + let (mut json, _) = super::map::to_rest(&message(false), "Identity"); + json["document"]["children"][0]["children"].as_array_mut().unwrap().rotate_right(1); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + assert!(doc.node(named(&doc, "Copy")).unwrap().source.is_some()); + // 1:40 is nested inside Outer, so duplicate that actual template at page level. + let template = json["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == "1:30").unwrap()["children"][0].clone(); + json["document"]["children"][0]["children"].as_array_mut().unwrap().push(template); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + assert!(doc.node(named(&doc, "Renamed wrapper")).unwrap().source.is_none()); +} + +#[test] +fn missing_contextual_targets_do_not_fall_back_to_a_global_slot() { + let (mut json, _) = super::map::to_rest(&message(false), "Identity"); + edit_node(&mut json["document"]["children"][0], "1:40", &mut |node| { + node["children"].as_array_mut().unwrap().retain(|n| n["id"] != "I1:40;1:60"); + }); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let copy_slot = child(&doc, child(&doc, named(&doc, "Copy"), 0), 0); + assert!(doc.node(copy_slot).unwrap().source.is_none()); + assert!(doc.nodes().any(|n| n.name == "Same nested slot"), "the raw slot still exists"); +} + +#[test] +fn instance_to_empty_frame_keeps_its_source_identity() { + let mut msg = message(false); + let Kv::Object(top) = &mut msg else { panic!() }; + let Kv::Array(nodes) = &mut top.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1 else { panic!() }; + let copy = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("Copy")).unwrap(); + let Kv::Object(fields) = copy else { panic!() }; + let Kv::Object(symbol) = &mut fields.iter_mut().find(|(key, _)| *key == "symbolData").unwrap().1 else { panic!() }; + let Kv::Array(overrides) = &mut symbol.iter_mut().find(|(key, _)| *key == "symbolOverrides").unwrap().1 else { panic!() }; + overrides.push(row(&[40, 61], vec![("type", Kv::Enum("FRAME"))])); + let (doc, _) = import(&super::tests::canvas(&schema(), &msg), "Identity.fig").unwrap(); + let main = child(&doc, named(&doc, "Outer"), 0); + let copy = child(&doc, named(&doc, "Copy"), 0); + let frame = child(&doc, copy, 1); + assert!(matches!(doc.node(frame).unwrap().kind, NodeKind::Frame)); + assert!(doc.node(frame).unwrap().children.is_empty()); + assert_eq!(doc.node(frame).unwrap().source, Some(child(&doc, main, 1))); +} + +#[test] +fn explicit_frame_to_instance_resets_children_to_the_resolved_main() { + let mut msg = message(false); + let Kv::Object(top) = &mut msg else { panic!() }; + let Kv::Array(nodes) = &mut top.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1 else { panic!() }; + nodes.push(node(80, Some(30), "FRAME", "Original frame", vec![])); + nodes.push(node(81, Some(80), "ROUNDED_RECTANGLE", "Old frame child", vec![])); + let copy = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("Copy")).unwrap(); + let Kv::Object(fields) = copy else { panic!() }; + let Kv::Object(symbol) = &mut fields.iter_mut().find(|(key, _)| *key == "symbolData").unwrap().1 else { panic!() }; + let Kv::Array(overrides) = &mut symbol.iter_mut().find(|(key, _)| *key == "symbolOverrides").unwrap().1 else { panic!() }; + overrides.push(row(&[80], vec![("type", Kv::Enum("INSTANCE")), ("overriddenSymbolID", g(10))])); + let (doc, notes) = import(&super::tests::canvas(&schema(), &msg), "Identity.fig").unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + let original = child(&doc, named(&doc, "Outer"), 1); + let current = child(&doc, named(&doc, "Copy"), 1); + let left = named(&doc, "Renamed Left main"); + assert!(matches!(doc.node(original).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(current).unwrap().source, Some(original)); + assert!(matches!(&doc.node(current).unwrap().kind, NodeKind::Instance {component, ..} if *component == left)); + assert_eq!(doc.node(child(&doc, current, 0)).unwrap().source, Some(child(&doc, left, 0))); +} + +// The orphan symbol is decoded and expanded but is not a layer on any imported page. This +// matches an external main: its instance becomes a frame while the rendered body survives. +fn external_namespace_message() -> Kv<'static> { + o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "External namespace", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node( + 10, + Some(2), + "SYMBOL", + "Known main", + vec![( + "componentPropDefs", + Kv::Array(vec![o(vec![ + ("id", g(200)), + ("name", s("Enabled")), + ("type", Kv::Enum("BOOL")), + ("initialValue", o(vec![("boolValue", Kv::Bool(true))])), + ])]), + )], + ), + node(11, Some(10), "FRAME", "Known frame", vec![("transform", matrix(2.0))]), + node(12, Some(11), "ROUNDED_RECTANGLE", "Known leaf", vec![("transform", matrix(4.0))]), + node(30, Some(2), "SYMBOL", "Outer external", vec![]), + node( + 90, + Some(30), + "INSTANCE", + "External body", + vec![("symbolData", symbol(99, vec![row(&[110, 11], vec![("transform", matrix(3.0))])]))], + ), + node(99, None, "SYMBOL", "External orphan", vec![]), + node(100, Some(99), "FRAME", "Body frame", vec![]), + node(101, Some(99), "FRAME", "Other body frame", vec![]), + node( + 110, + Some(100), + "INSTANCE", + "Same slot", + vec![ + ("symbolData", symbol(10, vec![])), + ("visible", Kv::Bool(false)), + ("opacity", Kv::Float(0.3)), + ("mask", Kv::Bool(true)), + ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(200)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ], + ), + node(111, Some(100), "INSTANCE", "Same slot", vec![("symbolData", symbol(10, vec![]))]), + node( + 70, + Some(2), + "INSTANCE", + "External copy", + vec![( + "symbolData", + symbol( + 30, + vec![ + row( + &[90, 110], + vec![ + ("name", s("Renamed owned slot")), + ("visible", Kv::Bool(true)), + ("opacity", Kv::Float(1.0)), + ("mask", Kv::Bool(false)), + ("componentPropAssignments", Kv::Array(vec![])), + ], + ), + row(&[90, 110, 11], vec![("transform", matrix(2.0))]), + ], + ), + )], + ), + ]), + )]) +} + +fn assert_external_namespace(doc: &Document) { + let outer = named(doc, "Outer external"); + let copy = named(doc, "External copy"); + let main = named(doc, "Known main"); + let body = child(doc, outer, 0); + let copied_body = child(doc, copy, 0); + assert!(matches!(doc.node(body).unwrap().kind, NodeKind::Frame)); + assert!(matches!(doc.node(copied_body).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(body).unwrap().source, None, "the absent external main must not acquire a guessed native node"); + assert_eq!(doc.node(copied_body).unwrap().source, Some(body)); + let frame = child(doc, body, 0); + let copied_frame = child(doc, copied_body, 0); + assert_eq!(doc.node(frame).unwrap().source, None, "materialized ordinary layers are owned by the frame"); + assert_eq!(doc.node(copied_frame).unwrap().source, Some(frame)); + for index in 0..2 { + let slot = child(doc, frame, index); + let copied_slot = child(doc, copied_frame, index); + assert_eq!(doc.node(slot).unwrap().source, Some(main), "known owned instance has its real root main"); + assert_eq!(doc.node(copied_slot).unwrap().source, Some(slot), "copied slots preserve the contextual owned layer identity"); + assert!(matches!(&doc.node(slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == main)); + assert!(matches!(&doc.node(copied_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == main)); + let inherited = child(doc, slot, 0); + let current = child(doc, copied_slot, 0); + assert_eq!(doc.node(inherited).unwrap().source, Some(child(doc, main, 0))); + assert_eq!(doc.node(current).unwrap().source, Some(inherited)); + assert_eq!(doc.node(inherited).unwrap().transform.translation().x, if index == 0 { 3.0 } else { 2.0 }); + assert_eq!(doc.node(current).unwrap().transform.translation().x, 2.0); + assert_eq!(doc.node(child(doc, current, 0)).unwrap().source, Some(child(doc, inherited, 0))); + assert_eq!(doc.node(child(doc, current, 0)).unwrap().transform.translation().x, 4.0); + } + let old = doc.node(child(doc, frame, 0)).unwrap(); + let current = doc.node(child(doc, copied_frame, 0)).unwrap(); + assert!(!old.visible && old.is_mask && old.opacity < 1.0); + assert!(current.visible && !current.is_mask && current.opacity == 1.0); + assert_eq!(current.name, "Renamed owned slot"); + assert!(matches!(&old.kind, NodeKind::Instance {props, ..} if props.len() == 1)); + assert!(matches!(¤t.kind, NodeKind::Instance {props, ..} if props.is_empty())); +} + +#[test] +fn absent_external_mains_keep_known_owned_instances_and_contextual_copies() { + let (doc, notes) = import(&super::tests::canvas(&schema(), &external_namespace_message()), "External.fig").unwrap(); + assert!(notes.iter().any(|w| w.contains("components that aren't in the file")), "{notes:?}"); + assert!(!notes.iter().any(|w| w.contains("source link")), "materialized own layers are not unresolved copies: {notes:?}"); + assert_external_namespace(&doc); +} + +#[test] +fn external_namespace_is_private_and_requires_actual_wire_instances() { + for mode in 0..3 { + let (mut json, _) = super::map::to_rest(&external_namespace_message(), "External"); + if mode == 0 { + let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); + assert!(doc.nodes().all(|n| n.source.is_none())); + continue; + } + edit_node(&mut json["document"]["children"][0], "1:90", &mut |node| { + if mode == 1 { + node["type"] = json!("FRAME"); + } else { + node[crate::rest::FIG_SOURCE_FIELD]["wireInstance"] = json!(false); + node[crate::rest::FIG_SOURCE_FIELD]["wireFrame"] = json!(true); + } + }); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let raw_slot = child(&doc, child(&doc, child(&doc, named(&doc, "Outer external"), 0), 0), 0); + assert!(doc.node(raw_slot).unwrap().source.is_none(), "ordinary/fake frame mode {mode} must not establish a namespace"); + } +} + +#[test] +fn external_namespace_does_not_cover_present_wrong_kind_or_blocked_main() { + for mode in 0..3 { + let (mut json, _) = super::map::to_rest(&external_namespace_message(), "External"); + let page = &mut json["document"]["children"][0]; + match mode { + 0 => page["children"].as_array_mut().unwrap().push(json!({"id":"1:99", "type":"FRAME", "name":"Wrong kind", + "_figSource":{"origin":"1:99","expanded":false,"wireFrame":true,"wireInstance":false}})), + 1 => edit_node(page, "1:90", &mut |node| { + node["componentId"] = json!("1:30"); + }), + _ => page["children"].as_array_mut().unwrap().push(json!({"id":"1:99", "type":"COMPONENT", "name":"Cycle main", + "_figSource":{"origin":"1:99","expanded":false,"wireFrame":false,"wireInstance":false}, + "children":[{"id":"1:199", "type":"INSTANCE", "name":"Cycle edge", "componentId":"1:30", + "_figSource":{"origin":"1:199","expanded":false,"wireFrame":false,"wireInstance":true}}]})), + } + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let body = child(&doc, named(&doc, "Outer external"), 0); + assert!(matches!(doc.node(body).unwrap().kind, NodeKind::Frame)); + let slot = child(&doc, child(&doc, body, 0), 0); + assert!(doc.node(slot).unwrap().source.is_none(), "existing invalid/cycle/self main mode {mode} must not become a materialized namespace"); + } +} + +#[test] +fn external_namespace_rejects_foreign_siblings_duplicates_and_bad_paths() { + for mode in 0..4 { + let (mut json, _) = super::map::to_rest(&external_namespace_message(), "External"); + let page = &mut json["document"]["children"][0]; + match mode { + 0 => { + // Same root namespace and GUID, but physically under a different frame slot. + let mut moved = None; + edit_node(page, "I1:70;1:90;1:100", &mut |node| { + moved = Some(node["children"].as_array_mut().unwrap().remove(0)); + }); + edit_node(page, "I1:70;1:90;1:101", &mut |node| { + node["children"] = json!([moved.take().unwrap()]); + }); + } + 1 => edit_node(page, "I1:70;1:90;1:110", &mut |node| { + node["id"] = json!("I1:70;1:999;1:110"); + }), + 2 => { + let duplicate = page["children"].as_array().unwrap().iter().find(|n| n["id"] == "1:30").unwrap()["children"][0].clone(); + page["children"].as_array_mut().unwrap().push(duplicate); + } + _ => edit_node(page, "I1:90;1:100", &mut |node| { + node["id"] = json!("I1:999;1:100"); + }), + } + let (doc, warnings) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let slot = named(&doc, "Renamed owned slot"); + assert!(doc.node(slot).unwrap().source.is_none(), "invalid frame namespace mode {mode}"); + assert!(warnings.iter().any(|w| w.contains("source link")), "invalid expanded paths remain reported: {warnings:?}"); + } +} diff --git a/crates/format/src/rest/fig_sources.rs b/crates/format/src/rest/fig_sources.rs new file mode 100644 index 00000000..61097e52 --- /dev/null +++ b/crates/format/src/rest/fig_sources.rs @@ -0,0 +1,316 @@ +//! Explicit .fig GUID provenance projected through instance expansion contexts. +//! All strings borrow the bounded JSON; no names, node-kind similarity or child order match links. + +use std::collections::{BTreeMap, BTreeSet}; + +use figcraft_doc::{Document, NodeId, NodeKind}; +use serde_json::Value; + +use super::{FIG_SOURCE_FIELD, MAX_DEPTH}; + +// A GUID has at most two 20-digit u64 parts and a colon; instance paths contain at most +// MAX_DEPTH + 2 GUIDs. Check before constructing any projected path string. +const MAX_SOURCE_ID_BYTES: usize = 44 * (MAX_DEPTH + 2); + +#[derive(Clone, Copy)] +struct Origin<'a> { + rest: &'a str, + guid: &'a str, + expanded: bool, + wire_frame: bool, + wire_instance: bool, + component: Option<&'a str>, +} + +pub(super) struct Sources<'a> { + /// Most layers recorded. + max_nodes: usize, + nodes: BTreeMap>, + duplicate_ids: BTreeSet<&'a str>, + rejected: usize, +} + +fn guid(s: &str) -> bool { + let Some((session, local)) = s.split_once(':') else { return false }; + s.len() <= 41 + && [session, local].into_iter().all(|part| !part.is_empty() && part.bytes().all(|c| c.is_ascii_digit()) && part.parse::().is_ok()) +} + +fn expanded_id(instance: &str, origin: &str) -> Option { + let bytes = instance.len().checked_add(origin.len())?.checked_add(2)?; + if bytes > MAX_SOURCE_ID_BYTES { + return None; + } + Some(if instance.starts_with('I') { format!("{instance};{origin}") } else { format!("I{instance};{origin}") }) +} + +impl<'a> Sources<'a> { + pub(super) fn new(max_nodes: usize) -> Self { + Self { max_nodes, nodes: BTreeMap::new(), duplicate_ids: BTreeSet::new(), rejected: 0 } + } + + pub(super) fn record(&mut self, id: NodeId, node: &'a Value, primary: bool) { + let Some(rest) = node.get("id").and_then(Value::as_str) else { return }; + if !primary { + self.duplicate_ids.insert(rest); + return; + } + let Some(meta) = node.get(FIG_SOURCE_FIELD) else { return }; + let parsed = meta.get("origin").and_then(Value::as_str).zip(meta.get("expanded").and_then(Value::as_bool)); + let Some((origin, expanded)) = parsed else { + self.rejected += 1; + return; + }; + if self.nodes.len() >= self.max_nodes + || rest.len() > MAX_SOURCE_ID_BYTES + || !guid(origin) + || (!expanded && rest != origin) + || (expanded && !rest.starts_with('I')) + { + self.rejected += 1; + return; + } + let wire_frame = meta.get("wireFrame").and_then(Value::as_bool).unwrap_or(false); + let wire_instance = !wire_frame + && meta.get("wireInstance").and_then(Value::as_bool) == Some(true) + && node.get("type").and_then(Value::as_str) == Some("INSTANCE"); + let component = wire_instance.then(|| node.get("componentId").and_then(Value::as_str).filter(|id| guid(id))).flatten(); + self.nodes.insert(id, Origin { rest, guid: origin, expanded, wire_frame, wire_instance, component }); + } + + fn origin(&self, id: NodeId) -> Option> { + let meta = *self.nodes.get(&id)?; + (!self.duplicate_ids.contains(meta.rest)).then_some(meta) + } + + fn raw_in_main(&self, origin: &str, main: NodeId, doc: &Document, ids: &BTreeMap) -> Option { + let target = *ids.get(origin)?; + let meta = self.origin(target)?; + (!meta.expanded && meta.rest == origin && target != main && doc.is_ancestor(main, target)).then_some(target) + } + + fn root_source(&self, id: NodeId, doc: &Document) -> Option { + let NodeKind::Instance { component, .. } = &doc.node(id)?.kind else { return None }; + let main_meta = self.origin(*component)?; + (!main_meta.expanded + && matches!(doc.node(*component)?.kind, NodeKind::Component { .. }) + && *component != id + && !doc.is_ancestor(id, *component) + && !doc.is_ancestor(*component, id)) + .then_some(*component) + } + + fn missing_frame(&self, id: NodeId, doc: &Document, ids: &BTreeMap) -> bool { + self.origin(id).is_some_and(|meta| { + meta.wire_instance + && matches!(doc.node(id).map(|n| &n.kind), Some(NodeKind::Frame)) + // An existing target of the wrong kind or a cycle-blocked main is not missing. + && meta.component.is_some_and(|main| !ids.contains_key(main)) + }) + } + + fn owner(&self, id: NodeId, doc: &Document, ids: &BTreeMap) -> Option { + let mut current = doc.node(id)?.parent; + let mut seen = BTreeSet::from([id]); + for _ in 0..512 { + let parent = current?; + if !seen.insert(parent) { + return None; + } + let node = doc.node(parent)?; + if matches!(node.kind, NodeKind::Instance { .. }) || self.missing_frame(parent, doc, ids) { + return Some(parent); + } + current = node.parent; + } + None + } + + fn namespace(&self, id: NodeId, owner: NodeId, doc: &Document) -> bool { + let Some(owner_meta) = self.origin(owner) else { return false }; + let mut current = id; + let mut seen = BTreeSet::new(); + for _ in 0..512 { + if current == owner { + return true; + } + if !seen.insert(current) { + return false; + } + let Some(meta) = self.origin(current) else { return false }; + if !meta.expanded || expanded_id(owner_meta.rest, meta.guid).as_deref() != Some(meta.rest) { + return false; + } + let Some(parent) = doc.node(current).and_then(|n| n.parent) else { return false }; + current = parent; + } + false + } + + // A missing-main instance is materialized as a frame. Outside real instances its children + // are owned layers; known instances there become ordinary roots with their own actual main. + fn owned_layer(&self, id: NodeId, doc: &Document, ids: &BTreeMap) -> bool { + if doc.ancestors(id).into_iter().any(|p| doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))) { + return false; + } + let mut current = id; + for _ in 0..=MAX_DEPTH { + let Some(owner) = self.owner(current, doc, ids) else { return false }; + if !self.missing_frame(owner, doc, ids) || !self.namespace(current, owner, doc) { + return false; + } + let Some(meta) = self.origin(owner) else { return false }; + if !meta.expanded { + return true; + } + current = owner; + } + false + } + + fn frame_target(&self, id: NodeId, owner: NodeId, source_owner: NodeId, doc: &Document, ids: &BTreeMap) -> Option { + let meta = self.origin(id)?; + let owner_meta = self.origin(owner)?; + let source_meta = self.origin(source_owner)?; + if !self.missing_frame(source_owner, doc, ids) || owner_meta.component != source_meta.component { + return None; + } + let target = *ids.get(expanded_id(source_meta.rest, meta.guid)?.as_str())?; + let target_meta = self.origin(target)?; + if !target_meta.expanded + || target_meta.guid != meta.guid + || self.owner(target, doc, ids) != Some(source_owner) + || !self.namespace(target, source_owner, doc) + { + return None; + } + let parent = doc.node(id)?.parent?; + let target_parent = doc.node(target)?.parent?; + if parent == owner { + (target_parent == source_owner).then_some(target) + } else { + (target_parent != source_owner && self.origin(parent)?.guid == self.origin(target_parent)?.guid).then_some(target) + } + } + + fn source( + &self, + id: NodeId, + doc: &Document, + ids: &BTreeMap, + memo: &mut BTreeMap>, + active: &mut BTreeSet, + depth: usize, + ) -> Option { + if let Some(source) = memo.get(&id) { + return *source; + } + if depth > MAX_DEPTH || !active.insert(id) { + return None; + } + let found = (|| { + let meta = self.origin(id)?; + let _ = doc.node(id)?; + if !meta.expanded { + return self.root_source(id, doc); + } + // An actual wire INSTANCE whose main is absent remains a namespace owner even + // when the REST importer materializes it as a frame. Ordinary frames never do. + let owner = self.owner(id, doc, ids)?; + if !self.namespace(id, owner, doc) { + return None; + } + let source_owner = self.source(owner, doc, ids, memo, active, depth + 1); + if self.missing_frame(owner, doc, ids) { + let target = match source_owner { + Some(source_owner) => self.frame_target(id, owner, source_owner, doc, ids)?, + None if self.owned_layer(id, doc, ids) => return self.root_source(id, doc), + _ => return None, + }; + return (target != id && !doc.is_ancestor(id, target)).then_some(target); + } + let NodeKind::Instance { component: main, .. } = &doc.node(owner)?.kind else { return None }; + let source_owner = source_owner?; + let target = match &doc.node(source_owner)?.kind { + NodeKind::Component { .. } if source_owner == *main => self.raw_in_main(meta.guid, *main, doc, ids)?, + NodeKind::Instance { component: source_main, .. } if source_main != main => { + // A resolved main switch is evidence for resetting to that CURRENT main. + self.raw_in_main(meta.guid, *main, doc, ids)? + } + NodeKind::Instance { component: source_main, .. } if source_main == main => { + let source_meta = self.origin(source_owner)?; + let target = *ids.get(expanded_id(source_meta.rest, meta.guid)?.as_str())?; + let target_meta = self.origin(target)?; + let target_owner = self.owner(target, doc, ids); + if !target_meta.expanded || target_meta.guid != meta.guid || target_owner != Some(source_owner) { + return None; + } + target + } + NodeKind::Frame if self.origin(source_owner)?.wire_frame => { + // An actual wire FRAME becoming INSTANCE has no previous main. Its resolved + // current main is the only proven namespace for its newly expanded children. + self.raw_in_main(meta.guid, *main, doc, ids)? + } + _ => return None, + }; + (target != id && !doc.is_ancestor(id, target)).then_some(target) + })(); + active.remove(&id); + memo.insert(id, found); + found + } + + pub(super) fn apply(self, doc: &mut Document, ids: &BTreeMap) -> usize { + let mut links = BTreeMap::new(); + let mut active = BTreeSet::new(); + for &id in self.nodes.keys() { + let _ = self.source(id, doc, ids, &mut links, &mut active, 0); + } + let mut missing = self.rejected; + for (&id, meta) in &self.nodes { + let needs_source = + (meta.expanded && !self.owned_layer(id, doc, ids)) || doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })); + if !needs_source { + continue; + } + let target = links.get(&id).copied().flatten().filter(|_| acyclic(id, &links)); + if let Some(target) = target { + if let Some(node) = doc.node_mut(id) { + node.source = Some(target); + } + } else { + missing += 1; + } + } + missing + } +} + +fn acyclic(id: NodeId, links: &BTreeMap>) -> bool { + let mut current = id; + for _ in 0..=MAX_DEPTH { + let Some(next) = links.get(¤t).copied().flatten() else { return true }; + if next == id { + return false; + } + current = next; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn self_cycles_and_overlong_source_chains_are_rejected() { + let self_link = BTreeMap::from([(NodeId(1), Some(NodeId(1)))]); + assert!(!acyclic(NodeId(1), &self_link)); + let cycle = BTreeMap::from([(NodeId(1), Some(NodeId(2))), (NodeId(2), Some(NodeId(1)))]); + assert!(!acyclic(NodeId(1), &cycle)); + let long = (1..=MAX_DEPTH as u64 + 2).map(|i| (NodeId(i), Some(NodeId(i + 1)))).collect(); + assert!(!acyclic(NodeId(1), &long)); + assert!(expanded_id(&"x".repeat(MAX_SOURCE_ID_BYTES), "1:2").is_none()); + } +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 798b2645..8c41ffb3 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -16,7 +16,8 @@ //! //! Instances come out with their own layers as the file lists them. Linking those layers to the //! main component's (`source`) and deriving overrides is left to the caller (the engine does it -//! after loading), since that needs the component model. +//! after loading), since that needs the component model. The private .fig entry point additionally +//! preserves explicit GUID/expansion provenance before the engine derives overrides. #[cfg(test)] mod blur_tests; @@ -24,6 +25,7 @@ mod blur_tests; mod conditional_tests; #[cfg(test)] mod effects_tests; +mod fig_sources; #[cfg(test)] mod gamepad_tests; pub(crate) mod props; @@ -53,6 +55,10 @@ pub use variables::export as export_variables; pub const MAX_NODES: usize = 200_000; /// Deepest layer nesting followed. const MAX_DEPTH: usize = 256; +/// Private .fig provenance, consumed only by `import_fig`. +pub(crate) const FIG_SOURCE_FIELD: &str = "_figSource"; +/// Hidden .fig library context; accepted only by [`import_fig`]. +pub(crate) const FIG_LIBRARY_CONTEXTS_FIELD: &str = "_figLibraryContexts"; /// Longest SVG path string read from one geometry entry. const MAX_PATH_BYTES: usize = 4 << 20; /// Most path elements in one vector. @@ -138,12 +144,22 @@ pub fn import(v: &Value, images: ImageResolver) -> Result<(Document, Vec /// in the file JSON (a `meta` with `variableCollections`, or `variableCollections` and /// `variables` at the top level) are read too; the first definition of an id wins. pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) -> Result<(Document, Vec), FormatError> { + import_impl(v, variables, images, false) +} + +/// Internal .fig JSON path: GUID provenance is not part of the public REST import contract. +pub(crate) fn import_fig(v: &Value, images: ImageResolver) -> Result<(Document, Vec), FormatError> { + import_impl(v, None, images, true) +} + +fn import_impl(v: &Value, variables: Option<&Value>, images: ImageResolver, fig_sources: bool) -> Result<(Document, Vec), FormatError> { let entries: Vec<&Value> = if is_document(v) { vec![v] } else { nodes_entries(v).collect() }; if entries.is_empty() { return Err(FormatError::Corrupt("not a REST API file JSON (no `document`)".into())); } let name = text(v, "name").filter(|n| !n.trim().is_empty()).map_or("Imported file", clip_str); let mut cx = Ctx::new(Document::new(name), images); + cx.fig_sources = fig_sources.then(|| fig_sources::Sources::new(MAX_NODES)); let blank = cx.doc.first_page(); let mut loose: Option = None; for e in &entries { @@ -178,6 +194,12 @@ pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) None => cx.warn("entries without a node type were skipped"), } } + if fig_sources { + for context in v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).into_iter().flatten() { + let store = cx.doc.ensure_library_store(); + cx.build(context, store, Space::default(), None, 0, false); + } + } // Drop the empty page the new document started with once real pages exist. if let Some(p) = blank && cx.doc.pages.len() > 1 @@ -291,6 +313,7 @@ pub(crate) struct Ctx<'a> { count: usize, /// REST node id → imported node. ids: BTreeMap, + fig_sources: Option>, component_meta: BTreeMap<&'a str, &'a Value>, set_meta: BTreeMap<&'a str, &'a Value>, style_meta: BTreeMap<&'a str, &'a Value>, @@ -321,6 +344,7 @@ impl<'a> Ctx<'a> { warnings: Warnings::default(), count: 0, ids: BTreeMap::new(), + fig_sources: None, component_meta: BTreeMap::new(), set_meta: BTreeMap::new(), style_meta: BTreeMap::new(), @@ -411,7 +435,10 @@ impl<'a> Ctx<'a> { } self.count += 1; if let Some(rid) = text(j, "id") { - self.ids.entry(rid.to_string()).or_insert(id); + let primary = *self.ids.entry(rid.to_string()).or_insert(id) == id; + if let Some(sources) = &mut self.fig_sources { + sources.record(id, j, primary); + } } if ty == "INSTANCE" { self.instances.push((id, j)); @@ -822,6 +849,12 @@ impl<'a> Ctx<'a> { } } self.resolve_instances(); + if let Some(sources) = self.fig_sources.take() { + let missing = sources.apply(&mut self.doc, &self.ids); + if missing > 0 { + self.warn(format!("{missing} .fig instance source link(s) could not be resolved and were left unlinked")); + } + } self.rename_props(); for (id, j) in std::mem::take(&mut self.protos) { let list = self.interactions(j);