diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 5e27c572..28124b7c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -529,6 +529,7 @@ fn load_with( /// innermost first so outer instances settle last. fn link_instances(doc: &mut Document) { crate::style_vars::after_load(doc); + super::styles::apply_paint_bindings_on_load(doc); let mut insts: Vec<(usize, NodeId)> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect(); insts.sort_by_key(|a| std::cmp::Reverse(a.0)); @@ -1272,6 +1273,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 +1303,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 +1375,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/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 552a44cf..25e36e3a 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -913,6 +913,24 @@ pub fn apply_bindings(doc: &mut Document) { /// [`apply_bindings`] for the layers `only` (every bound layer when `None`). pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTreeSet>) { + apply_bindings_matching(doc, only, |_| true); +} + +/// Settle imported paint colours before the document is first shown. Other bindings can affect +/// layout or component structure, which must be resolved through the normal edit pipeline. +pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) { + let pending = doc.take_dirty(); + apply_bindings_matching(doc, None, |field| { + super::lists::list_binding(field).is_some_and(|(list, _, prop)| { + matches!(list, super::lists::BoundList::Fills | super::lists::BoundList::Strokes) + && (prop == "color" || super::lists::stop_binding(prop).is_some()) + }) + }); + doc.take_dirty(); + doc.mark_dirty(pending); +} + +fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet>, include: impl Fn(&str) -> bool) { use super::lists::{BoundList, list_binding}; if doc.variables.variables.is_empty() { return; @@ -932,6 +950,9 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr let mut grid_sets: Vec<(usize, String, f64)> = Vec::new(); let mut prop_sets: Vec<(String, VariableValue)> = Vec::new(); for (field, var) in binds { + if !include(&field) { + continue; + } let Some(val) = doc.variables.resolve(&var, &modes) else { continue }; // One list item's value: a paint's colour, an effect's or a layout grid's field. if let Some((list, i, prop)) = list_binding(&field) { 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/lib.rs b/crates/engine/src/lib.rs index 34653bd5..6e0e8b12 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -401,6 +401,8 @@ impl DocState { doc.repair(); } doc.link_layer_text_styles(); + cmd::styles::apply_paint_bindings_on_load(&mut doc); + // Initial import settlement belongs to the saved baseline, not the user's first edit. let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0)); let doc = Arc::new(doc); let mut st = Self { 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/engine/src/variable_tests.rs b/crates/engine/src/variable_tests.rs index 536b2699..dcdd04e3 100644 --- a/crates/engine/src/variable_tests.rs +++ b/crates/engine/src/variable_tests.rs @@ -5,6 +5,79 @@ use serde_json::json; use crate::Session; use crate::params::id_of; +fn stale_bound_color_document() -> (figcraft_doc::Document, figcraft_doc::NodeId) { + let mut s = Session::new(); + let id = id_of(&s.execute("shape.rectangle", &json!({"x": 4000, "y": 4000, "width": 23, "height": 17})).unwrap()["id"]).unwrap(); + let page = s.doc().unwrap().first_page().unwrap(); + s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap(); + s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#FFFFFF"})).unwrap(); + s.execute("variable.create", &json!({"collection": "Theme", "name": "size", "type": "number", "value": 23})).unwrap(); + s.execute("variable.set", &json!({"variable": "surface", "mode": "Dark", "value": "#202124"})).unwrap(); + s.execute("variable.set", &json!({"variable": "size", "mode": "Dark", "value": 99})).unwrap(); + s.execute("variable.bind", &json!({"ids": [id], "field": "fill", "variable": "surface"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [id], "field": "width", "variable": "size"})).unwrap(); + s.execute("variable.setMode", &json!({"collection": "Theme", "mode": "Dark"})).unwrap(); + let mut doc = s.doc().unwrap().clone(); + let node = doc.get_mut(id).unwrap(); + node.name = "load-binding-sentinel".into(); + node.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]; + node.width = 5.0; + node.source = Some(page); + node.overrides.insert("sentinel".into()); + doc.take_dirty(); + (doc, id) +} + +fn assert_dark_bound_color(doc: &figcraft_doc::Document, transform: figcraft_doc::geom::Affine) -> figcraft_doc::NodeId { + let node = doc.nodes().find(|n| n.name == "load-binding-sentinel").unwrap(); + assert_eq!(node.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()), Some("202124".into())); + assert_eq!((node.transform, node.width, node.height), (transform, 5.0, 17.0)); + assert!(node.bound.contains_key("fills/0/color") || node.bound.contains_key("fill")); + node.id +} + +#[test] +fn initial_color_bindings_settle_in_constructor_and_loaders() { + let (stale, _id) = stale_bound_color_document(); + let name_transform = stale.nodes().find(|n| n.name == "load-binding-sentinel").unwrap().transform; + + let mut state = Session::with_document(stale.clone()); + let id = assert_dark_bound_color(state.doc().unwrap(), name_transform); + assert_eq!(state.doc().unwrap().node(id).unwrap().source, stale.first_page()); + assert!(state.doc().unwrap().node(id).unwrap().overrides.contains("sentinel")); + assert!(!state.ds().unwrap().is_dirty()); + assert!(state.doc().unwrap().dirty().is_empty()); + assert_eq!(state.undo_depth(), (0, 0)); + state.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap(); + assert_dark_bound_color(state.doc().unwrap(), name_transform); + + let native = figcraft_format::save_bytes(&stale, None).unwrap(); + let (loaded, _) = crate::cmd::file::load_bytes(&native, "bindings.figcraft").unwrap(); + // A native file settles when it opens as a document, not in the loader. + let opened = Session::with_document(loaded); + let loaded = opened.doc().unwrap(); + let loaded_id = assert_dark_bound_color(loaded, name_transform); + assert_eq!(loaded_id, id); + assert_eq!(loaded.node(id).unwrap().source, stale.first_page()); + assert!(loaded.node(id).unwrap().overrides.contains("sentinel")); + assert!(loaded.dirty().is_empty()); + + let (fig, _) = figcraft_format::fig::export(&stale, None).unwrap(); + let (loaded, _) = crate::cmd::file::load_bytes(&fig, "bindings.fig").unwrap(); + assert_dark_bound_color(&loaded, name_transform); +} + +#[test] +fn initial_paint_settlement_preserves_existing_dirty_nodes() { + let (mut doc, _) = stale_bound_color_document(); + let page = doc.first_page().unwrap(); + doc.mark_dirty([page]); + + crate::cmd::styles::apply_paint_bindings_on_load(&mut doc); + + assert_eq!(doc.dirty().iter().copied().collect::>(), [page]); +} + #[test] fn bound_layers_follow_variable_values_and_unbind() { let mut s = Session::new(); diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 66a00129..92d7ff75 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}; @@ -23,6 +23,8 @@ const MAX_NODES: usize = crate::rest::MAX_NODES; const MAX_DEPTH: usize = 256; /// Most path commands read from one geometry blob. const MAX_COMMANDS: usize = 200_000; +const MAX_GRID_TRACKS: usize = 1_000; +const MAX_GRID_TRACK_VALUE: f64 = 1e6; /// Id of "no style". const NO_STYLE: &str = "4294967295:4294967295"; /// Longest asset `key` or `version` kept for resolving references given as assets. @@ -33,6 +35,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)); } @@ -74,11 +79,33 @@ fn guid(v: &Kv) -> Option { Some(format!("{}:{}", num(v, "sessionID")? as u64, num(v, "localID")? as u64)) } +/// An override row names a layer of the main by its current GUID or by the `overrideKey` the +/// layer had when the row was written: the key becomes the current GUID, unless the layer is +/// current already or the key is ambiguous. +fn normalize_override_segment(path: &mut [String], index: usize, scope: &OverrideScope) { + let Some(segment) = path.get(index) else { return }; + if scope.current.contains(segment) { + return; + } + if let Some(Some(current)) = scope.aliases.get(segment) { + path[index] = current.clone(); + } +} + /// A `GUID`-holding reference (`StyleId`, `VariableID`, …): its `guid`. fn ref_guid(v: &Kv) -> Option { v.get("guid").and_then(guid).filter(|g| g != NO_STYLE) } +/// The style a layer links in `key`, or in the older `legacy` field when `key` names none. An +/// explicit NO_STYLE in `key` is a detach and wins over the legacy field. +fn style_link(v: &View, key: &str, legacy: &str) -> Option { + match v.get(key).and_then(|r| r.get("guid")).and_then(guid) { + Some(id) => Some(id).filter(|g| g != NO_STYLE), + None => v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE), + } +} + fn color(c: &Kv) -> Value { let ch = |k: &str| num(c, k).unwrap_or(0.0); json!({"r": ch("r"), "g": ch("g"), "b": ch("b"), "a": num(c, "a").unwrap_or(1.0)}) @@ -142,6 +169,16 @@ impl<'a> View<'a> { v } + /// This view with `style` just above the node itself: the style beats the node's own copy of + /// its fields (stale when the style changed since), and override rows beat both. + fn styled(&self, style: Option<&'a Kv<'a>>) -> Self { + let mut v = self.clone(); + if let Some(s) = style { + v.layers.insert(v.layers.len().min(1), s); + } + v + } + fn get(&self, k: &str) -> Option<&'a Kv<'a>> { self.layers.iter().rev().find_map(|l| l.get(k)) } @@ -221,7 +258,7 @@ impl Fit { } /// Overrides and derived layout by id path. -type Overrides<'a> = BTreeMap, Vec<(Entry, &'a Kv<'a>)>>; +type Overrides<'a> = BTreeMap, Vec>>; /// Where an override entry comes from. #[derive(Clone, Copy, PartialEq)] @@ -233,6 +270,25 @@ enum Entry { Stale, } +/// One override or derived-layout row addressed to a layer. +#[derive(Clone)] +struct OverrideRow<'a> { + row: &'a Kv<'a>, + entry: Entry, + /// How many mains are being expanded around the instance that wrote the row: the rows of + /// the enclosing (outer) instances win over those of the inner ones. + source_depth: usize, + /// Position among the rows of the instance that wrote it; later rows win. + ordinal: usize, +} + +/// The layers of a main by current GUID, and the old `overrideKey` of each that has one +/// (`None` when two layers claim the same key). +struct OverrideScope { + current: BTreeSet, + aliases: BTreeMap>, +} + /// Where an instance's expanded layers are: the overrides and derived layout of the outermost /// instance (by id path), the path of the instance being expanded, and the property values. #[derive(Clone)] @@ -259,6 +315,16 @@ struct Mapper<'a> { /// Descriptions of components and component sets (REST `components` / `componentSets`). components: Obj, sets: Obj, + /// Layers and keys of each main, built when an instance of it first needs them. + override_scopes: BTreeMap>, + /// Entries held by `override_scopes` (bounded by [`MAX_NODES`] together). + override_scope_entries: usize, + /// Component-bearing branches of the file's internal canvases (cached library definitions). + library_contexts: Vec, + /// Layers visited while scanning internal canvases, across all of them. + library_scan_count: usize, + /// Most layers produced or scanned; the one place the cap comes from. + max_nodes: usize, warnings: Vec, count: usize, } @@ -268,9 +334,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 +361,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 +377,23 @@ 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(), + override_scopes: BTreeMap::new(), + override_scope_entries: 0, + 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`, @@ -337,6 +428,50 @@ impl<'a> Mapper<'a> { self.kids.get(id).map(|k| k.iter().map(|(_, c)| c.clone()).collect()).unwrap_or_default() } + /// The layers of main `main` by current GUID and by `overrideKey`. An instance nested in the + /// main is not entered: its layers belong to another main. + fn override_scope(&mut self, main: &str) -> Rc { + if let Some(scope) = self.override_scopes.get(main) { + return Rc::clone(scope); + } + let mut current = BTreeSet::new(); + let mut aliases: BTreeMap> = BTreeMap::new(); + let mut visited = BTreeSet::new(); + let mut pending: Vec<(String, usize)> = self.children(main).into_iter().map(|id| (id, 1)).collect(); + let mut complete = true; + while let Some((id, depth)) = pending.pop() { + if depth > MAX_DEPTH || visited.len() >= MAX_NODES { + complete = false; + break; + } + if !visited.insert(id.clone()) { + continue; + } + let Some(node) = self.nodes.get(&id) else { continue }; + current.insert(id.clone()); + if let Some(alias) = node.get("overrideKey").and_then(guid) { + let target = aliases.entry(alias).or_insert_with(|| Some(id.clone())); + if target.as_deref().is_some_and(|target| target != id) { + *target = None; + } + } + if node.get("type").and_then(Kv::str) != Some("INSTANCE") { + pending.extend(self.children(&id).into_iter().map(|child| (child, depth + 1))); + } + } + // A scope cut short could name the wrong layer for a key: use none of the keys. + if !complete { + aliases.clear(); + } + let scope = Rc::new(OverrideScope { current, aliases }); + let cost = scope.current.len() + scope.aliases.len(); + if cost <= MAX_NODES.saturating_sub(self.override_scope_entries) { + self.override_scope_entries += cost; + self.override_scopes.insert(main.to_string(), Rc::clone(&scope)); + } + scope + } + /// The DOCUMENT with its pages. fn document(&mut self) -> Value { let doc_id = self.nodes.iter().find(|(_, n)| n.get("type").and_then(Kv::str) == Some("DOCUMENT")).map(|(id, _)| id.clone()); @@ -345,6 +480,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 +490,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 +697,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; @@ -415,24 +725,24 @@ impl<'a> Mapper<'a> { } let base = *self.nodes.get(id)?; let path = inst.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()); - let entries = match (inst, &path) { + let entries: &[OverrideRow<'a>] = match (inst, &path) { (Some(i), Some(p)) => i.overrides.get(p).map(Vec::as_slice).unwrap_or_default(), _ => &[], }; - let mut view = View::of(base).with(entries.iter().map(|e| e.1)); + let mut view = View::of(base).with(entries.iter().map(|e| e.row)); // A layer of a resized instance the file gives no derived layout for follows its // constraints from the main's geometry, then takes the size and place overridden on it // (auto layout places the others, as in the editor). - let derived = entries.iter().any(|(d, l)| *d == Entry::Derived && (l.get("size").is_some() || l.get("transform").is_some())); + let derived = entries.iter().any(|e| e.entry == Entry::Derived && (e.row.get("size").is_some() || e.row.get("transform").is_some())); let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID")) && view.str("stackPositioning") != Some("ABSOLUTE"); let main = View::of(base); - let set = |k: &str| entries.iter().rev().filter(|e| e.0 != Entry::Stale).find_map(|(_, l)| l.get(k)); + let set = |k: &str| entries.iter().rev().filter(|e| e.entry != Entry::Stale).find_map(|e| e.row.get(k)); if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) { let t = main.transform().unwrap_or_default(); let (fw, ft) = f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint")); view.fitted = Some((set("size").and_then(size).unwrap_or(fw), set("transform").and_then(matrix).unwrap_or(ft))); - } else if entries.iter().any(|(d, l)| *d == Entry::Stale && (l.get("size").is_some() || l.get("transform").is_some())) + } else if entries.iter().any(|e| e.entry == Entry::Stale && (e.row.get("size").is_some() || e.row.get("transform").is_some())) && let Some(s) = set("size").and_then(size).or_else(|| main.size()) { view.fitted = Some((s, set("transform").and_then(matrix).or_else(|| main.transform()).unwrap_or_default())); @@ -457,6 +767,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 +806,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 +819,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") { @@ -719,7 +1035,7 @@ impl<'a> Mapper<'a> { /// The style node a layer links in `key` (or the older `legacy` field), recorded for the REST /// `styles` map under `slot`. fn style(&mut self, v: &View<'a>, key: &str, legacy: &str, slot: &str, out: &mut Obj) -> Option<&'a Kv<'a>> { - let id = v.get(key).and_then(ref_guid).or_else(|| v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE))?; + let id = style_link(v, key, legacy)?; let node = *self.nodes.get(&id)?; node.get("styleType").and_then(Kv::str)?; out.insert(slot.into(), json!(id)); @@ -775,35 +1091,100 @@ impl<'a> Mapper<'a> { copy_num(o, v, "gridRowGap", "gridRowGap"); o.insert("gridColumnCount".into(), json!(tracks(v, "gridColumns").len())); o.insert("gridRowCount".into(), json!(tracks(v, "gridRows").len())); - let sizes = ["gridColumnsSizing", "gridRowsSizing"] - .iter() - .flat_map(|k| v.get(k).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default()); - let flexible = sizes.into_iter().all(|e| { - let t = e.get("trackSize"); - ["minSizing", "maxSizing"] - .iter() - .all(|s| t.and_then(|t| t.get(s)).and_then(|s| s.get("type")).and_then(Kv::str).is_none_or(|x| x == "FLEX")) - }); - if !flexible { - self.warn("grid tracks of fixed or content size import as equal tracks"); + let track_data = [("columnTracks", "gridColumns", "gridColumnsSizing"), ("rowTracks", "gridRows", "gridRowsSizing")] + .into_iter() + .filter_map(|(out_key, order_key, sizing_key)| { + self.map_grid_tracks(v, order_key, sizing_key).map(|mapped| (out_key.to_string(), mapped)) + }) + .collect::(); + if !track_data.is_empty() { + let source = o.entry(crate::rest::FIG_SOURCE_FIELD).or_insert_with(|| json!({})); + if let Some(source) = source.as_object_mut() { + source.insert("gridTracks".into(), Value::Object(track_data)); + } } } } + /// Track sizing maps are keyed by GUID; their order comes from the separate track list. + fn map_grid_tracks(&mut self, v: &View<'a>, order_key: &str, sizing_key: &str) -> Option { + let track_entries = v.get(order_key).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default(); + let sizes = v.get(sizing_key).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default(); + if track_entries.len() > MAX_GRID_TRACKS { + self.warn("grid track list exceeds 1000 entries; the tracks use the default equal sizing"); + return None; + } + if sizes.is_empty() { + return None; + } + if sizes.len() > MAX_GRID_TRACKS { + self.warn("grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing"); + return None; + } + let ordered = tracks(v, order_key); + let ordered_ids: BTreeSet<&str> = ordered.iter().map(String::as_str).collect(); + let mut size_by_id: BTreeMap>> = BTreeMap::new(); + for entry in sizes { + let Some(id) = entry.get("id").and_then(guid) else { + self.warn("stale or invalid grid track sizing entries were ignored"); + continue; + }; + if !ordered_ids.contains(id.as_str()) { + self.warn("stale or invalid grid track sizing entries were ignored"); + continue; + } + match size_by_id.entry(id) { + std::collections::btree_map::Entry::Vacant(slot) => { + slot.insert(Some(entry)); + } + std::collections::btree_map::Entry::Occupied(mut slot) => { + slot.insert(None); + self.warn("duplicate grid track sizing entries use equal tracks"); + } + } + } + let mut mapped = Vec::with_capacity(ordered.len()); + for id in ordered { + if let Some(Some(entry)) = size_by_id.get(&id) { + match grid_track_sizing(entry.get("trackSize")) { + Some(track) => mapped.push(track), + None => { + self.warn("grid track sizing is unsupported or malformed; affected tracks use equal sizing"); + mapped.push(json!({"sizing": "fill", "value": 1.0})); + } + } + } else { + mapped.push(json!({"sizing": "fill", "value": 1.0})); + } + } + Some(Value::Array(mapped)) + } + fn text(&mut self, o: &mut Obj, v: &View<'a>) { - let style_node = v - .get("styleIdForText") - .and_then(ref_guid) - .or_else(|| v.get("inheritTextStyleID").and_then(guid)) - .and_then(|s| self.nodes.get(&s).copied()); - let sv = v.with(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT"))); + let style_node = style_link(v, "styleIdForText", "inheritTextStyleID").and_then(|s| self.nodes.get(&s).copied()); + // Instance overrides beat the shared style; the style beats the layer's own copy. + let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT"))); let td = v.get("textData"); let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or(""); o.insert("characters".into(), json!(chars)); let mut style = text_style(&sv, None); + // The style's typography beats the layer's own copy of it (the copy is stale when the style + // changed). The file cannot tell a stale copy of these four values from a deliberate local + // one, and "original", "none" and 0 could never be set locally if the style always won, so + // a value the layer or an override sets itself is taken as deliberate. + if let Some(decoration @ ("NONE" | "UNDERLINE" | "STRIKETHROUGH")) = v.str("textDecoration") { + style.insert("textDecoration".into(), json!(decoration)); + } + if let Some(case @ ("ORIGINAL" | "UPPER" | "LOWER" | "TITLE" | "SMALL_CAPS" | "SMALL_CAPS_FORCED")) = v.str("textCase") { + style.insert("textCase".into(), json!(case)); + } for k in ["textAlignHorizontal", "textAlignVertical", "textAutoResize", "textTruncation"] { copy_str(&mut style, v, k, k); } + // Paragraph settings belong to the effective text layer, including explicit zero clears. + for k in ["paragraphSpacing", "paragraphIndent"] { + copy_num(&mut style, v, k, k); + } if let Some(n) = v.num("maxLines").filter(|n| *n >= 1.0) { style.insert("maxLines".into(), json!(n)); } @@ -813,20 +1194,7 @@ impl<'a> Mapper<'a> { } o.insert("style".into(), Value::Object(style)); let Some(td) = td else { return }; - let ids: Vec = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect(); - if ids.iter().any(|i| *i != 0) { - let mut table = Obj::new(); - for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() { - let Some(sid) = num(e, "styleID") else { continue }; - let mut s = text_style(&View::of(e), size); - if let Some(fills) = e.get("fillPaints") { - s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(paint).collect())); - } - table.insert((sid as u64).to_string(), Value::Object(s)); - } - o.insert("characterStyleOverrides".into(), json!(ids)); - o.insert("styleOverrideTable".into(), Value::Object(table)); - } + char_styles(o, td, size); let lines: Vec<&str> = td .get("lines") .map(Kv::list) @@ -880,10 +1248,13 @@ impl<'a> Mapper<'a> { fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool { let Some(vd) = v.get("vectorData") else { return false }; let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false }; - let Some(net) = network::decode(blob) else { + let Some((net, styled_segments)) = network::decode_styled(blob) else { self.warn("damaged vector networks import as outlines"); return false; }; + if styled_segments > 0 { + self.warn("stroke styles of single vector network segments were left out"); + } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = o.get("fills").and_then(Value::as_array).is_some_and(|f| f.iter().any(|p| p.get("visible").and_then(Value::as_bool) != Some(false))); @@ -1011,6 +1382,16 @@ impl<'a> Mapper<'a> { "characters" => { if let Some(t) = prop_text(val) { o.insert("characters".into(), json!(t)); + // Run styles count the chars they were made for: for other chars only + // the value's own apply. + if own_text(v) != Some(t) { + o.remove("characterStyleOverrides"); + o.remove("styleOverrideTable"); + if let Some(tv) = val.get("textValue") { + let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64); + char_styles(o, tv, size); + } + } } } _ => {} @@ -1064,7 +1445,18 @@ impl<'a> Mapper<'a> { } // Overrides by id path below this instance (the instance itself is addressed by its main's id). let own_path: Vec = outer.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()).unwrap_or_default(); - let mut overrides: Overrides<'a> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default(); + let scope = self.override_scope(&sym); + let mut overrides: Overrides<'a> = BTreeMap::new(); + if let Some(inherited) = outer { + for (path, rows) in inherited.overrides.iter() { + let mut path = path.clone(); + // Rows an outer instance wrote for this instance's layers may use their old keys. + if path.starts_with(&own_path) && path.len() > own_path.len() { + normalize_override_segment(&mut path, own_path.len(), &scope); + } + overrides.entry(path).or_default().extend(rows.iter().cloned()); + } + } // A nested instance's derived layout is for the size it has in its main: resized here, the // sizes and places in it no longer hold and its layers follow their constraints instead. let differs = |a: f64, b: f64| (a - b).abs() > 1e-6 * a.abs().max(b.abs()).max(1.0); @@ -1073,30 +1465,41 @@ impl<'a> Mapper<'a> { (Some(a), Some(b)) => differs(a.0, b.0) || differs(a.1, b.1), (a, b) => a.is_some() != b.is_some(), }; + let mut local: Overrides<'a> = BTreeMap::new(); let lists = [sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), v.list("derivedSymbolData")]; + let source_depth = outer.map_or(0, |i| i.symbols.len()); + let mut ordinal = 0; for (layer, list) in lists.into_iter().enumerate() { for ov in list { - let rel: Vec = + let row_ordinal = ordinal; + ordinal += 1; + let mut rel: Vec = ov.get("guidPath").and_then(|g| g.get("guids")).map(Kv::list).unwrap_or_default().iter().filter_map(guid).collect(); if rel.is_empty() || rel == [sym.clone()] { continue; } - let e = overrides.entry([own_path.as_slice(), &rel].concat()).or_default(); - // The outer instance's overrides win over this one's own; derived layout comes last. - if layer == 0 && outer.is_some() { - e.insert(0, (Entry::Override, ov)); - } else { - e.push(( - match (layer, resized) { - (0, _) => Entry::Override, - (_, false) => Entry::Derived, - (_, true) => Entry::Stale, - }, - ov, - )); - } + normalize_override_segment(&mut rel, 0, &scope); + // Explicit overrides, then derived layout (stale once the instance is resized). + let entry = match (layer, resized) { + (0, _) => Entry::Override, + (_, false) => Entry::Derived, + (_, true) => Entry::Stale, + }; + local.entry([own_path.as_slice(), &rel].concat()).or_default().push(OverrideRow { + row: ov, + entry, + source_depth, + ordinal: row_ordinal, + }); } } + for (path, rows) in local { + overrides.entry(path).or_default().extend(rows); + } + for rows in overrides.values_mut() { + // Deeper source rows apply first; outer rows win, with wire order preserved per source. + rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth), row.ordinal)); + } let mut all_props: BTreeMap> = outer.map(|i| (*i.props).clone()).unwrap_or_default(); for a in assignments { if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) { @@ -1671,6 +2074,32 @@ fn tracks(v: &View, key: &str) -> Vec { t.into_iter().map(|e| e.1).collect() } +/// Convert only unconstrained tracks, whose min and max sizing functions agree. +fn grid_track_sizing(size: Option<&Kv<'_>>) -> Option { + let size = size?; + let min = size.get("minSizing")?; + let max = size.get("maxSizing")?; + let min_type = min.get("type").and_then(Kv::str)?; + let max_type = max.get("type").and_then(Kv::str)?; + if min_type != max_type { + return None; + } + match min_type { + "FIXED" => { + let px = num(min, "value")?; + let max_px = num(max, "value")?; + (px == max_px && px.is_finite() && (0.0..=MAX_GRID_TRACK_VALUE).contains(&px)).then(|| json!({"sizing": "fixed", "value": px})) + } + "FLEX" => { + let fr = num(min, "value")?; + let max_fr = num(max, "value")?; + (fr == max_fr && fr.is_finite() && fr > 0.0 && fr <= MAX_GRID_TRACK_VALUE).then(|| json!({"sizing": "fill", "value": fr})) + } + "HUG" => Some(json!({"sizing": "hug", "value": 0.0})), + _ => None, + } +} + /// The variable an `ALIAS` variable data points at. fn alias(d: Option<&Kv>) -> Option { let d = d?; @@ -1697,6 +2126,26 @@ fn variable_value(d: &Kv) -> Option { v.get("textValue").and_then(Kv::str).map(|s| json!(s)) } +/// Per-character styles of text data `td` (`characterStyleIDs` + `styleOverrideTable`) as REST +/// `characterStyleOverrides` + `styleOverrideTable`; `size`: the base font size. +fn char_styles(o: &mut Obj, td: &Kv, size: Option) { + let ids: Vec = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect(); + if ids.iter().all(|i| *i == 0) { + return; + } + let mut table = Obj::new(); + for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() { + let Some(sid) = num(e, "styleID") else { continue }; + let mut s = text_style(&View::of(e), size); + if let Some(fills) = e.get("fillPaints") { + s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(paint).collect())); + } + table.insert((sid as u64).to_string(), Value::Object(s)); + } + o.insert("characterStyleOverrides".into(), json!(ids)); + o.insert("styleOverrideTable".into(), Value::Object(table)); +} + /// The text of a component property value. fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> { v.get("textValue").and_then(|t| t.get("characters")).and_then(Kv::str) @@ -1734,7 +2183,11 @@ fn text_style(v: &View, size: Option) -> Obj { let val = num(ls, "value").unwrap_or(0.0); let px = match ls.get("units").and_then(Kv::str) { Some("PIXELS") => val, - Some("PERCENT") => val / 100.0 * font_size, + Some("PERCENT") => { + // Private (read by `rest/text.rs`): REST spacing is in pixels; keep the percent. + s.insert("_letterSpacingPercent".into(), json!(val)); + val / 100.0 * font_size + } _ => val * font_size, }; s.insert("letterSpacing".into(), json!(px)); diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index ec2a0e23..81457f8d 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -21,7 +21,11 @@ pub mod kiwi; mod map; mod network; #[cfg(test)] +mod source_identity_tests; +#[cfg(test)] mod tests; +#[cfg(test)] +mod text_style_tests; use std::io::Read; @@ -64,7 +68,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/network.rs b/crates/format/src/fig/network.rs index 112a767a..92ad4898 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -64,8 +64,10 @@ impl Reader<'_> { } } -/// The network in blob `b`; `None` when it's truncated, out of range or not finite. -pub(super) fn decode(b: &[u8]) -> Option { +/// The network in blob `b` and how many of its segments have a style id of their own (per-segment +/// stroke overrides, which the document model can't hold); `None` when it's truncated, out of +/// range or not finite. +pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { let mut r = Reader { b, pos: 0 }; let (nv, ns, nr) = (r.u32()? as usize, r.u32()? as usize, r.u32()? as usize); if nv > MAX_ITEMS || ns > MAX_ITEMS || nr > MAX_ITEMS || nv * 12 + ns * 28 + nr * 8 > b.len().saturating_sub(12) { @@ -75,8 +77,11 @@ pub(super) fn decode(b: &[u8]) -> Option { for _ in 0..nv { net.vertices.push(Vertex { style: r.u32()?, x: r.f32()?, y: r.f32()? }); } + let mut styled = 0usize; for _ in 0..ns { - r.u32()?; + if r.u32()? != 0 { + styled += 1; + } net.segments.push(Segment { a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); } let mut entries = 0usize; @@ -93,7 +98,7 @@ pub(super) fn decode(b: &[u8]) -> Option { } net.regions.push(Region { style: sw >> 1, non_zero: sw & 1 == 1, loops }); } - Some(net) + Some((net, styled)) } /// The blob of `net` (the export writes it; so do test fixtures). Callers pass finite values. 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..6667e4a2 --- /dev/null +++ b/crates/format/src/fig/source_identity_tests.rs @@ -0,0 +1,1120 @@ +//! 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 alias_schema() -> Schema { + let schema = export::SCHEMA.replace("message NodeChange GUID guid=1,", "message NodeChange GUID guid=1, GUID overrideKey=500,"); + Schema::parse(&schema).unwrap() +} + +fn alias(id: u64) -> Fields { + vec![("overrideKey", g(id))] +} + +fn import_alias_message(message: Kv<'static>) -> Document { + let schema = alias_schema(); + let bytes = super::tests::canvas(&schema, &message); + let (doc, warnings) = import(&bytes, "Alias.fig").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + doc +} + +fn fill_rgb(doc: &Document, name: &str) -> Option<[f64; 3]> { + let node = doc.nodes().find(|n| n.name == name)?; + fill_rgb_node(doc, node.id) +} + +fn alias_priority_message() -> Kv<'static> { + let nested_local = symbol(10, vec![row(&[112], vec![("fillPaints", Kv::Array(vec![solid(0.0, 0.0, 1.0)]))])]); + o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(10, Some(2), "SYMBOL", "Inner main", vec![]), + node(11, Some(10), "FRAME", "Normal frame", vec![]), + node(12, Some(11), "ROUNDED_RECTANGLE", "Alias leaf", alias(112)), + node(20, Some(2), "SYMBOL", "Outer main", vec![]), + node( + 21, + Some(20), + "INSTANCE", + "Nested slot", + vec![ + ("overrideKey", g(221)), + ("symbolData", nested_local), + ("derivedSymbolData", Kv::Array(vec![row(&[112], vec![("opacity", Kv::Float(0.25))])])), + ], + ), + node( + 30, + Some(2), + "INSTANCE", + "Alias copy", + vec![ + ( + "symbolData", + symbol( + 20, + vec![ + row(&[221, 112], vec![("fillPaints", Kv::Array(vec![solid(0.0, 1.0, 0.0)]))]), + row(&[21, 12], vec![("fillPaints", Kv::Array(vec![solid(1.0, 0.0, 0.0)]))]), + ], + ), + ), + ("derivedSymbolData", Kv::Array(vec![row(&[221, 112], vec![("fillPaints", Kv::Array(vec![solid(1.0, 0.5, 0.0)]))])])), + ], + ), + ]), + )]) +} + +#[test] +fn override_aliases_normalize_lazily_and_keep_outer_wire_order() { + let doc = import_alias_message(alias_priority_message()); + let main = named(&doc, "Inner main"); + let leaf = child(&doc, child(&doc, main, 0), 0); + let outer_main = named(&doc, "Outer main"); + let main_slot = child(&doc, outer_main, 0); + let main_leaf = child(&doc, child(&doc, main_slot, 0), 0); + let copy = named(&doc, "Alias copy"); + let nested = child(&doc, copy, 0); + let frame = child(&doc, nested, 0); + let expanded_leaf = child(&doc, frame, 0); + assert_eq!(doc.node(main_leaf).unwrap().source, Some(leaf)); + assert_eq!(doc.node(expanded_leaf).unwrap().source, Some(main_leaf)); + assert_eq!(fill_rgb_node(&doc, expanded_leaf), Some([1.0, 0.5, 0.0])); + assert_eq!(doc.node(expanded_leaf).unwrap().opacity, 0.25); + assert_eq!(fill_rgb_node(&doc, leaf), None, "source node stays unchanged"); +} + +#[test] +fn selected_swapped_main_owns_alias_resolution_scope() { + 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), "SYMBOL", "Old main", vec![]), + node(11, Some(10), "ROUNDED_RECTANGLE", "Old leaf", alias(112)), + node(20, Some(2), "SYMBOL", "Swapped main", vec![]), + node(21, Some(20), "ROUNDED_RECTANGLE", "Swapped leaf", alias(112)), + node( + 30, + Some(2), + "INSTANCE", + "Swap", + vec![ + ("overriddenSymbolID", g(20)), + ("symbolData", symbol(10, vec![row(&[112], vec![("fillPaints", Kv::Array(vec![solid(1.0, 0.0, 0.0)]))])])), + ], + ), + ]), + )]); + let doc = import_alias_message(message); + let copy = named(&doc, "Swap"); + let source = named(&doc, "Swapped leaf"); + let expanded = child(&doc, copy, 0); + assert_eq!(doc.node(expanded).unwrap().source, Some(source)); + assert_eq!(fill_rgb_node(&doc, expanded), Some([1.0, 0.0, 0.0])); + assert_eq!(fill_rgb(&doc, "Old leaf"), None); +} + +#[test] +fn ambiguous_alias_is_ignored_but_current_guid_still_matches() { + 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), "SYMBOL", "Main", vec![]), + node(11, Some(10), "ROUNDED_RECTANGLE", "First", alias(13)), + node(12, Some(10), "ROUNDED_RECTANGLE", "Second", alias(112)), + node(13, Some(10), "ROUNDED_RECTANGLE", "Current GUID", alias(112)), + node( + 20, + Some(2), + "INSTANCE", + "Copy", + vec![( + "symbolData", + symbol( + 10, + vec![ + row(&[112], vec![("fillPaints", Kv::Array(vec![solid(1.0, 0.0, 0.0)]))]), + row(&[13], vec![("fillPaints", Kv::Array(vec![solid(0.0, 0.0, 1.0)]))]), + ], + ), + )], + ), + ]), + )]); + let doc = import_alias_message(message); + let copy = named(&doc, "Copy"); + let first = child(&doc, copy, 0); + let second = child(&doc, copy, 1); + let current = child(&doc, copy, 2); + assert_eq!(doc.node(first).unwrap().source, Some(named(&doc, "First"))); + assert_eq!(fill_rgb_node(&doc, first), None, "ambiguous alias must not be guessed"); + assert_eq!(doc.node(second).unwrap().source, Some(named(&doc, "Second"))); + assert_eq!(fill_rgb_node(&doc, second), None, "ambiguous alias must not match either target"); + assert_eq!(doc.node(current).unwrap().source, Some(named(&doc, "Current GUID"))); + assert_eq!(fill_rgb_node(&doc, current), Some([0.0, 0.0, 1.0]), "a current GUID outranks an alias with the same key"); +} + +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:?}"); + } +} + +fn no_style() -> Kv<'static> { + o(vec![("sessionID", Kv::Uint(4_294_967_295)), ("localID", Kv::Uint(4_294_967_295))]) +} + +#[test] +fn an_explicit_no_style_detaches_and_beats_the_older_style_field() { + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(5, Some(2), "RECTANGLE", "Red", vec![("styleType", Kv::Enum("FILL"))]), + node(6, Some(2), "RECTANGLE", "Box", vec![("styleIdForFill", o(vec![("guid", no_style())])), ("inheritFillStyleID", g(5))]), + node(7, Some(2), "RECTANGLE", "Linked", vec![("inheritFillStyleID", g(5))]), + ]), + )]); + let (json, _) = super::map::to_rest(&message, "Detach"); + let find = |name: &str| json["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["name"] == name).unwrap().clone(); + assert!(find("Box")["styles"].get("fill").is_none(), "{}", find("Box")["styles"]); + assert!(find("Linked")["styles"].get("fill").is_some(), "the older field still links when nothing detaches"); +} diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index fc0c5815..19618044 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -262,6 +262,25 @@ fn vectors_import_as_editable_networks() { assert!(warnings.iter().any(|w| w.contains("damaged vector networks import as outlines")), "{warnings:?}"); } +#[test] +fn per_segment_stroke_styles_are_reported() { + let mut blob = square_blob(); + // The first segment's style id follows the counts and the vertices (12 bytes each). + blob[12 + 4 * 12..12 + 4 * 12 + 4].copy_from_slice(&7u32.to_le_bytes()); + let (net, styled) = network::decode_styled(&blob).unwrap(); + assert_eq!((net.segments.len(), styled), (4, 1)); + assert_eq!(network::decode_styled(&square_blob()).unwrap().1, 0); + let plain = import(&tail_canvas(), "Tail").unwrap().1; + assert!(!plain.iter().any(|w| w.contains("stroke styles of single")), "{plain:?}"); + let mut message = tail_message(); + let Kv::Object(fields) = &mut message else { panic!() }; + let Kv::Array(blobs) = &mut fields.iter_mut().find(|(key, _)| *key == "blobs").unwrap().1 else { panic!() }; + blobs[1] = o(vec![("bytes", Kv::Bytes(blob))]); + let (doc, warnings) = import(&canvas(&schema(), &message), "Tail").unwrap(); + assert!(warnings.iter().any(|w| w.contains("stroke styles of single vector network segments were left out")), "{warnings:?}"); + assert_eq!(network_of(&doc, "Wave").segments.len(), 4); +} + #[test] fn other_layer_kinds_import_as_the_closest_layers() { let (doc, warnings) = import(&tail_canvas(), "Tail").unwrap(); @@ -336,9 +355,9 @@ fn unreadable_conditions_skip_the_conditional() { #[test] fn damaged_network_blobs_never_panic() { let good = square_blob(); - assert!(network::decode(&good).is_some()); + assert!(network::decode_styled(&good).is_some()); for cut in 0..good.len() { - assert!(network::decode(&good[..cut]).is_none(), "{cut}"); + assert!(network::decode_styled(&good[..cut]).is_none(), "{cut}"); } let mut r = Lcg(5); for _ in 0..5000 { @@ -347,7 +366,7 @@ fn damaged_network_blobs_never_panic() { let i = (r.next() as usize) % m.len(); m[i] ^= 1 << (r.next() % 8); } - if let Some(net) = network::decode(&m) { + if let Some((net, _)) = network::decode_styled(&m) { assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len())); assert!(net.regions.iter().flat_map(|r| &r.loops).flatten().all(|i| *i < net.segments.len())); assert!(net.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())); @@ -356,13 +375,13 @@ fn damaged_network_blobs_never_panic() { // Counts larger than the blob, and indices out of range. let mut huge = vec![0xff; 12]; huge.extend_from_slice(&[0; 64]); - assert!(network::decode(&huge).is_none()); + assert!(network::decode_styled(&huge).is_none()); let mut bad = arrow_blob(); bad[12 + 24 + 4..12 + 24 + 8].copy_from_slice(&7u32.to_le_bytes()); - assert!(network::decode(&bad).is_none()); + assert!(network::decode_styled(&bad).is_none()); for _ in 0..2000 { let junk: Vec = (0..(r.next() % 120)).map(|_| r.next() as u8).collect(); - let _ = network::decode(&junk); + let _ = network::decode_styled(&junk); } } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..65c8ed38 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -390,6 +390,175 @@ fn named<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::N doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}")) } +/// The shared schema plus the grid sizing definitions only these tests use. +fn grid_schema() -> Schema { + const NODE_FIELDS: &str = ", GridTrackMap gridColumns, GridTrackMap gridRows, GridTrackSizeMap gridColumnsSizing, GridTrackSizeMap gridRowsSizing, GUID gridColumnAnchor, GUID gridRowAnchor, uint gridColumnSpan, uint gridRowSpan, float gridColumnGap, float gridRowGap"; + const TYPES: &str = " +enum GridTrackSizingType FLEX FIXED HUG UNKNOWN +message GridTrackSizeFunction GridTrackSizingType type, float value +message GridTrackSize GridTrackSizeFunction minSizing, GridTrackSizeFunction maxSizing +message GridTrackSizeEntry GUID id, GridTrackSize trackSize +message GridTrackSizeMap GridTrackSizeEntry[] entries +message GridTrackEntry GUID id, string position +message GridTrackMap GridTrackEntry[] entries +message Blob "; + let text = SCHEMA.replace("enum StackMode NONE HORIZONTAL VERTICAL\n", "enum StackMode NONE HORIZONTAL VERTICAL GRID\n").replacen( + "\nmessage Blob ", + &format!("{NODE_FIELDS}{TYPES}"), + 1, + ); + Schema::parse(&text).unwrap() +} + +fn track_size(kind: &'static str, value: f64) -> Kv<'static> { + let sizing = || o(vec![("type", e(kind)), ("value", f(value))]); + o(vec![("minSizing", sizing()), ("maxSizing", sizing())]) +} + +fn ranged_track_size(min: Kv<'static>, max: Kv<'static>) -> Kv<'static> { + o(vec![("minSizing", min), ("maxSizing", max)]) +} + +fn track_map_entry(id: u64, size: Kv<'static>) -> Kv<'static> { + o(vec![("id", g(id)), ("trackSize", size)]) +} + +fn positioned_track(id: u64, position: &'static str) -> Kv<'static> { + o(vec![("id", g(id)), ("position", s(position))]) +} + +fn unpositioned_track(id: u64) -> Kv<'static> { + o(vec![("id", g(id))]) +} + +#[test] +fn grid_tracks_import_by_guid_and_keep_track_order() { + let cols = arr(vec![positioned_track(13, "$"), unpositioned_track(10), positioned_track(12, "#"), unpositioned_track(11)]); + let rows = arr(vec![positioned_track(21, "\""), positioned_track(20, "!")]); + // Sizing entries intentionally differ from track order. Their GUIDs define the join. + let col_sizes = arr(vec![ + track_map_entry(12, track_size("FLEX", 1.0)), + track_map_entry(10, track_size("FIXED", 120.0)), + track_map_entry(13, track_size("FLEX", 2.0)), + track_map_entry(11, track_size("HUG", 80.0)), + ]); + let row_sizes = arr(vec![track_map_entry(21, track_size("HUG", 30.0)), track_map_entry(20, track_size("FIXED", 50.0))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node( + 2, + Some((1, "!")), + "FRAME", + "Grid", + vec![ + ("size", xy(600.0, 300.0)), + ("transform", at(0.0, 0.0)), + ("stackMode", e("GRID")), + ("gridColumns", o(vec![("entries", cols)])), + ("gridRows", o(vec![("entries", rows)])), + ("gridColumnsSizing", o(vec![("entries", col_sizes)])), + ("gridRowsSizing", o(vec![("entries", row_sizes)])), + ("gridColumnGap", f(10.0)), + ("gridRowGap", f(12.0)), + ], + ), + node( + 3, + Some((2, "!")), + "RECTANGLE", + "Hug child", + vec![ + ("size", xy(80.0, 30.0)), + ("transform", at(0.0, 0.0)), + ("gridColumnAnchor", g(11)), + ("gridRowAnchor", g(21)), + ("gridColumnSpan", u(2)), + ("gridRowSpan", u(1)), + ], + ), + ]; + let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); + let (doc, warnings) = import(&bytes, "Grid tracks").unwrap(); + let grid = named(&doc, "Grid").layout.as_ref().unwrap(); + assert_eq!((grid.mode, grid.grid_columns, grid.grid_rows, grid.gap, grid.cross_gap), (figcraft_doc::LayoutMode::Grid, 4, 2, 10.0, 12.0)); + assert_eq!( + grid.column_tracks, + vec![ + figcraft_doc::GridTrack::fixed(120.0), + figcraft_doc::GridTrack::hug(), + figcraft_doc::GridTrack::fill(1.0), + figcraft_doc::GridTrack::fill(2.0), + ] + ); + assert_eq!(grid.row_tracks, vec![figcraft_doc::GridTrack::fixed(50.0), figcraft_doc::GridTrack::hug()]); + let child = &named(&doc, "Hug child").child_layout; + assert_eq!((child.grid_column, child.grid_row, child.grid_column_span, child.grid_row_span), (Some(1), Some(1), 2, 1)); + assert!(!warnings.iter().any(|w| w.contains("grid track")), "{warnings:?}"); +} + +#[test] +fn malformed_grid_tracks_warn_and_keep_equal_track_fallbacks() { + let tracks = arr(vec![positioned_track(30, "!"), positioned_track(31, "\""), positioned_track(32, "#"), positioned_track(33, "$")]); + let sizing = arr(vec![ + track_map_entry(30, track_size("FIXED", -1.0)), + track_map_entry(31, ranged_track_size(o(vec![("type", e("FLEX")), ("value", f(1.0))]), o(vec![("type", e("FLEX")), ("value", f(2.0))]))), + track_map_entry(33, track_size("UNKNOWN", 5.0)), + track_map_entry(34, track_size("FLEX", 3.0)), + ]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node( + 2, + Some((1, "!")), + "FRAME", + "Grid", + vec![ + ("size", xy(100.0, 100.0)), + ("transform", at(0.0, 0.0)), + ("stackMode", e("GRID")), + ("gridColumns", o(vec![("entries", tracks)])), + ("gridColumnsSizing", o(vec![("entries", sizing)])), + ], + ), + ]; + let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); + let (doc, warnings) = import(&bytes, "Malformed grid tracks").unwrap(); + let layout = named(&doc, "Grid").layout.as_ref().unwrap(); + assert_eq!(layout.column_tracks, vec![figcraft_doc::GridTrack::default(); 4]); + assert!(warnings.iter().any(|w| w.contains("grid track sizing is unsupported or malformed")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("stale or invalid grid track sizing entries")), "{warnings:?}"); +} + +#[test] +fn oversized_grid_track_lists_warn_and_use_default_tracks() { + let oversized: Vec> = (0..1_001).map(|i| unpositioned_track(100 + i)).collect(); + let sizing = vec![track_map_entry(100, track_size("FIXED", 40.0))]; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node( + 2, + Some((1, "!")), + "FRAME", + "Grid", + vec![ + ("size", xy(100.0, 100.0)), + ("transform", at(0.0, 0.0)), + ("stackMode", e("GRID")), + ("gridColumns", o(vec![("entries", arr(oversized))])), + ("gridColumnsSizing", o(vec![("entries", arr(sizing))])), + ], + ), + ]; + let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); + let (doc, warnings) = import(&bytes, "Oversized grid tracks").unwrap(); + let layout = named(&doc, "Grid").layout.as_ref().unwrap(); + assert!(layout.column_tracks.is_empty(), "{:?}", layout.column_tracks.len()); + assert!(warnings.iter().any(|w| w.contains("grid track list exceeds 1000 entries")), "{warnings:?}"); +} + #[test] fn kiwi_round_trip() { let sc = schema(); diff --git a/crates/format/src/fig/text_style_tests.rs b/crates/format/src/fig/text_style_tests.rs new file mode 100644 index 00000000..a5f135e7 --- /dev/null +++ b/crates/format/src/fig/text_style_tests.rs @@ -0,0 +1,292 @@ +//! Text layers and their shared text styles on import: which of the style, the layer's own +//! values and an instance's overrides win, on messages built in code. + +use figcraft_doc::{Document, LetterSpacing, StyleKind, TextCase, TextDecoration}; +use serde_json::Value; + +use super::kiwi::Kv; + +type Fields = Vec<(&'static str, Kv<'static>)>; + +const NO_STYLE: u64 = 4_294_967_295; + +fn object(fields: Fields) -> Kv<'static> { + Kv::Object(fields) +} + +fn guid(id: u64) -> Kv<'static> { + object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(id))]) +} + +fn link(id: u64) -> Kv<'static> { + object(vec![("guid", guid(id))]) +} + +fn node(id: u64, ty: &'static str, name: &str, parent: Option, mut more: Fields) -> Kv<'static> { + let mut fields = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + fields.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))]))); + } + fields.append(&mut more); + object(fields) +} + +fn font(family: &str) -> Kv<'static> { + object(vec![("family", Kv::Str(family.into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))]) +} + +fn chars(text: &str) -> Kv<'static> { + object(vec![("characters", Kv::Str(text.into()))]) +} + +/// The REST JSON of a page holding `nodes`. +fn rest(nodes: Vec>) -> Value { + let base = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; + let message = object(vec![("nodeChanges", Kv::Array(base.into_iter().chain(nodes).collect()))]); + super::map::to_rest(&message, "Synthetic text").0 +} + +fn import(value: &Value) -> Document { + let (doc, warnings) = crate::rest::import_fig(value, &|_| None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + doc +} + +/// Depth-first search for a REST node by name below `v`. +fn find<'v>(v: &'v Value, name: &str) -> Option<&'v Value> { + if v["name"] == name { + return Some(v); + } + v["children"].as_array()?.iter().find_map(|c| find(c, name)) +} + +/// A shared text style (Inter, 20 px, with `shared` fields) and a layer linking it whose own +/// copy of the typography is stale (40 px, another family) and which sets `local`. +fn linked(shared: Fields, local: Fields) -> Document { + let mut definition = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(20.0))]; + definition.extend(shared); + let mut layer = vec![("fontName", font("Stale copy")), ("fontSize", Kv::Float(40.0)), ("styleIdForText", link(10)), ("textData", chars("a\nb"))]; + layer.extend(local); + import(&rest(vec![node(10, "TEXT", "Shared", Some(2), definition), node(11, "TEXT", "Label", Some(2), layer)])) +} + +struct Seen { + case: TextCase, + decoration: TextDecoration, + spacing: f64, + indent: f64, + shared_case: TextCase, +} + +fn seen(doc: &Document) -> Seen { + let label = doc.nodes().find(|n| n.name == "Label").unwrap(); + let text = label.kind.text().unwrap(); + // The shared style's typography beats the layer's stale copy, whatever the layer sets locally. + assert_eq!((text.style.font_family.as_str(), text.style.font_size), ("Inter", 20.0)); + let linked = doc.style(label.styles.text.as_deref().unwrap()).unwrap(); + assert_eq!(linked.name, "Shared"); + let StyleKind::Text { style } = &linked.kind else { panic!("the linked definition must stay a text style") }; + Seen { + case: text.style.case, + decoration: text.style.decoration, + spacing: text.paragraph_spacing, + indent: text.paragraph_indent, + shared_case: style.case, + } +} + +#[test] +fn local_case_decoration_and_paragraph_values_beat_the_shared_style() { + let shared = vec![ + ("textCase", Kv::Enum("UPPER")), + ("textDecoration", Kv::Enum("UNDERLINE")), + ("paragraphSpacing", Kv::Float(24.0)), + ("paragraphIndent", Kv::Float(12.0)), + ]; + // Nothing set on the layer: the style's values stay. + let s = seen(&linked(shared.clone(), vec![])); + assert_eq!((s.case, s.decoration, s.spacing, s.indent), (TextCase::Upper, TextDecoration::Underline, 24.0, 12.0)); + // Explicit values on the layer win, and "original", "none" and zero clear the style's. + let cleared = vec![ + ("textCase", Kv::Enum("ORIGINAL")), + ("textDecoration", Kv::Enum("NONE")), + ("paragraphSpacing", Kv::Float(0.0)), + ("paragraphIndent", Kv::Float(0.0)), + ]; + let s = seen(&linked(shared.clone(), cleared)); + assert_eq!((s.case, s.decoration, s.spacing, s.indent), (TextCase::Original, TextDecoration::None, 0.0, 0.0)); + assert_eq!(s.shared_case, TextCase::Upper, "a local value must not change the definition"); + let local = vec![ + ("textCase", Kv::Enum("SMALL_CAPS_FORCED")), + ("textDecoration", Kv::Enum("STRIKETHROUGH")), + ("paragraphSpacing", Kv::Float(6.0)), + ("paragraphIndent", Kv::Float(3.0)), + ]; + let s = seen(&linked(vec![], local)); + assert_eq!((s.case, s.decoration, s.spacing, s.indent), (TextCase::SmallCaps, TextDecoration::Strikethrough, 6.0, 3.0)); + // A value the importer does not know leaves the style's in place. + let s = seen(&linked(shared, vec![("textCase", Kv::Enum("UNSUPPORTED_CASE")), ("textDecoration", Kv::Enum("WAVY"))])); + assert_eq!((s.case, s.decoration), (TextCase::Upper, TextDecoration::Underline)); +} + +#[test] +fn an_instance_override_beats_the_shared_style_and_a_detach_unlinks_it() { + let value = rest(vec![ + node(10, "TEXT", "Body", Some(2), vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(14.0))]), + node(20, "SYMBOL", "Main", Some(2), vec![]), + node( + 21, + "TEXT", + "Label", + Some(20), + vec![("fontName", font("Inter")), ("fontSize", Kv::Float(14.0)), ("styleIdForText", link(10)), ("textData", chars("Hi"))], + ), + node( + 30, + "INSTANCE", + "Inst", + Some(2), + vec![( + "symbolData", + object(vec![ + ("symbolID", guid(20)), + ( + "symbolOverrides", + Kv::Array(vec![object(vec![ + ("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))])), + ("fontSize", Kv::Float(30.0)), + ])]), + ), + ]), + )], + ), + // An explicit "no style" detaches, even when the older field still names a style. + node( + 40, + "TEXT", + "Detached", + Some(2), + vec![ + ("fontName", font("Roboto")), + ("fontSize", Kv::Float(40.0)), + ("styleIdForText", object(vec![("guid", object(vec![("sessionID", Kv::Uint(NO_STYLE)), ("localID", Kv::Uint(NO_STYLE))]))])), + ("inheritTextStyleID", guid(10)), + ("textData", chars("ab")), + ], + ), + ]); + let label = find(find(&value["document"], "Inst").unwrap(), "Label").unwrap(); + assert_eq!(label["style"]["fontSize"].as_f64(), Some(30.0), "the override beats the style"); + let detached = find(&value["document"], "Detached").unwrap(); + assert!(detached["styles"].get("text").is_none(), "{}", detached["styles"]); + assert_eq!(detached["style"]["fontSize"].as_f64(), Some(40.0)); +} + +#[test] +fn a_text_property_value_drops_the_run_styles_made_for_the_main_text() { + let ids = [0u64, 0, 0, 1, 1, 1].map(Kv::Uint).to_vec(); + let value = rest(vec![ + node( + 20, + "SYMBOL", + "Main", + Some(2), + vec![( + "componentPropDefs", + Kv::Array(vec![object(vec![ + ("id", guid(70)), + ("name", Kv::Str("Text".into())), + ("type", Kv::Enum("TEXT")), + ("initialValue", object(vec![("textValue", chars("Hi you"))])), + ])]), + )], + ), + node( + 21, + "TEXT", + "Label", + Some(20), + vec![ + ("fontName", font("Inter")), + ("fontSize", Kv::Float(12.0)), + ( + "textData", + object(vec![ + ("characters", Kv::Str("Hi you".into())), + ("characterStyleIDs", Kv::Array(ids)), + ("styleOverrideTable", Kv::Array(vec![object(vec![("styleID", Kv::Uint(1)), ("fontSize", Kv::Float(30.0))])])), + ]), + ), + ( + "parameterConsumptionMap", + object(vec![( + "entries", + Kv::Array(vec![object(vec![ + ("variableField", Kv::Enum("TEXT_DATA")), + ("variableData", object(vec![("value", object(vec![("propRefValue", object(vec![("defId", guid(70))]))]))])), + ])]), + )]), + ), + ], + ), + node( + 30, + "INSTANCE", + "Inst", + Some(2), + vec![ + ("symbolData", object(vec![("symbolID", guid(20))])), + ( + "componentPropAssignments", + Kv::Array(vec![object(vec![("defID", guid(70)), ("value", object(vec![("textValue", chars("Goodbye"))]))])]), + ), + ], + ), + ]); + let main = find(find(&value["document"], "Main").unwrap(), "Label").unwrap(); + let kept: Vec = main["characterStyleOverrides"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + assert_eq!(kept, [0, 0, 0, 1, 1, 1], "the main keeps its run styles"); + let inst = find(&value["document"], "Inst").unwrap(); + let layer = find(inst, "Label").unwrap(); + assert_eq!(layer["characters"], "Goodbye"); + assert!(layer.get("characterStyleOverrides").is_none(), "positions of the main's runs do not fit the property text"); +} + +#[test] +fn percent_letter_spacing_stays_a_percentage() { + let spacing = |units: &'static str, value: f32| object(vec![("value", Kv::Float(value)), ("units", Kv::Enum(units))]); + let layer = |id, name: &str, letter: Kv<'static>| { + node( + id, + "TEXT", + name, + Some(2), + vec![("fontName", font("Inter")), ("fontSize", Kv::Float(20.0)), ("letterSpacing", letter), ("textData", chars("Hello"))], + ) + }; + let doc = import(&rest(vec![layer(10, "Percent", spacing("PERCENT", 5.0)), layer(11, "Pixels", spacing("PIXELS", 2.0))])); + let spacing = |name: &str| doc.nodes().find(|n| n.name == name).unwrap().kind.text().unwrap().style.letter_spacing; + assert_eq!(spacing("Percent"), LetterSpacing::Percent(5.0)); + assert_eq!(spacing("Pixels"), LetterSpacing::Pixels(2.0)); +} + +#[test] +fn the_private_percent_is_read_for_fig_imports_only() { + let value = rest(vec![node( + 10, + "TEXT", + "Label", + Some(2), + vec![ + ("fontName", font("Inter")), + ("fontSize", Kv::Float(20.0)), + ("letterSpacing", object(vec![("value", Kv::Float(5.0)), ("units", Kv::Enum("PERCENT"))])), + ("textData", chars("Hello")), + ], + )]); + let spacing = |doc: &Document| doc.nodes().find(|n| n.name == "Label").unwrap().kind.text().unwrap().style.letter_spacing; + assert_eq!(spacing(&import(&value)), LetterSpacing::Percent(5.0)); + // The same JSON through the public REST import is pixels. + let (public, _) = crate::rest::import(&value, &|_| None).unwrap(); + assert_eq!(spacing(&public), LetterSpacing::Pixels(1.0)); +} 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/import_regression_tests.rs b/crates/format/src/rest/import_regression_tests.rs new file mode 100644 index 00000000..2d6364be --- /dev/null +++ b/crates/format/src/rest/import_regression_tests.rs @@ -0,0 +1,59 @@ +//! Stroke-only vectors that import as the outline of their stroke keep the stroke's style and variables. + +use super::proto_variable_tests::{BRAND, alias, variables_json}; +use super::*; +use serde_json::json; + +fn at(x: f64, y: f64) -> Value { + json!([[1, 0, x], [0, 1, y]]) +} + +fn file(children: Value) -> Value { + json!({"name": "Fixture", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Page", "type": "CANVAS", "children": children} + ]}}) +} + +fn named<'a>(doc: &'a Document, name: &str) -> &'a Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no node {name}")) +} + +/// A stroke-only icon vector as `geometry=paths` returns it: no fills, one visible stroke. +fn stroke_only_vector(id: &str, name: &str, stroke: Value) -> Value { + json!({"id": id, "name": name, "type": "VECTOR", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, + "fills": [], "strokes": [stroke], "strokeWeight": 2, + "fillGeometry": [], + "strokeGeometry": [{"path": "M 0 0 L 10 0 L 10 2 L 0 2 Z", "windingRule": "NONZERO"}], + "styles": {"stroke": "S:stroke"}}) +} + +#[test] +fn stroke_only_vector_shared_stroke_style_keeps_paints() { + let stroke = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}}); + let mut v = file(json!([stroke_only_vector("1:1", "Icon", stroke)])); + v["styles"] = json!({"S:stroke": {"key": "k", "name": "Brand/Stroke", "styleType": "FILL"}}); + let (doc, _) = import(&v, &no_images).unwrap(); + let icon = named(&doc, "Icon"); + assert!(matches!(icon.kind, NodeKind::Vector { .. })); + let style = doc.style("S:stroke").expect("shared stroke style is created"); + match &style.kind { + StyleKind::Paint { paints } => assert_eq!(paints.len(), 1, "shared stroke style must keep the stroke paint, got {paints:?}"), + k => panic!("unexpected style kind {k:?}"), + } +} + +#[test] +fn stroke_colour_variable_on_outlined_vector_is_kept() { + let stroke = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}, "boundVariables": {"color": alias(BRAND)}}); + let mut node = stroke_only_vector("1:1", "Icon", stroke); + node.as_object_mut().unwrap().remove("styles"); + node["boundVariables"] = json!({"strokes": [alias(BRAND)]}); + let (doc, warnings) = import_with(&file(json!([node])), Some(&variables_json()), &no_images).unwrap(); + let icon = named(&doc, "Icon"); + // The stroke paint now paints the outline as its fill: its colour token must follow it. + assert!( + icon.bound.values().any(|v| v == BRAND), + "colour variable bound on the outlined stroke was dropped; bound={:?} warnings={warnings:?}", + icon.bound + ); +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 798b2645..4aa252a7 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,8 +25,11 @@ mod blur_tests; mod conditional_tests; #[cfg(test)] mod effects_tests; +mod fig_sources; #[cfg(test)] mod gamepad_tests; +#[cfg(test)] +mod import_regression_tests; pub(crate) mod props; pub(crate) mod proto; #[cfg(test)] @@ -53,6 +57,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 +146,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 +196,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 @@ -210,7 +234,7 @@ fn style_binding(slot: &str, key: &str) -> Option { /// Put back the shape a layer was before the `.fig` export wrote it as the outline of its stroke /// (`outlinedStroke`: `{kind, fills, strokes, stroke}` as the document holds them). Anything that /// isn't such a shape is ignored. -fn restore_stroke(n: &mut Node, original: &Value) { +fn restore_stroke(n: &mut Node, original: &Value) -> bool { #[derive(serde::Deserialize)] struct Original { kind: NodeKind, @@ -218,16 +242,16 @@ fn restore_stroke(n: &mut Node, original: &Value) { strokes: Vec, stroke: figcraft_doc::StrokeProps, } - let Ok(mut o) = serde_json::from_value::(original.clone()) else { return }; + let Ok(mut o) = serde_json::from_value::(original.clone()) else { return false }; match &mut o.kind { NodeKind::Vector { network } => { if network.vertices.len() + network.segments.len() > MAX_PATH_ELEMENTS { - return; + return false; } network.sanitize(); } NodeKind::Line | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Rectangle => {} - _ => return, + _ => return false, } let sp = &mut o.stroke; sp.weight = sp.weight.clamp(0.0, props::MAX_SIZE); @@ -237,6 +261,28 @@ fn restore_stroke(n: &mut Node, original: &Value) { o.fills.truncate(64); o.strokes.truncate(64); (n.kind, n.fills, n.strokes, n.stroke) = (o.kind, o.fills, o.strokes, o.stroke); + true +} + +/// A stroke-only vector imported as the outline of its stroke: the stroke paints fill the +/// outline, and the stroke's shared style and colour variables move with them. The hidden fills +/// it replaces go, with their style and bindings. +fn outline_stroke(n: &mut Node) { + n.fills = std::mem::take(&mut n.strokes); + n.styles.fill = n.styles.stroke.take(); + let bound = std::mem::take(&mut n.bound); + for (key, var) in bound { + let moved = match key.as_str() { + "fill" => continue, + k if k.starts_with("fills/") => continue, + "stroke" => "fill".to_string(), + k => match k.strip_prefix("strokes/") { + Some(rest) => format!("fills/{rest}"), + None => key, + }, + }; + n.bound.insert(moved, var); + } } fn children(v: &Value) -> &[Value] { @@ -291,6 +337,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 +368,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 +459,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)); @@ -548,7 +599,7 @@ impl<'a> Ctx<'a> { if let Some(c) = flag(j, "clipsContent") { n.clip = c; } - n.layout = props::auto_layout(j); + n.layout = props::auto_layout(j, self.fig_sources.is_some()); n.grids = props::layout_grids(j); } n.child_layout = props::child_layout(j, n.layout.as_ref().map(|l| l.mode), parent_mode); @@ -558,9 +609,7 @@ impl<'a> Ctx<'a> { n.prop_refs.insert(field.clone(), p.to_string()); } } - if matches!(n.kind, NodeKind::Vector { .. }) { - self.vector(n, j); - } + let outlined = matches!(n.kind, NodeKind::Vector { .. }) && self.vector(n, j); if matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }) { let defs = self.prop_defs(n.id, j); match &mut n.kind { @@ -581,20 +630,24 @@ impl<'a> Ctx<'a> { self.overlay_meta.entry(rid).or_insert(j); } self.bindings(n, j); - if let Some(original) = j.get("outlinedStroke") { - restore_stroke(n, original); + let restored = j.get("outlinedStroke").is_some_and(|original| restore_stroke(n, original)); + // Styles and bindings were read against the stroke: they follow its paints to the fill. + if outlined && !restored { + outline_stroke(n); } } /// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, our /// extension, written by the `.fig` import), else from its geometry. A stroke-only vector - /// uses the stroke outline, painted with the stroke paints (exact look, editable as a shape). - fn vector(&mut self, n: &mut Node, j: &Value) { + /// uses the stroke outline, painted with the stroke paints (exact look, editable as a shape): + /// returns whether it does, and the caller moves the paints with [`outline_stroke`] once the + /// styles and variables of the stroke are read. + fn vector(&mut self, n: &mut Node, j: &Value) -> bool { if let Some(net) = j.get("vectorNetwork") { match network(net) { Some(network) => { n.kind = NodeKind::Vector { network }; - return; + return false; } None => self.warn("invalid or oversized vector networks import as outlines"), } @@ -609,17 +662,15 @@ impl<'a> Ctx<'a> { (None, Some(s)) => (s, true), (None, None) => { self.warn("vectors without geometry are empty (export the file JSON with geometry=paths)"); - return; + return false; } }; let mut network = VectorNetwork::from_path(&path); for r in &mut network.regions { r.winding = winding; } - if outline { - n.fills = std::mem::take(&mut n.strokes); - } n.kind = NodeKind::Vector { network }; + outline } /// The paths of a `fillGeometry` / `strokeGeometry` list (node-local SVG path data). @@ -822,6 +873,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); diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 67ec8063..1f8fdd19 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -5,8 +5,8 @@ use figcraft_doc::color::{BlendMode, Color, ColorStop, Gradient, GradientKind, I use figcraft_doc::geom::Affine; use figcraft_doc::{ AutoLayout, Blur, BlurType, CellAlign, ChildLayout, Constraints, ContentAlign, CrossAlign, Effect, EffectKind, ExportFormat, ExportSetting, - ExportSize, Glass, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign, StrokeCap, - StrokeJoin, StrokeProps, Texture, VConstraint, WidthPoint, WidthProfile, + ExportSize, Glass, GridAlign, GridKind, GridTrack, HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign, + StrokeCap, StrokeJoin, StrokeProps, Texture, VConstraint, WidthPoint, WidthProfile, }; use serde_json::Value; @@ -394,7 +394,7 @@ pub(super) fn constraints(v: &Value) -> Option { // ---- auto layout ---- /// The auto layout of a frame-like node (`layoutMode` other than `NONE`). -pub(super) fn auto_layout(v: &Value) -> Option { +pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option { let mode = match text(v, "layoutMode")? { "HORIZONTAL" => LayoutMode::Horizontal, "VERTICAL" => LayoutMode::Vertical, @@ -432,10 +432,31 @@ pub(super) fn auto_layout(v: &Value) -> Option { grid_rows: count("gridRowCount", 2), strokes_included: flag(v, "strokesIncludedInLayout").unwrap_or(false), reverse_z: flag(v, "itemReverseZIndex").unwrap_or(false), - ..AutoLayout::default() + column_tracks: if fig_tracks && grid { fig_grid_tracks(v, "columnTracks") } else { Vec::new() }, + row_tracks: if fig_tracks && grid { fig_grid_tracks(v, "rowTracks") } else { Vec::new() }, }) } +fn fig_grid_tracks(v: &Value, axis: &str) -> Vec { + v.get(crate::rest::FIG_SOURCE_FIELD) + .and_then(|source| source.get("gridTracks")) + .and_then(|tracks| tracks.get(axis)) + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(1_000) + .map(|track| { + let value = track.get("value").and_then(Value::as_f64).filter(|n| n.is_finite() && (0.0..=MAX_SIZE).contains(n)); + match (track.get("sizing").and_then(Value::as_str), value) { + (Some("fixed"), Some(px)) => GridTrack::fixed(px), + (Some("fill"), Some(fr)) if fr > 0.0 => GridTrack::fill(fr), + (Some("hug"), _) => GridTrack::hug(), + _ => GridTrack::default(), + } + }) + .collect() +} + /// How the node sizes and sits in its parent: its own hug/fixed axes (auto layout frames), its /// fill/fixed axes as a child of an auto layout `parent_mode`, absolute positioning, min/max sizes /// and grid cells. diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index d7117fd0..79956338 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -363,6 +363,7 @@ fn auto_layout_constraints_and_grids() { ]}, {"id": "4", "name": "Grid", "type": "FRAME", "relativeTransform": at(200.0, 0.0), "size": {"x": 300, "y": 200}, "layoutMode": "GRID", "gridColumnCount": 3, "gridRowCount": 2, "gridColumnGap": 4, "gridRowGap": 6, + "_figSource": {"gridTracks": {"columnTracks": [{"sizing": "fixed", "value": 120}] }}, "children": [{"id": "5", "name": "Cell", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, "gridColumnAnchorIndex": 1, "gridRowAnchorIndex": 0, "gridColumnSpan": 2, "gridRowSpan": 1}]} ])); @@ -389,6 +390,7 @@ fn auto_layout_constraints_and_grids() { assert_eq!((abs.constraints.horizontal, abs.constraints.vertical), (HConstraint::LeftRight, VConstraint::Bottom)); let grid = named(&doc, "Grid").layout.clone().unwrap(); assert_eq!((grid.mode, grid.grid_columns, grid.grid_rows, grid.gap, grid.cross_gap), (LayoutMode::Grid, 3, 2, 4.0, 6.0)); + assert!(grid.column_tracks.is_empty(), "private .fig track metadata is ignored by the public REST importer"); let cell = &named(&doc, "Cell").child_layout; assert_eq!((cell.grid_column, cell.grid_row, cell.grid_column_span, cell.grid_row_span), (Some(1), Some(0), 2, 1)); } diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index c5381bb2..3875204f 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -106,6 +106,12 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle { if let Some(ls) = num(s, "letterSpacing") { t.letter_spacing = if ls == 0.0 { LetterSpacing::default() } else { LetterSpacing::Pixels(ls.clamp(-1e4, 1e4)) }; } + // Private, from the .fig mapper: the spacing was a share of the font size. + if cx.fig_sources.is_some() + && let Some(p) = num(s, "_letterSpacingPercent") + { + t.letter_spacing = LetterSpacing::Percent(p.clamp(-1e4, 1e4)); + } match text(s, "textCase") { Some("ORIGINAL") => t.case = TextCase::Original, Some("UPPER") => t.case = TextCase::Upper,