From d331db00aa5b09f1c97e9411fc6b1476d54ed82b Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 01:31:56 +0200 Subject: [PATCH 01/33] M1: keep explicit instance identity and cached library components in .fig import A .fig import now links the layers of an instance to the layers of the component they came from by the ids the file stores, instead of leaving it to the engine to guess from names and places. Renamed slots and layers, siblings that share a name and swapped mains keep their source link and the override they carry. Components that live only on the file's hidden internal pages (cached copies of library components) are kept in the hidden library store when a visible layer uses them, so their instances stay instances. The read-only guard for library copies now compares the copies themselves rather than the dirty set, which an import leaves full until the first edit. Rebasing an instance (after detaching an outer instance or restoring a main) follows the source chain inside the right enclosing instance, so contextual links survive it. The last change of a GUID wins and a removed change deletes it. Ordinary file JSON never reads the private provenance. Importing such a file into an open document, or pasting its layers into another one, copies the cached components along (import) or leaves them behind as a missing main (paste), so no link points at a layer of the target. --- crates/engine/src/cmd/file.rs | 84 ++ crates/engine/src/components.rs | 226 ++++- .../src/components_rebase_source_tests.rs | 244 +++++ crates/engine/src/fig_import_tests.rs | 40 + crates/engine/src/library.rs | 13 +- crates/engine/src/library_tests.rs | 46 + crates/format/src/fig/map.rs | 225 ++++- crates/format/src/fig/mod.rs | 4 +- .../format/src/fig/source_identity_tests.rs | 938 ++++++++++++++++++ crates/format/src/rest/fig_sources.rs | 316 ++++++ crates/format/src/rest/mod.rs | 37 +- 11 files changed, 2138 insertions(+), 35 deletions(-) create mode 100644 crates/engine/src/components_rebase_source_tests.rs create mode 100644 crates/format/src/fig/source_identity_tests.rs create mode 100644 crates/format/src/rest/fig_sources.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 5e27c572..f50c5edc 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1272,6 +1272,19 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result> { map.insert(id, doc.alloc_id()); } } + // Library copies a .fig import keeps (cached definitions) have no library record to follow: + // the layers of its store come along into the store of `doc`. Recorded copies are left to + // `merge_store`, which shares them with copies `doc` already has. + let cached: Vec = other + .library_store() + .and_then(|s| other.node(s)) + .map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect()) + .unwrap_or_default(); + for root in &cached { + for id in other.subtree(*root) { + map.insert(id, doc.alloc_id()); + } + } let mut new_pages = Vec::new(); for pg in &other.pages { let Some(pn) = other.node(*pg) else { continue }; @@ -1289,6 +1302,13 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result> { } new_pages.push(np); } + if !cached.is_empty() { + let store = doc.ensure_library_store(); + for root in &cached { + let nodes: Vec = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, *root, store, None)?; + } + } for new in map.values() { let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied()); if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) { @@ -1354,6 +1374,70 @@ mod rest_import_tests { doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap() } + /// What a .fig import with a cached library component leaves: the main in the hidden store, a + /// visible instance of it and a layer linked to the main's layer. + fn cached_import() -> Document { + let mut doc = Document::new("Cached"); + let page = doc.first_page().unwrap(); + let store = doc.ensure_library_store(); + let main = doc + .create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0) + .unwrap(); + let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap(); + let inst = + doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap(); + let mut copy = doc.node(body).unwrap().clone(); + copy.id = doc.alloc_id(); + copy.parent = None; + copy.source = Some(body); + let copy_id = copy.id; + doc.insert(copy, inst, None).unwrap(); + assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body)); + doc + } + + /// Pasting layers that use a cached main into another document leaves the main behind, as it + /// does for other mains that are not published: no link points at a layer of the target. + #[test] + fn pasting_instances_of_cached_components_leaves_no_dangling_links() { + let src = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id; + let ids = src.subtree(inst); + let map: std::collections::BTreeMap = ids.iter().map(|i| (*i, doc.alloc_id())).collect(); + let nodes: Vec = ids.iter().filter_map(|i| src.node(*i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, inst, page, None).unwrap(); + crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap(); + let pasted = doc.node(map[&inst]).unwrap(); + let NodeKind::Instance { component, .. } = pasted.kind else { panic!() }; + assert!(!doc.contains(component), "the main stays behind: {component:?}"); + assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s))); + } + + #[test] + fn appending_pages_with_cached_library_components_brings_the_components() { + let other = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + // Ids of the target that would collide with the other document's store. + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + append_pages(&mut doc, &other).unwrap(); + assert!(doc.validate().is_empty(), "{:?}", doc.validate()); + assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc)); + let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap(); + let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; + assert_eq!(doc.node(component).unwrap().name, "Cached main"); + assert!(doc.is_remote(component), "the main comes along into the store"); + let layer = doc.node(inst.children[0]).unwrap(); + assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied()); + } + #[test] fn rest_json_loads_with_linked_instances() { let bytes = serde_json::to_vec(&kit()).unwrap(); diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index da0b2d13..30f21b8c 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -1710,6 +1710,165 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti None } +/// Bounded source and parent lookups while a rebase reads an unchanged document. +struct RebaseSources<'a> { + doc: &'a Document, + chains: BTreeMap>>, + parents: BTreeMap<(NodeId, NodeId), Option>, + main_changes: BTreeMap, +} + +impl<'a> RebaseSources<'a> { + const PARENTS: usize = 512; + const CACHE: usize = 8192; + + fn new(doc: &'a Document) -> Self { + Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() } + } + + fn chain(&mut self, source: NodeId) -> Option> { + if let Some(chain) = self.chains.get(&source) { + return chain.clone(); + } + let read = || { + let mut chain = Vec::new(); + let mut seen = BTreeSet::new(); + let mut current = Some(source); + for _ in 0..MAX_DEPTH { + let Some(id) = current else { return Some(chain) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + chain.push(id); + current = n.source; + } + current.is_none().then_some(chain) + }; + let chain = read(); + if self.chains.len() < Self::CACHE { + self.chains.insert(source, chain.clone()); + } + chain + } + + /// Validate the complete parent chain, even if `main` was found before a cycle. + fn in_main(&mut self, id: NodeId, main: NodeId) -> Option { + if let Some(found) = self.parents.get(&(id, main)) { + return *found; + } + let read = || { + let mut current = Some(id); + let mut seen = BTreeSet::new(); + let mut found = false; + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(found) }; + if !seen.insert(id) { + return None; + } + found |= id == main; + current = self.doc.node(id)?.parent; + } + current.is_none().then_some(found) + }; + let found = read(); + if self.parents.len() < Self::CACHE { + self.parents.insert((id, main), found); + } + found + } + + fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option { + let chain = self.chain(source)?; + if chain.contains(&target) { + return None; + } + let mut member = None; + for id in chain { + if self.in_main(id, main)? && id != main && member.is_none() { + member = Some(id); + } + } + member + } + + /// Nearest enclosing instances and their current mains. Slots with own content end scope. + fn owners(&self, id: NodeId) -> Option> { + let mut owners = Vec::new(); + let mut current = self.doc.node(id)?.parent; + let mut seen = BTreeSet::from([id]); + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(owners) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + if holds_content(n) { + return Some(owners); + } + if let NodeKind::Instance { component, .. } = &n.kind { + owners.push((id, *component)); + } + current = n.parent; + } + current.is_none().then_some(owners) + } + + fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main) + } + + /// A contextual child belongs to the same placed slot as its enclosing instance. + fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + let Some((owner, main)) = owners.first() else { return false }; + if self.member(source, *main, target).is_none() { + return false; + } + if self.in_main(source, *main) == Some(true) { + return true; + } + let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false }; + matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main) + && self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner) + } + + fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option { + let source = self.doc.node(id)?.source?; + if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame) + || !self.valid(source, id, owners) + || !self.contextual(source, owners) + { + return None; + } + Some(source) + } + + fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool { + for (index, (id, main)) in owners.iter().enumerate() { + if *id == stop { + break; + } + let changed = if let Some(changed) = self.main_changes.get(id) { + *changed + } else { + let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => component != main, + Some(NodeKind::Frame) => true, + _ => false, + }); + if self.main_changes.len() < Self::CACHE { + self.main_changes.insert(*id, changed); + } + changed + }; + if changed { + return true; + } + } + false + } +} + /// Re-derive `source` links and overrides of instance `inst` against its own main by comparing /// values (after detaching an outer instance, restoring a main, or copying a nested instance out): /// the look is kept exactly. Values a component property sets aren't overrides. @@ -1722,22 +1881,28 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect() }; let mut updates = Vec::new(); + let mut sources = RebaseSources::new(doc); + let root_owners = sources.owners(inst).unwrap_or_default(); + let placed_source = sources.placed(inst, &root_owners); + let mut contexts = vec![(inst, component)]; + contexts.extend_from_slice(&root_owners); for (id, path) in &mine { let Some(n) = doc.node(*id) else { continue }; - // Follow the source chain into the main first (exact), then fall back to the name path. - let mut main = None; - let mut cur = n.source; - for _ in 0..MAX_DEPTH { - let Some(c) = cur else { break }; - if c != component && doc.contains(c) && doc.is_ancestor(component, c) { - main = Some(c); - break; - } - cur = doc.node(c).and_then(|x| x.source); + let owners = sources.owners(*id).unwrap_or_default(); + let nearest_main = owners.first().map(|(_, main)| *main); + let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners)); + let mut main = valid_source.and_then(|source| sources.member(source, component, *id)); + // A swapped or Frame-to-Instance subtree is compared to its current main by the inner + // rebase. An outer name path must not rebind those children to the old expanded copies. + if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) { + updates.push((*id, valid_source, n.overrides.clone())); + continue; } - let main = main.or_else(|| theirs.get(path).copied()); + main = main + .or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some()))); + let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main); let mut overrides: BTreeSet = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect(); - if let Some(m) = main.and_then(|m| doc.node(m)) { + if let Some(m) = source.and_then(|source| doc.node(source)) { let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect(); bound.extend(variable_groups(n, m)); overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str()))); @@ -1745,36 +1910,51 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { overrides.insert("component".into()); } } - updates.push((*id, main, overrides)); + updates.push((*id, source, overrides)); } - for (id, main, overrides) in updates { - if let Some(n) = doc.node_mut(id) { - n.source = main; - n.overrides = if main.is_some() { overrides } else { BTreeSet::new() }; - } - } - let root_groups = match (doc.node(component), doc.node(inst)) { + // A placed nested root compares to its actual slot. A detached root compares to its own main. + let root_base = placed_source.or_else(|| { + let chain = sources.chain(component)?; + (!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component) + }); + let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) { (Some(m), Some(n)) => { let from_variables = variable_groups(n, m); let mut groups: Vec = value_groups(m, n) .into_iter() .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) .collect(); - // A layer name of its own (a nested instance renamed in its old main) stays. - if n.name != default_instance_name(doc, component) { + let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; + if named { groups.push("name".into()); } + if placed_source.is_some() + && matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) + { + groups.push("component".into()); + } groups } _ => Vec::new(), }; + drop(sources); + for (id, main, overrides) in updates { + if let Some(n) = doc.node_mut(id) { + n.source = main; + n.overrides = if main.is_some() { overrides } else { BTreeSet::new() }; + } + } if let Some(n) = doc.node_mut(inst) { - n.source = Some(component); + n.source = root_base; n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position")); n.overrides.extend(root_groups); } } +#[cfg(test)] +#[path = "components_rebase_source_tests.rs"] +mod rebase_source_tests; + /// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then /// the left-most). pub fn default_variant(doc: &Document, set: NodeId) -> Option { diff --git a/crates/engine/src/components_rebase_source_tests.rs b/crates/engine/src/components_rebase_source_tests.rs new file mode 100644 index 00000000..b272b82f --- /dev/null +++ b/crates/engine/src/components_rebase_source_tests.rs @@ -0,0 +1,244 @@ +//! Contextual import sources survive rebasing; detached and swapped subtrees use their current mains. + +use super::*; + +fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str) -> NodeId { + doc.create(parent, kind, name, 0.0, 0.0, 20.0, 20.0).unwrap() +} + +fn component(doc: &mut Document, parent: NodeId, name: &str) -> NodeId { + add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name) +} + +fn mirror(doc: &mut Document, source: NodeId, parent: NodeId) -> NodeId { + let mut n = doc.node(source).unwrap().clone(); + let children = std::mem::take(&mut n.children); + n.id = doc.alloc_id(); + n.parent = None; + n.source = Some(source); + let id = n.id; + doc.insert(n, parent, None).unwrap(); + for child in children { + mirror(doc, child, id); + } + id +} + +fn instance(doc: &mut Document, main: NodeId, parent: NodeId, name: &str) -> NodeId { + let mut n = doc.node(main).unwrap().clone(); + let children = std::mem::take(&mut n.children); + n.id = doc.alloc_id(); + n.kind = NodeKind::Instance { component: main, props: BTreeMap::new() }; + n.name = name.into(); + n.parent = None; + n.source = None; + let id = n.id; + doc.insert(n, parent, None).unwrap(); + for child in children { + mirror(doc, child, id); + } + id +} + +struct Scene { + doc: Document, + inner: NodeId, + base: NodeId, + slot: NodeId, + context: NodeId, + root: NodeId, + nested: NodeId, + child: NodeId, +} + +fn scene() -> Scene { + let mut doc = Document::new("Source context"); + let page = doc.first_page().unwrap(); + let inner = component(&mut doc, page, "Inner"); + let base = add(&mut doc, inner, NodeKind::Rectangle, "Body"); + doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0)); + let outer = component(&mut doc, page, "Outer"); + let slot = instance(&mut doc, inner, outer, "Nested"); + doc.node_mut(slot).unwrap().source = Some(inner); + let context = doc.node(slot).unwrap().children[0]; + doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0)); + doc.node_mut(context).unwrap().opacity = 0.3; + let root = instance(&mut doc, outer, page, "Outer copy"); + let nested = doc.node(root).unwrap().children[0]; + let child = doc.node(nested).unwrap().children[0]; + doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0)); + doc.node_mut(child).unwrap().opacity = 0.2; + Scene { doc, inner, base, slot, context, root, nested, child } +} + +fn both(s: &mut Scene) { + rebase_instance(&mut s.doc, s.nested); + rebase_instance(&mut s.doc, s.root); +} + +#[test] +fn rebase_default_only_sources_promote_to_the_actual_outer_context() { + let mut s = scene(); + s.doc.node_mut(s.nested).unwrap().source = None; + s.doc.node_mut(s.child).unwrap().source = None; + both(&mut s); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); +} + +#[test] +fn rebase_rejects_a_context_source_from_another_nested_slot() { + let mut s = scene(); + let outer = s.doc.node(s.slot).unwrap().parent.unwrap(); + let sibling = instance(&mut s.doc, s.inner, outer, "Other nested slot"); + s.doc.node_mut(sibling).unwrap().source = Some(s.inner); + let sibling_child = s.doc.node(sibling).unwrap().children[0]; + mirror(&mut s.doc, sibling, s.root); + s.doc.node_mut(s.child).unwrap().source = Some(sibling_child); + both(&mut s); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); + assert_eq!(s.doc.node(sibling_child).unwrap().transform.translation().x, 2.0); + assert_eq!(s.doc.node(s.context).unwrap().transform.translation().x, 3.0); +} + +#[test] +fn rebase_does_not_mark_inherited_context_values_as_local_overrides() { + let mut s = scene(); + s.doc.node_mut(s.child).unwrap().opacity = 0.3; + both(&mut s); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); + assert!(!s.doc.node(s.child).unwrap().overrides.contains("opacity")); +} + +#[test] +fn rebase_preserves_multilevel_context_without_shortening_source_chains() { + let mut doc = Document::new("Nested source contexts"); + let page = doc.first_page().unwrap(); + let inner = component(&mut doc, page, "Inner"); + let base = add(&mut doc, inner, NodeKind::Rectangle, "Body"); + doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0)); + let wrapper = component(&mut doc, page, "Wrapper"); + let default_slot = instance(&mut doc, inner, wrapper, "Nested"); + doc.node_mut(default_slot).unwrap().source = Some(inner); + let outer = component(&mut doc, page, "Outer"); + let wrapper_slot = instance(&mut doc, wrapper, outer, "Wrapper slot"); + doc.node_mut(wrapper_slot).unwrap().source = Some(wrapper); + let source_slot = doc.node(wrapper_slot).unwrap().children[0]; + let context = doc.node(source_slot).unwrap().children[0]; + doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0)); + let root = instance(&mut doc, outer, page, "Outer copy"); + let wrapper_copy = doc.node(root).unwrap().children[0]; + let nested = doc.node(wrapper_copy).unwrap().children[0]; + let child = doc.node(nested).unwrap().children[0]; + doc.node_mut(nested).unwrap().name = "Renamed nested".into(); + doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0)); + for id in [nested, wrapper_copy, root] { + rebase_instance(&mut doc, id); + assert_eq!(doc.node(nested).unwrap().source, Some(source_slot)); + assert_eq!(doc.node(child).unwrap().source, Some(context)); + } + assert_eq!(doc.node(context).unwrap().source, Some(doc.node(default_slot).unwrap().children[0])); + assert_eq!(doc.node(context).unwrap().transform.translation().x, 3.0); + assert_eq!(doc.node(child).unwrap().transform.translation().x, 2.0); +} + +#[test] +fn rebase_preserves_placed_frame_to_instance_and_current_main_children() { + let mut doc = Document::new("Typed nested source"); + let page = doc.first_page().unwrap(); + let inner = component(&mut doc, page, "Inner"); + let body = add(&mut doc, inner, NodeKind::Rectangle, "Body"); + let outer = component(&mut doc, page, "Outer"); + let slot = add(&mut doc, outer, NodeKind::Frame, "Empty slot"); + let root = instance(&mut doc, outer, page, "Outer copy"); + let nested = doc.node(root).unwrap().children[0]; + doc.node_mut(nested).unwrap().kind = NodeKind::Instance { component: inner, props: BTreeMap::new() }; + let child = mirror(&mut doc, body, nested); + rebase_instance(&mut doc, nested); + rebase_instance(&mut doc, root); + assert_eq!(doc.node(nested).unwrap().source, Some(slot)); + assert_eq!(doc.node(child).unwrap().source, Some(body)); + assert!(matches!(doc.node(nested).unwrap().kind, NodeKind::Instance { component, .. } if component == inner)); +} + +#[test] +fn rebase_keeps_current_main_sources_and_component_delta_beneath_swap() { + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let replacement = component(&mut s.doc, page, "Replacement"); + let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body"); + s.doc.node_mut(body).unwrap().transform = Affine::translate((7.0, 0.0)); + s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() }; + s.doc.remove(s.child).unwrap(); + s.child = mirror(&mut s.doc, body, s.nested); + s.doc.node_mut(s.child).unwrap().transform = Affine::translate((8.0, 0.0)); + s.doc.node_mut(s.nested).unwrap().name = "Renamed swap".into(); + rebase_instance(&mut s.doc, s.nested); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert!(s.doc.node(s.nested).unwrap().overrides.contains("component")); + rebase_instance(&mut s.doc, s.root); + assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(body)); + assert!(s.doc.node(s.nested).unwrap().overrides.contains("component")); + assert_eq!(s.doc.node(s.child).unwrap().transform.translation().x, 8.0); +} + +#[test] +fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() { + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let detached = mirror(&mut s.doc, s.nested, page); + let child = s.doc.node(detached).unwrap().children[0]; + // The old source references still exist, but their Outer context is absent here. + s.doc.node_mut(detached).unwrap().source = Some(s.slot); + s.doc.node_mut(child).unwrap().source = Some(s.context); + s.doc.node_mut(child).unwrap().transform = Affine::translate((3.0, 0.0)); + rebase_instance(&mut s.doc, detached); + assert_eq!(s.doc.node(detached).unwrap().source, Some(s.inner)); + assert_eq!(s.doc.node(child).unwrap().source, Some(s.base)); + assert_eq!(s.doc.node(child).unwrap().transform.translation().x, 3.0); + assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0); +} + +#[test] +fn rebase_does_not_preserve_existing_foreign_or_stale_sources() { + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let foreign = component(&mut s.doc, page, "Foreign"); + let foreign_body = add(&mut s.doc, foreign, NodeKind::Rectangle, "Body"); + s.doc.node_mut(s.child).unwrap().source = Some(foreign_body); + both(&mut s); + assert_ne!(s.doc.node(s.child).unwrap().source, Some(foreign_body)); + assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context)); + let mut s = scene(); + let page = s.doc.first_page().unwrap(); + let replacement = component(&mut s.doc, page, "Replacement"); + let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body"); + s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() }; + s.doc.node_mut(s.child).unwrap().source = Some(s.context); + both(&mut s); + assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context), "the old default-main child is stale after a swap"); + assert!(s.doc.node(s.child).unwrap().source.is_none() || s.doc.node(s.child).unwrap().source == Some(body)); +} + +#[test] +fn rebase_rejects_source_and_parent_cycles_with_bounded_lookups() { + let mut s = scene(); + s.doc.node_mut(s.base).unwrap().source = Some(s.context); + both(&mut s); + assert_eq!(s.doc.node(s.child).unwrap().source, None); + let mut s = scene(); + s.doc.node_mut(s.context).unwrap().parent = Some(s.context); + both(&mut s); + assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context)); + let mut s = scene(); + s.doc.node_mut(s.nested).unwrap().parent = Some(s.child); + s.doc.node_mut(s.child).unwrap().parent = Some(s.nested); + let sources = RebaseSources::new(&s.doc); + assert!(sources.owners(s.child).is_none()); + let mut s = scene(); + s.doc.node_mut(s.root).unwrap().kind = NodeKind::Instance { component: s.root, props: BTreeMap::new() }; + rebase_instance(&mut s.doc, s.root); + assert_ne!(s.doc.node(s.root).unwrap().source, Some(s.root)); +} diff --git a/crates/engine/src/fig_import_tests.rs b/crates/engine/src/fig_import_tests.rs index d6241448..f0ea0886 100644 --- a/crates/engine/src/fig_import_tests.rs +++ b/crates/engine/src/fig_import_tests.rs @@ -50,3 +50,43 @@ fn local_samples_match_their_exports() { } } } + +/// Layers of an instance copy keep their link to the layer they stand for when the file is +/// opened, also after they were renamed in the copy (a name is no identity), and the override +/// that a rename is stays an override. +#[test] +fn opened_fig_links_instance_layers_by_identity_not_by_name() { + use base64::Engine as _; + let mut s = Session::new(); + let make = |s: &mut Session, cmd: &str, p: serde_json::Value| s.execute(cmd, &p).unwrap()["id"].as_u64().map(figcraft_doc::NodeId).unwrap(); + let body = make(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Inner"})); + let label = make(&mut s, "text.create", json!({"text": "Label", "x": 2, "y": 2, "parent": body, "props": {"name": "Label"}})); + let inner = make(&mut s, "component.create", json!({"ids": [body]})); + let frame = make(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 200, "height": 60, "name": "Outer"})); + let outer = make(&mut s, "component.create", json!({"ids": [frame]})); + let slot = make(&mut s, "instance.create", json!({"component": inner, "x": 0, "y": 0, "parent": outer})); + s.execute("node.set", &json!({"id": slot, "props": {"name": "Slot"}})).unwrap(); + let copy = make(&mut s, "instance.create", json!({"component": outer, "x": 0, "y": 200})); + let copy_slot = s.doc().unwrap().node(copy).unwrap().children[0]; + let copy_label = s.doc().unwrap().node(copy_slot).unwrap().children[0]; + s.execute("node.set", &json!({"id": copy_slot, "props": {"name": "Renamed slot"}})).unwrap(); + s.execute("node.set", &json!({"id": copy_label, "props": {"name": "Renamed label"}})).unwrap(); + let _ = label; + + let exported = s.execute("file.export", &json!({"format": "fig"})).unwrap(); + let path = std::env::temp_dir().join(format!("figcraft-slots-{}.fig", std::process::id())); + std::fs::write(&path, base64::engine::general_purpose::STANDARD.decode(exported["data"].as_str().unwrap()).unwrap()).unwrap(); + let mut opened = Session::new(); + let result = opened.execute("file.open", &json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + result.unwrap(); + let doc = opened.doc().unwrap(); + let outer = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Component { .. })).unwrap(); + let copy = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Instance { .. })).unwrap(); + let slot = doc.node(copy.children[0]).unwrap(); + let label = doc.node(slot.children[0]).unwrap(); + assert_eq!((slot.name.as_str(), label.name.as_str()), ("Renamed slot", "Renamed label")); + assert_eq!(slot.source, Some(outer.children[0])); + assert_eq!(label.source, Some(doc.node(outer.children[0]).unwrap().children[0])); + assert!(slot.overrides.contains("name") && label.overrides.contains("name")); +} diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 73c599a4..d7bafe82 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -1221,9 +1221,16 @@ pub fn check_read_only(cmd: &str, before: &Document, after: &Document) -> std::r if !variables_unchanged(before, after) { return Err(READ_ONLY.into()); } - let Some(store) = after.library_store().or_else(|| before.library_store()) else { return Ok(()) }; - let touched = |d: &Document, id: NodeId| d.contains(id) && d.page_of(id) == Some(store); - if after.dirty().iter().any(|id| touched(before, *id) || touched(after, *id)) { + let changed_remote = |id| { + if !before.is_remote(id) && !after.is_remote(id) { + return false; + } + match (before.node_arc(id), after.node_arc(id)) { + (Some(old), Some(new)) => !Arc::ptr_eq(old, new), + _ => true, + } + }; + if after.dirty().iter().any(|id| changed_remote(*id)) { return Err(READ_ONLY.into()); } Ok(()) diff --git a/crates/engine/src/library_tests.rs b/crates/engine/src/library_tests.rs index 23d5af09..c229e4e0 100644 --- a/crates/engine/src/library_tests.rs +++ b/crates/engine/src/library_tests.rs @@ -45,6 +45,38 @@ fn path_str(p: &std::path::Path) -> String { p.to_string_lossy().into_owned() } +fn dirty_library_document() -> (Document, NodeId) { + let mut doc = Document::new("Dirty library cache"); + let store = doc.ensure_library_store(); + let remote = doc.alloc_id(); + doc.insert(Node::new(remote, NodeKind::Frame, "Remote", 10.0, 10.0), store, None).unwrap(); + (doc, remote) +} + +/// An imported cache is dirty until the first edit; that alone is no edit of the library copies. +#[test] +fn read_only_allows_unchanged_predirty_remote_nodes() { + let (before, remote) = dirty_library_document(); + assert!(before.dirty().contains(&remote)); + + let mut after = before.clone(); + after.add_page("Unrelated page"); + assert!(after.dirty().contains(&remote), "the imported remote node remains in the pending dirty set"); + check_read_only("page.add", &before, &after).unwrap(); + + let mut edited = before.clone(); + edited.get_mut(remote).unwrap().opacity = 0.5; + assert_eq!(check_read_only("node.set", &before, &edited), Err(READ_ONLY.into())); + + let mut moved = before.clone(); + moved.reparent(remote, before.first_page().unwrap(), None).unwrap(); + assert_eq!(check_read_only("node.reparent", &before, &moved), Err(READ_ONLY.into())); + + let mut deleted = before.clone(); + deleted.remove(remote).unwrap(); + assert_eq!(check_read_only("edit.delete", &before, &deleted), Err(READ_ONLY.into())); +} + /// The library file's layers. struct Kit { button: NodeId, @@ -179,8 +211,16 @@ fn add_place_and_update_keep_overrides() { NodeKind::Instance { component, .. } => component, _ => panic!("not an instance"), }; + let unchanged = s.doc().unwrap().clone(); let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err(); assert!(e.to_string().contains("can't be edited"), "{e}"); + assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back"); + for (cmd, args) in + [("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))] + { + assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies"); + assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back"); + } let style = s.doc().unwrap().styles[0].id.clone(); assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err()); assert!(s.execute("style.delete", &json!({"style": style})).is_err()); @@ -281,10 +321,16 @@ fn library_styles_apply_and_import_with_pages() { let copy = doc.nodes().find(|n| n.name == node(&s, inst).name && matches!(n.kind, NodeKind::Instance { .. }) && !doc.is_remote(n.id)).unwrap(); let NodeKind::Instance { component, .. } = copy.kind else { panic!("not an instance") }; assert!(doc.is_remote(component)); + let store_roots = |s: &Session| { + let doc = s.doc().unwrap(); + doc.library_store().and_then(|id| doc.node(id)).map_or(0, |n| n.children.len()) + }; + assert_eq!(store_roots(&t), 2, "recorded copies are not copied twice"); // A second import shares the copies it already has. ex(&mut t, "file.import", json!({"path": path})); healthy(&t); assert_eq!(t.doc().unwrap().libraries.components.len(), 2); + assert_eq!(store_roots(&t), 2); let _ = std::fs::remove_dir_all(&d); } diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 66a00129..9f9bacab 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -6,7 +6,7 @@ //! are expanded here from the main component (overrides and derived layout matched by id path, //! component property values applied) so the import looks as saved. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::rc::Rc; use figcraft_doc::geom::{Affine, Point}; @@ -33,6 +33,9 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { let mut m = Mapper::new(msg); let doc = m.document(); let mut out = json!({"name": name, "document": doc}); + if !m.library_contexts.is_empty() { + out[crate::rest::FIG_LIBRARY_CONTEXTS_FIELD] = Value::Array(std::mem::take(&mut m.library_contexts)); + } if !m.styles.is_empty() { out["styles"] = Value::Object(std::mem::take(&mut m.styles)); } @@ -259,6 +262,12 @@ struct Mapper<'a> { /// Descriptions of components and component sets (REST `components` / `componentSets`). components: Obj, sets: Obj, + /// Component-bearing branches of the file's internal canvases (cached library definitions). + library_contexts: Vec, + /// Layers visited while scanning internal canvases, across all of them. + library_scan_count: usize, + /// Most layers produced or scanned; the one place the cap comes from. + max_nodes: usize, warnings: Vec, count: usize, } @@ -268,9 +277,19 @@ impl<'a> Mapper<'a> { let mut nodes = BTreeMap::new(); let mut kids: BTreeMap> = BTreeMap::new(); let mut prop_defs = BTreeMap::new(); - for n in msg.get("nodeChanges").map(Kv::list).unwrap_or_default() { + let changes = msg.get("nodeChanges").map(Kv::list).unwrap_or_default(); + // The last change of a GUID wins; a REMOVED change deletes what came before it. + for n in changes { let Some(id) = n.get("guid").and_then(guid) else { continue }; if n.get("phase").and_then(Kv::str) == Some("REMOVED") { + nodes.remove(&id); + } else { + nodes.insert(id, n); + } + } + for n in changes { + let Some(id) = n.get("guid").and_then(guid) else { continue }; + if !nodes.get(&id).is_some_and(|latest| std::ptr::eq(*latest, n)) { continue; } if let Some(p) = n.get("parentIndex") @@ -285,7 +304,6 @@ impl<'a> Mapper<'a> { prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty)); } } - nodes.insert(id, n); } for list in kids.values_mut() { list.sort(); @@ -302,7 +320,21 @@ impl<'a> Mapper<'a> { let version = n.get("version").and_then(Kv::str).filter(|v| !v.is_empty() && v.len() <= MAX_ASSET_PART).unwrap_or(""); assets.entry(key.to_string()).or_default().entry(version.to_string()).or_insert(id); } - Self { nodes, kids, blobs, prop_defs, assets, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 } + Self { + nodes, + kids, + blobs, + prop_defs, + assets, + styles: Obj::new(), + components: Obj::new(), + sets: Obj::new(), + library_contexts: Vec::new(), + library_scan_count: 0, + max_nodes: MAX_NODES, + warnings: Vec::new(), + count: 0, + } } /// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`, @@ -345,6 +377,8 @@ impl<'a> Mapper<'a> { None => self.nodes.iter().filter(|(_, n)| n.get("type").and_then(Kv::str) == Some("CANVAS")).map(|(id, _)| id.clone()).collect(), }; let mut out = Vec::new(); + let mut internal = Vec::new(); + let mut visible_pages = Vec::new(); for p in pages { let Some(&n) = self.nodes.get(&p) else { continue }; if n.get("type").and_then(Kv::str) != Some("CANVAS") { @@ -353,16 +387,171 @@ impl<'a> Mapper<'a> { } // The hidden page that holds styles, variables and library copies. if n.get("internalOnly").and_then(Kv::bool) == Some(true) { + internal.push((p, n)); continue; } + visible_pages.push(p.clone()); out.push(self.page(&p, n)); } + let required_mains = self.referenced_component_mains(&visible_pages); + for (id, canvas) in internal { + if let Some(context) = self.library_context(&id, canvas, &required_mains) { + self.library_contexts.push(context); + } + } if out.is_empty() { self.warn("the file has no pages"); } json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT", "children": out}) } + /// The ids of the components the visible pages use, directly or through the components they + /// use in turn (nested instances, swap targets, overrides naming another main). + fn referenced_component_mains(&mut self, visible_pages: &[String]) -> BTreeSet { + let mut mains = BTreeSet::new(); + let mut visited = BTreeSet::new(); + let mut pending: Vec<(String, usize)> = visible_pages.iter().flat_map(|page| self.children(page)).map(|id| (id, 1)).collect(); + while let Some((id, depth)) = pending.pop() { + if depth > MAX_DEPTH { + self.warn(format!("component references below {MAX_DEPTH} layer levels were skipped")); + continue; + } + if !visited.insert(id.clone()) { + continue; + } + if visited.len() > self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the component reference scan reached the {max}-layer limit")); + break; + } + let Some(node) = self.nodes.get(&id) else { continue }; + for target in self.component_main_targets(node) { + if mains.insert(target.clone()) { + pending.push((target, 1)); + } + } + pending.extend(self.children(&id).into_iter().map(|child| (child, depth + 1))); + } + mains + } + + /// The mains a layer names: an instance's own, the ones its overrides swap in, the instance-swap + /// defaults and values of component properties. + fn component_main_targets(&self, node: &Kv<'a>) -> Vec { + let mut targets: Vec = node + .get("componentPropDefs") + .map(Kv::list) + .unwrap_or_default() + .iter() + .filter_map(|definition| { + self.is_instance_swap(definition.get("id")?)?; + definition.get("initialValue")?.get("guidValue").and_then(guid) + }) + .collect(); + if node.get("type").and_then(Kv::str) != Some("INSTANCE") { + return targets; + } + // The instance and the override rows below it (rows carry the same fields, nested). + let mut pending: Vec<(&Kv<'a>, usize)> = vec![(node, 0)]; + let mut rows = 0usize; + while let Some((row, depth)) = pending.pop() { + rows += 1; + if depth > MAX_DEPTH || rows > self.max_nodes { + break; + } + let data = row.get("symbolData"); + targets.extend(row.get("overriddenSymbolID").and_then(guid)); + targets.extend(data.and_then(|d| d.get("symbolID")).and_then(guid)); + for assignment in row.get("componentPropAssignments").map(Kv::list).unwrap_or_default() { + if assignment.get("defID").is_some_and(|def| self.is_instance_swap(def).is_some()) { + targets.extend(assignment.get("value").and_then(|v| v.get("guidValue")).and_then(guid)); + } + } + let overrides = data.and_then(|d| d.get("symbolOverrides")).map(Kv::list).unwrap_or_default(); + let derived = row.get("derivedSymbolData").map(Kv::list).unwrap_or_default(); + pending.extend(overrides.iter().chain(derived).map(|row| (row, depth + 1))); + } + targets + } + + fn is_instance_swap(&self, def: &Kv) -> Option<()> { + matches!(self.prop_defs.get(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(()) + } + + /// The branches of the internal canvas `id` that hold the `required_mains` (with the frames + /// they sit in), as a frame of its own: the cached definitions of components that come from a + /// library. `None` when it holds none. + fn library_context(&mut self, id: &str, canvas: &'a Kv<'a>, required_mains: &BTreeSet) -> Option { + let mut included = BTreeSet::new(); + let mut parents: BTreeMap = BTreeMap::new(); + // The canvas itself is never a layer of the walk, whatever the parent links say. + let mut visited = BTreeSet::from([id.to_string()]); + // Each entry: layer, depth, inside a required definition. + let mut pending: Vec<(String, usize, bool)> = self.children(id).into_iter().map(|child| (child, 1, false)).collect(); + while let Some((node_id, depth, in_definition)) = pending.pop() { + if depth > MAX_DEPTH { + self.warn(format!("internal library branches deeper than {MAX_DEPTH} levels were skipped")); + continue; + } + if !visited.insert(node_id.clone()) { + continue; + } + self.library_scan_count += 1; + if self.library_scan_count > self.max_nodes { + let max = self.max_nodes; + self.warn(format!("the internal library scan reached the {max}-layer limit; the remaining branches were skipped")); + break; + } + let Some(node) = self.nodes.get(&node_id) else { continue }; + let kind = node.get("type").and_then(Kv::str); + let definition = (kind == Some("SYMBOL") || (kind == Some("FRAME") && node.get("isStateGroup").and_then(Kv::bool) == Some(true))) + && required_mains.contains(&node_id); + let in_definition = in_definition || definition; + if in_definition { + included.insert(node_id.clone()); + } + if definition { + // The frames around it keep the path its layers are addressed by. + let mut ancestor = parents.get(&node_id); + for _ in 0..=MAX_DEPTH { + let Some(parent) = ancestor else { break }; + if !included.insert(parent.clone()) { + break; + } + ancestor = parents.get(parent); + } + } + for child in self.children(&node_id).into_iter().rev() { + // A layer reached again keeps the parent it was first reached by, so the parents + // lead back to the canvas. + if visited.contains(&child) { + continue; + } + parents.entry(child.clone()).or_insert_with(|| node_id.clone()); + pending.push((child, depth + 1, in_definition)); + } + } + if included.is_empty() { + return None; + } + let mut context = Obj::new(); + context.insert("id".into(), json!(id)); + context.insert("type".into(), json!("FRAME")); + copy_str(&mut context, &View::of(canvas), "name", "name"); + context.insert("size".into(), json!({"x": 0.0, "y": 0.0})); + context.insert("relativeTransform".into(), rows(Affine::IDENTITY)); + context.insert(crate::rest::FIG_SOURCE_FIELD.into(), json!({"origin": id, "expanded": false, "wireFrame": true, "wireInstance": false})); + self.variable_bindings(&mut context, &View::of(canvas)); + let mut children = Vec::new(); + for child in self.children(id) { + children.extend(self.layer_filtered(&child, None, Affine::IDENTITY, None, None, 0, Some(&included))); + } + if !children.is_empty() { + context.insert("children".into(), Value::Array(children)); + } + Some(Value::Object(context)) + } + fn page(&mut self, id: &str, n: &'a Kv<'a>) -> Value { let v = View::of(n); let mut o = Obj::new(); @@ -405,6 +594,24 @@ impl<'a> Mapper<'a> { fit: Option, depth: usize, ) -> Option { + self.layer_filtered(id, inst, space, parent, fit, depth, None) + } + + /// [`Self::layer`], keeping only the layers in `included` (all when `None`) below the + /// top-level one: the cached library definitions of an internal canvas. + fn layer_filtered( + &mut self, + id: &str, + inst: Option<&Inst<'a>>, + space: Affine, + parent: Option<&View<'a>>, + fit: Option, + depth: usize, + included: Option<&BTreeSet>, + ) -> Option { + if included.is_some_and(|ids| !ids.contains(id)) { + return None; + } if depth > MAX_DEPTH { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; @@ -457,6 +664,11 @@ impl<'a> Mapper<'a> { self.count += 1; let mut o = Obj::new(); o.insert("id".into(), json!(rest_id)); + // Private provenance for the .fig importer; ordinary REST import ignores it. + o.insert( + crate::rest::FIG_SOURCE_FIELD.into(), + json!({"origin": id, "expanded": inst.is_some(), "wireFrame": own == "FRAME", "wireInstance": own == "INSTANCE"}), + ); o.insert("type".into(), json!(ty)); copy_str(&mut o, &view, "name", "name"); self.common(&mut o, &view, ty, space, parent); @@ -491,7 +703,8 @@ impl<'a> Mapper<'a> { let main = self.nodes.get(&sym).map(|m| View::of(m)); let fit = Fit::frame(main.and_then(|m| m.size()), view.size()); for c in self.children(&sym) { - kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), fit, depth + 1)); + // The main's branch can live outside the cached context being mapped. + kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None)); } } } else { @@ -503,7 +716,7 @@ impl<'a> Mapper<'a> { (Some(_), _) => Fit::frame(View::of(base).size(), view.size()), }; for c in self.children(id) { - kids.extend(self.layer(&c, inst, child_space, Some(&view), fit, depth + 1)); + kids.extend(self.layer_filtered(&c, inst, child_space, Some(&view), fit, depth + 1, included)); } } if stand.is_some_and(|s| s.0 == "FRAME") { diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index ec2a0e23..24f304a3 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -21,6 +21,8 @@ pub mod kiwi; mod map; mod network; #[cfg(test)] +mod source_identity_tests; +#[cfg(test)] mod tests; use std::io::Read; @@ -64,7 +66,7 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); let (json, mut warnings) = map::to_rest(&msg, file_name); let images = |r: &str| archive.image(r); - let (doc, more) = crate::rest::import(&json, &images)?; + let (doc, more) = crate::rest::import_fig(&json, &images)?; warnings.extend(more.into_iter().filter_map(map::adapt_warning)); Ok((doc, warnings)) } diff --git a/crates/format/src/fig/source_identity_tests.rs b/crates/format/src/fig/source_identity_tests.rs new file mode 100644 index 00000000..955df1da --- /dev/null +++ b/crates/format/src/fig/source_identity_tests.rs @@ -0,0 +1,938 @@ +//! Synthetic .fig source identity: cached library components, contextual sources, main switches +//! and invalid provenance. + +use figcraft_doc::color::PaintKind; +use figcraft_doc::{Document, NodeId, NodeKind, PropDef}; +use serde_json::{Value, json}; + +use super::kiwi::{Kv, Schema}; +use super::*; + +type Fields = Vec<(&'static str, Kv<'static>)>; + +fn o(fields: Fields) -> Kv<'static> { + Kv::Object(fields) +} + +fn g(id: u64) -> Kv<'static> { + o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))]) +} + +fn s(value: &str) -> Kv<'static> { + Kv::Str(value.into()) +} + +fn matrix(x: f32) -> Kv<'static> { + o(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(x)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]) +} + +fn node(id: u64, parent: Option, ty: &'static str, name: &str, more: Fields) -> Kv<'static> { + let mut fields = vec![("guid", g(id)), ("phase", Kv::Enum("CREATED")), ("type", Kv::Enum(ty)), ("name", s(name))]; + if let Some(parent) = parent { + fields.push(("parentIndex", o(vec![("guid", g(parent)), ("position", s(&format!("{id:04}")))]))); + } + fields.extend(more); + o(fields) +} + +fn symbol(main: u64, overrides: Vec>) -> Kv<'static> { + o(vec![("symbolID", g(main)), ("symbolOverrides", Kv::Array(overrides))]) +} + +fn row(path: &[u64], more: Fields) -> Kv<'static> { + let mut fields = vec![("guidPath", o(vec![("guids", Kv::Array(path.iter().map(|id| g(*id)).collect()))]))]; + fields.extend(more); + o(fields) +} + +fn solid(r: f32, g: f32, b: f32) -> Kv<'static> { + o(vec![ + ("type", Kv::Enum("SOLID")), + ("color", o(vec![("r", Kv::Float(r)), ("g", Kv::Float(g)), ("b", Kv::Float(b)), ("a", Kv::Float(1.0))])), + ("opacity", Kv::Float(1.0)), + ("visible", Kv::Bool(true)), + ]) +} + +fn schema() -> Schema { + Schema::parse(export::SCHEMA).unwrap() +} + +fn cached_component_message() -> Kv<'static> { + o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Visible page", vec![]), + node( + 3, + Some(1), + "CANVAS", + "Remote library", + vec![ + ("internalOnly", Kv::Bool(true)), + ( + "variableModeBySetMap", + o(vec![("entries", Kv::Array(vec![o(vec![("variableSetID", o(vec![("guid", g(30))])), ("variableModeID", g(52))])]))]), + ), + ], + ), + node( + 30, + Some(3), + "VARIABLE_SET", + "Remote theme", + vec![( + "variableSetModes", + Kv::Array(vec![ + o(vec![("id", g(51)), ("name", s("Light")), ("sortPosition", s("!"))]), + o(vec![("id", g(52)), ("name", s("Dark")), ("sortPosition", s("\""))]), + ]), + )], + ), + node( + 9, + Some(3), + "FRAME", + "Cached component set", + vec![("isStateGroup", Kv::Bool(true)), ("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), ("transform", matrix(0.0))], + ), + node( + 10, + Some(9), + "SYMBOL", + "Cached remote icon", + vec![("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), ("transform", matrix(0.0))], + ), + node( + 11, + Some(10), + "ROUNDED_RECTANGLE", + "Cached path", + vec![("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), ("transform", matrix(0.0))], + ), + node( + 12, + Some(10), + "INSTANCE", + "Nested cached instance", + vec![ + ("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), + ("transform", matrix(0.0)), + ("symbolData", symbol(22, vec![row(&[23], vec![("fillPaints", Kv::Array(vec![solid(0.2, 0.7, 0.1)]))])])), + ], + ), + node( + 13, + Some(9), + "SYMBOL", + "Nested cached main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 14, + Some(13), + "ROUNDED_RECTANGLE", + "Nested cached path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 15, + Some(10), + "INSTANCE", + "Nested internal instance", + vec![ + ("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), + ("transform", matrix(0.0)), + ("symbolData", symbol(13, vec![])), + ], + ), + node( + 20, + Some(2), + "INSTANCE", + "Visible icon instance", + vec![ + ("size", o(vec![("x", Kv::Float(24.0)), ("y", Kv::Float(24.0))])), + ("transform", matrix(0.0)), + ("symbolData", symbol(10, vec![])), + ], + ), + node( + 22, + Some(2), + "SYMBOL", + "Visible nested main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 23, + Some(22), + "ROUNDED_RECTANGLE", + "Visible nested path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + ]), + )]) +} + +fn cached_component_fixture() -> Vec { + let schema = schema(); + super::tests::canvas(&schema, &cached_component_message()) +} + +fn cached_component_override_fixture() -> Vec { + let mut message = cached_component_message(); + let Kv::Object(fields) = &mut message else { panic!("cached component fixture has an unexpected wire shape") }; + let changes = &mut fields.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1; + let Kv::Array(nodes) = changes else { panic!("cached component fixture has an unexpected wire shape") }; + let instance = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Visible icon instance")).unwrap(); + let Kv::Object(fields) = instance else { panic!("cached component fixture has an unexpected wire shape") }; + fields.iter_mut().find(|(key, _)| *key == "symbolData").unwrap().1 = + symbol(10, vec![row(&[12], vec![("symbolData", symbol(22, vec![row(&[26], vec![("overriddenSymbolID", g(24))])]))])]); + nodes.extend([ + node( + 24, + Some(3), + "SYMBOL", + "Overridden cached main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 25, + Some(24), + "ROUNDED_RECTANGLE", + "Overridden cached path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 26, + Some(22), + "INSTANCE", + "Deep cached instance", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0)), ("symbolData", symbol(27, vec![]))], + ), + node( + 27, + Some(3), + "SYMBOL", + "Base deep main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 28, + Some(27), + "ROUNDED_RECTANGLE", + "Base deep path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + ]); + super::tests::canvas(&schema(), &message) +} + +fn cached_component_default_swap_fixture() -> Vec { + let mut message = cached_component_message(); + let Kv::Object(fields) = &mut message else { panic!("cached component fixture has an unexpected wire shape") }; + let changes = &mut fields.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1; + let Kv::Array(nodes) = changes else { panic!("cached component fixture has an unexpected wire shape") }; + let owner = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Visible nested main")).unwrap(); + let Kv::Object(fields) = owner else { panic!("cached component fixture has an unexpected wire shape") }; + fields.push(( + "componentPropDefs", + Kv::Array(vec![o(vec![ + ("id", g(90)), + ("name", s("Icon")), + ("type", Kv::Enum("INSTANCE_SWAP")), + ("initialValue", o(vec![("guidValue", g(24))])), + ])]), + )); + nodes.extend([ + node( + 24, + Some(3), + "SYMBOL", + "Default cached main", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + node( + 25, + Some(24), + "ROUNDED_RECTANGLE", + "Default cached path", + vec![("size", o(vec![("x", Kv::Float(12.0)), ("y", Kv::Float(12.0))])), ("transform", matrix(0.0))], + ), + ]); + super::tests::canvas(&schema(), &message) +} + +fn assert_default_swap(doc: &Document) { + let owner = named_node(doc, "Visible nested main"); + let target = named_node(doc, "Default cached main"); + let NodeKind::Component { props, .. } = &doc.node(owner).unwrap().kind else { panic!("visible component lost its property definition") }; + assert_eq!(props.get("Icon"), Some(&PropDef::InstanceSwap { default: target, preferred: Vec::new() }), "{props:?}"); + assert!(doc.is_remote(target)); +} + +fn named_node(doc: &Document, name: &str) -> NodeId { + doc.nodes().find(|node| node.name == name).unwrap().id +} + +fn assert_dark_remote_mode(doc: &Document, node: NodeId) { + let collection = doc.variables.collections.iter().find(|collection| collection.name == "Remote theme").unwrap(); + let dark = collection.modes.iter().find(|mode| mode.name == "Dark").unwrap(); + assert_eq!(doc.modes_for(node).get(&collection.id), Some(&dark.id)); +} + +fn assert_no_linked_library_records(doc: &Document) { + assert!(doc.libraries.enabled.is_empty()); + assert!(doc.libraries.components.is_empty()); + assert!(doc.libraries.styles.is_empty()); + assert!(doc.libraries.collections.is_empty()); + assert!(doc.libraries.variables.is_empty()); +} + +#[test] +fn hidden_cached_component_is_retained_and_linked() { + let (doc, warnings) = import(&cached_component_fixture(), "Cached library.fig").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(doc.pages.len(), 1, "hidden cache must not become a visible page"); + let check = |doc: &Document| { + let store = doc.library_store().expect("private .fig cache is retained"); + let component = named_node(doc, "Cached remote icon"); + let path = doc.node(component).unwrap().children[0]; + let set = doc.node(component).unwrap().parent.unwrap(); + assert!(matches!(doc.node(set).unwrap().kind, NodeKind::ComponentSet { .. })); + assert_eq!(doc.node(doc.node(set).unwrap().parent.unwrap()).unwrap().parent, Some(store)); + assert!(doc.is_remote(component) && doc.is_remote(path)); + assert!(matches!(doc.node(component).unwrap().kind, NodeKind::Component { .. })); + assert_dark_remote_mode(doc, component); + assert_no_linked_library_records(doc); + // The visible instance resolves to the cached main, and its layer to the main's layer. + let instance = named_node(doc, "Visible icon instance"); + assert!(matches!(doc.node(instance).unwrap().kind, NodeKind::Instance { component: linked, .. } if linked == component)); + assert_eq!(doc.node(doc.node(instance).unwrap().children[0]).unwrap().source, Some(path)); + // A cached main nested in another cached main is kept too, and so is an instance in the + // cache of a visible main, with the override its row carries. + let nested_main = named_node(doc, "Nested cached main"); + let nested = named_node(doc, "Nested internal instance"); + assert!(doc.is_remote(nested_main)); + assert!(matches!(doc.node(nested).unwrap().kind, NodeKind::Instance { component: linked, .. } if linked == nested_main)); + let external_main = named_node(doc, "Visible nested main"); + let external = doc + .node(component) + .unwrap() + .children + .iter() + .copied() + .find(|id| doc.node(*id).is_some_and(|n| n.name == "Nested cached instance")) + .unwrap(); + assert!(matches!(doc.node(external).unwrap().kind, NodeKind::Instance { component: linked, .. } if linked == external_main)); + let external_child = doc.node(external).unwrap().children[0]; + assert_eq!(doc.node(external_child).unwrap().source, Some(doc.node(external_main).unwrap().children[0])); + assert_eq!(fill_rgb_node(doc, external_child), Some([f64::from(0.2_f32), f64::from(0.7_f32), f64::from(0.1_f32)])); + }; + check(&doc); + let (saved, warnings) = crate::load_bytes(&crate::save_bytes(&doc, None).unwrap()).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + check(&saved); +} + +#[test] +fn visible_instance_override_retains_and_links_hidden_cached_main() { + let (doc, warnings) = import(&cached_component_override_fixture(), "Cached override.fig").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let nested = named_node(&doc, "Nested cached instance"); + let deep = named_node(&doc, "Deep cached instance"); + assert_eq!(doc.node(deep).unwrap().parent, Some(nested)); + let NodeKind::Instance { component: overridden, .. } = doc.node(deep).unwrap().kind else { panic!("nested-row override lost its instance") }; + let overridden = doc.node(overridden).unwrap(); + assert_eq!(overridden.name, "Overridden cached main"); + assert!(doc.is_remote(overridden.id)); + assert_eq!(doc.node(doc.node(deep).unwrap().children[0]).unwrap().source, Some(overridden.children[0])); +} + +#[test] +fn visible_component_default_swap_retains_hidden_cached_main() { + let (doc, warnings) = import(&cached_component_default_swap_fixture(), "Cached default swap.fig").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_default_swap(&doc); + let (saved, _) = crate::load_bytes(&crate::save_bytes(&doc, None).unwrap()).unwrap(); + assert_default_swap(&saved); +} + +#[test] +fn public_rest_import_ignores_private_fig_provenance() { + let value = json!({ + "name": "Public REST", + "document": {"type": "DOCUMENT", "children": [{"type": "CANVAS", "name": "Page", "children": []}]}, + "_figLibraryContexts": [{"type": "FRAME", "name": "Private cache", "children": [{"type": "COMPONENT", "name": "Must not import", "children": []}]}] + }); + let (doc, warnings) = crate::rest::import(&value, &|_| None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(doc.library_store().is_none() && doc.nodes().all(|node| node.name != "Must not import")); + // Valid provenance on mapped layers links nothing either. + for message in [message(false), external_namespace_message()] { + let (json, _) = super::map::to_rest(&message, "Identity"); + let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); + assert!(doc.nodes().all(|n| n.source.is_none())); + } +} + +#[test] +fn the_last_change_of_a_guid_wins_and_removed_changes_delete() { + let moved = node(12, Some(11), "FRAME", "Moved frame", vec![]); + let mut removed = node(13, Some(10), "FRAME", "Removed frame", vec![]); + let Kv::Object(fields) = &mut removed else { panic!() }; + fields.iter_mut().find(|(key, _)| *key == "phase").unwrap().1 = Kv::Enum("REMOVED"); + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(10, Some(2), "FRAME", "Left", vec![]), + node(11, Some(2), "FRAME", "Right", vec![]), + node(12, Some(10), "FRAME", "Moved frame", vec![]), + node(13, Some(10), "FRAME", "Removed frame", vec![]), + moved, + removed, + ]), + )]); + let (doc, _) = import(&super::tests::canvas(&schema(), &message), "Changes.fig").unwrap(); + assert_eq!(doc.node(named_node(&doc, "Moved frame")).unwrap().parent, Some(named_node(&doc, "Right"))); + assert!(doc.nodes().all(|n| n.name != "Removed frame")); + assert!(doc.node(named_node(&doc, "Left")).unwrap().children.is_empty()); +} + +#[test] +fn internal_library_parent_cycles_are_bounded() { + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "CANVAS", "Visible page", vec![]), + node(2, Some(1), "INSTANCE", "Visible cyclic main", vec![("symbolData", symbol(11, vec![]))]), + node(3, Some(9), "CANVAS", "Cyclic library", vec![("internalOnly", Kv::Bool(true))]), + node(9, Some(11), "FRAME", "Loop frame", vec![]), + node(10, Some(3), "SYMBOL", "Unrelated component", vec![]), + node(11, Some(3), "SYMBOL", "Loop component", vec![]), + ]), + )]); + let (doc, warnings) = import(&super::tests::canvas(&schema(), &message), "Cyclic library.fig").unwrap(); + assert!(warnings.iter().any(|warning| warning.contains("deeper than")), "{warnings:?}"); + assert!(doc.node_count() < 1000, "the loop is cut at the nesting limit"); +} + +#[test] +fn parent_loops_through_the_internal_canvas_end() { + // The canvas is a child of a frame inside one of its own symbols, and the used main sits below + // that frame: the walk up from the main reaches the canvas again. + let message = o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "CANVAS", "Visible page", vec![]), + node(2, Some(1), "INSTANCE", "Uses the main", vec![("symbolData", symbol(12, vec![]))]), + node(3, Some(9), "CANVAS", "Loop library", vec![("internalOnly", Kv::Bool(true))]), + node(9, Some(11), "FRAME", "Loop frame", vec![]), + node(11, Some(3), "SYMBOL", "Loop symbol", vec![]), + node(12, Some(9), "SYMBOL", "Used main", vec![]), + ]), + )]); + let (doc, _) = import(&super::tests::canvas(&schema(), &message), "Loop.fig").unwrap(); + assert!(doc.nodes().any(|n| n.name == "Used main")); +} + +fn fill_rgb_node(doc: &Document, id: NodeId) -> Option<[f64; 3]> { + let node = doc.node(id)?; + match &node.fills.first()?.kind { + PaintKind::Solid { color } => Some([f64::from(color.r), f64::from(color.g), f64::from(color.b)]), + _ => None, + } +} + +fn message(swapped: bool) -> Kv<'static> { + let shape = |id, parent, x| node(id, Some(parent), "ROUNDED_RECTANGLE", "Same layer", vec![("transform", matrix(x))]); + let mut changes = vec![ + node(1, None, "DOCUMENT", "Identity", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node(10, Some(2), "SYMBOL", "Renamed Left main", vec![]), + node(11, Some(10), "FRAME", "Frame", vec![("transform", matrix(2.0))]), + shape(12, 11, 2.0), + shape(13, 11, 4.0), + shape(14, 11, 6.0), + node(15, Some(11), "FRAME", "Deep frame", vec![]), + node(16, Some(15), "FRAME", "Deeper frame", vec![]), + shape(17, 16, 8.0), + node( + 20, + Some(2), + "SYMBOL", + "State=Right, Renamed=Yes", + vec![( + "componentPropDefs", + Kv::Array(vec![o(vec![ + ("id", g(200)), + ("name", s("Enabled")), + ("type", Kv::Enum("BOOL")), + ("initialValue", o(vec![("boolValue", Kv::Bool(true))])), + ])]), + )], + ), + node(21, Some(20), "FRAME", "Frame", vec![("transform", matrix(if swapped { 23.0 } else { 2.0 }))]), + shape(22, 21, 2.0), + shape(23, 21, 4.0), + shape(24, 21, 6.0), + node(30, Some(2), "SYMBOL", "Outer", vec![]), + node(40, Some(30), "INSTANCE", "Original wrapper", vec![("symbolData", symbol(50, vec![row(&[60, 21], vec![("transform", matrix(3.0))])]))]), + node(50, Some(2), "SYMBOL", "Renamed wrapper main", vec![]), + node( + 60, + Some(50), + "INSTANCE", + "Same nested slot", + vec![ + ("symbolData", symbol(20, vec![])), + ("visible", Kv::Bool(false)), + ("opacity", Kv::Float(0.3)), + ("mask", Kv::Bool(true)), + ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(200)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ], + ), + node(61, Some(50), "INSTANCE", "Same nested slot", vec![("symbolData", symbol(20, vec![]))]), + ]; + let mut nested = vec![ + ("name", s("Renamed nested slot")), + ("visible", Kv::Bool(true)), + ("opacity", Kv::Float(1.0)), + ("mask", Kv::Bool(false)), + ("componentPropAssignments", Kv::Array(vec![])), + ]; + if swapped { + nested.push(("overriddenSymbolID", g(10))); + } + changes.push(node( + 70, + Some(2), + "INSTANCE", + "Copy", + vec![( + "symbolData", + symbol( + 30, + vec![ + row(&[40], vec![("name", s("Renamed wrapper"))]), + row(&[40, 60], nested), + row(&[40, 60, if swapped { 11 } else { 21 }], vec![("transform", matrix(2.0))]), + ], + ), + )], + )); + o(vec![("nodeChanges", Kv::Array(changes))]) +} + +fn import_message(swapped: bool) -> Document { + let schema = Schema::parse(export::SCHEMA).unwrap(); + let bytes = super::tests::canvas(&schema, &message(swapped)); + let (doc, notes) = import(&bytes, "Identity.fig").unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + doc +} + +fn named(doc: &Document, name: &str) -> NodeId { + doc.nodes().find(|n| n.name == name).unwrap().id +} + +fn child(doc: &Document, id: NodeId, index: usize) -> NodeId { + doc.node(id).unwrap().children[index] +} + +fn assert_contextual(doc: &Document) { + let outer = named(doc, "Outer"); + let right = named(doc, "State=Right, Renamed=Yes"); + let wrapper_main = named(doc, "Renamed wrapper main"); + let copy = named(doc, "Copy"); + let main_wrapper = child(doc, outer, 0); + let copy_wrapper = child(doc, copy, 0); + assert_eq!(doc.node(copy).unwrap().source, Some(outer)); + assert_eq!(doc.node(copy_wrapper).unwrap().source, Some(main_wrapper)); + assert!(matches!(&doc.node(copy_wrapper).unwrap().kind, NodeKind::Instance {component, ..} if *component == wrapper_main)); + for index in 0..2 { + let default_slot = child(doc, main_wrapper, index); + let copy_slot = child(doc, copy_wrapper, index); + assert_eq!(doc.node(copy_slot).unwrap().source, Some(default_slot), "slot {index}"); + assert!(matches!(&doc.node(default_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + assert!(matches!(&doc.node(copy_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + } + let default_slot = child(doc, main_wrapper, 0); + let copy_slot = child(doc, copy_wrapper, 0); + let inherited = child(doc, default_slot, 0); + let copy_frame = child(doc, copy_slot, 0); + assert!(matches!(doc.node(copy_frame).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(inherited).unwrap().transform.translation().x, 3.0); + assert_eq!(doc.node(copy_frame).unwrap().transform.translation().x, 2.0); + assert_eq!(doc.node(copy_frame).unwrap().source, Some(inherited)); + for index in 0..3 { + assert_eq!(doc.node(child(doc, copy_frame, index)).unwrap().source, Some(child(doc, inherited, index))); + assert_eq!(doc.node(child(doc, copy_frame, index)).unwrap().transform.translation().x, 2.0 + 2.0 * index as f64); + } + let a = doc.node(default_slot).unwrap(); + let b = doc.node(copy_slot).unwrap(); + assert!(!a.visible && a.is_mask && a.opacity < 1.0); + assert!(b.visible && !b.is_mask && b.opacity == 1.0); + assert!(matches!(&a.kind, NodeKind::Instance {props, ..} if props.len() == 1 && props.values().all(|p| p == &json!(false)))); + assert!(matches!(&b.kind, NodeKind::Instance {props, ..} if props.is_empty())); +} + +#[test] +fn contextual_sources_preserve_inherited_geometry_and_explicit_clears() { + assert_contextual(&import_message(false)); +} + +fn assert_swapped(doc: &Document) { + let outer = named(doc, "Outer"); + let right = named(doc, "State=Right, Renamed=Yes"); + let copy = named(doc, "Copy"); + let main_wrapper = child(doc, outer, 0); + let copy_wrapper = child(doc, copy, 0); + let copy_slot = child(doc, copy_wrapper, 0); + let default_slot = child(doc, main_wrapper, 0); + let left = named(doc, "Renamed Left main"); + assert_eq!(doc.node(copy).unwrap().source, Some(outer)); + assert_eq!(doc.node(copy_wrapper).unwrap().source, Some(main_wrapper)); + assert_eq!(doc.node(copy_slot).unwrap().source, Some(default_slot)); + assert!(matches!(&doc.node(copy_slot).unwrap().kind, NodeKind::Instance {component, props} if *component == left && props.is_empty())); + assert!(matches!(&doc.node(default_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + let current_frame = child(doc, copy_slot, 0); + let left_frame = child(doc, left, 0); + assert!(matches!(doc.node(current_frame).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(current_frame).unwrap().source, Some(left_frame)); + assert_eq!(doc.node(current_frame).unwrap().transform.translation().x, 2.0); + assert_eq!(doc.node(child(doc, right, 0)).unwrap().transform.translation().x, 23.0); + for index in 0..3 { + assert_eq!(doc.node(child(doc, current_frame, index)).unwrap().source, Some(child(doc, left_frame, index))); + assert_eq!(doc.node(child(doc, current_frame, index)).unwrap().transform.translation().x, 2.0 + 2.0 * index as f64); + } + let deep_copy = child(doc, child(doc, child(doc, current_frame, 3), 0), 0); + let deep_main = child(doc, child(doc, child(doc, left_frame, 3), 0), 0); + assert_eq!(doc.node(deep_copy).unwrap().source, Some(deep_main)); + assert_eq!(doc.node(deep_copy).unwrap().transform.translation().x, 8.0); + let other = child(doc, copy_wrapper, 1); + assert_eq!(doc.node(other).unwrap().source, Some(child(doc, main_wrapper, 1))); + assert!(matches!(&doc.node(other).unwrap().kind, NodeKind::Instance {component, ..} if *component == right)); + assert!(doc.node(copy_slot).unwrap().visible && !doc.node(copy_slot).unwrap().is_mask && doc.node(copy_slot).unwrap().opacity == 1.0); +} + +#[test] +fn swapped_children_use_current_main_through_frames_and_multiple_siblings() { + assert_swapped(&import_message(true)); +} + +fn edit_node(value: &mut Value, id: &str, edit: &mut dyn FnMut(&mut Value)) { + if value.get("id").and_then(Value::as_str) == Some(id) { + edit(value); + } + if let Some(kids) = value.get_mut("children").and_then(Value::as_array_mut) { + for child in kids { + edit_node(child, id, edit); + } + } +} + +#[test] +fn missing_foreign_self_and_wrong_namespace_provenance_is_not_guessed() { + for (origin, id) in [("1:99999", "I1:70;1:40"), ("1:12", "I1:70;1:40"), ("1:70", "1:70"), ("1:40", "I1:70;1:40")] { + let (mut json, _) = super::map::to_rest(&message(false), "Identity"); + edit_node(&mut json["document"]["children"][0], id, &mut |node| { + node[crate::rest::FIG_SOURCE_FIELD]["origin"] = json!(origin); + if id == "1:70" { + node["componentId"] = json!("1:70"); // a root cannot be its own component + } else if origin == "1:40" { + node["id"] = json!("I1:999;1:40"); + } else { + node["id"] = json!(format!("I1:70;{origin}")); // valid namespace, but missing/foreign target + } + }); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let target = if id == "1:70" { named(&doc, "Copy") } else { named(&doc, "Renamed wrapper") }; + assert!(doc.node(target).unwrap().source.is_none(), "{origin}/{id}"); + } +} + +#[test] +fn forward_main_references_and_duplicate_ids_are_conservative() { + let (mut json, _) = super::map::to_rest(&message(false), "Identity"); + json["document"]["children"][0]["children"].as_array_mut().unwrap().rotate_right(1); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + assert!(doc.node(named(&doc, "Copy")).unwrap().source.is_some()); + // 1:40 is nested inside Outer, so duplicate that actual template at page level. + let template = json["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == "1:30").unwrap()["children"][0].clone(); + json["document"]["children"][0]["children"].as_array_mut().unwrap().push(template); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + assert!(doc.node(named(&doc, "Renamed wrapper")).unwrap().source.is_none()); +} + +#[test] +fn missing_contextual_targets_do_not_fall_back_to_a_global_slot() { + let (mut json, _) = super::map::to_rest(&message(false), "Identity"); + edit_node(&mut json["document"]["children"][0], "1:40", &mut |node| { + node["children"].as_array_mut().unwrap().retain(|n| n["id"] != "I1:40;1:60"); + }); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let copy_slot = child(&doc, child(&doc, named(&doc, "Copy"), 0), 0); + assert!(doc.node(copy_slot).unwrap().source.is_none()); + assert!(doc.nodes().any(|n| n.name == "Same nested slot"), "the raw slot still exists"); +} + +#[test] +fn instance_to_empty_frame_keeps_its_source_identity() { + let mut msg = message(false); + let Kv::Object(top) = &mut msg else { panic!() }; + let Kv::Array(nodes) = &mut top.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1 else { panic!() }; + let copy = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("Copy")).unwrap(); + let Kv::Object(fields) = copy else { panic!() }; + let Kv::Object(symbol) = &mut fields.iter_mut().find(|(key, _)| *key == "symbolData").unwrap().1 else { panic!() }; + let Kv::Array(overrides) = &mut symbol.iter_mut().find(|(key, _)| *key == "symbolOverrides").unwrap().1 else { panic!() }; + overrides.push(row(&[40, 61], vec![("type", Kv::Enum("FRAME"))])); + let (doc, _) = import(&super::tests::canvas(&schema(), &msg), "Identity.fig").unwrap(); + let main = child(&doc, named(&doc, "Outer"), 0); + let copy = child(&doc, named(&doc, "Copy"), 0); + let frame = child(&doc, copy, 1); + assert!(matches!(doc.node(frame).unwrap().kind, NodeKind::Frame)); + assert!(doc.node(frame).unwrap().children.is_empty()); + assert_eq!(doc.node(frame).unwrap().source, Some(child(&doc, main, 1))); +} + +#[test] +fn explicit_frame_to_instance_resets_children_to_the_resolved_main() { + let mut msg = message(false); + let Kv::Object(top) = &mut msg else { panic!() }; + let Kv::Array(nodes) = &mut top.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap().1 else { panic!() }; + nodes.push(node(80, Some(30), "FRAME", "Original frame", vec![])); + nodes.push(node(81, Some(80), "ROUNDED_RECTANGLE", "Old frame child", vec![])); + let copy = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("Copy")).unwrap(); + let Kv::Object(fields) = copy else { panic!() }; + let Kv::Object(symbol) = &mut fields.iter_mut().find(|(key, _)| *key == "symbolData").unwrap().1 else { panic!() }; + let Kv::Array(overrides) = &mut symbol.iter_mut().find(|(key, _)| *key == "symbolOverrides").unwrap().1 else { panic!() }; + overrides.push(row(&[80], vec![("type", Kv::Enum("INSTANCE")), ("overriddenSymbolID", g(10))])); + let (doc, notes) = import(&super::tests::canvas(&schema(), &msg), "Identity.fig").unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + let original = child(&doc, named(&doc, "Outer"), 1); + let current = child(&doc, named(&doc, "Copy"), 1); + let left = named(&doc, "Renamed Left main"); + assert!(matches!(doc.node(original).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(current).unwrap().source, Some(original)); + assert!(matches!(&doc.node(current).unwrap().kind, NodeKind::Instance {component, ..} if *component == left)); + assert_eq!(doc.node(child(&doc, current, 0)).unwrap().source, Some(child(&doc, left, 0))); +} + +// The orphan symbol is decoded and expanded but is not a layer on any imported page. This +// matches an external main: its instance becomes a frame while the rendered body survives. +fn external_namespace_message() -> Kv<'static> { + o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, None, "DOCUMENT", "External namespace", vec![]), + node(2, Some(1), "CANVAS", "Page", vec![]), + node( + 10, + Some(2), + "SYMBOL", + "Known main", + vec![( + "componentPropDefs", + Kv::Array(vec![o(vec![ + ("id", g(200)), + ("name", s("Enabled")), + ("type", Kv::Enum("BOOL")), + ("initialValue", o(vec![("boolValue", Kv::Bool(true))])), + ])]), + )], + ), + node(11, Some(10), "FRAME", "Known frame", vec![("transform", matrix(2.0))]), + node(12, Some(11), "ROUNDED_RECTANGLE", "Known leaf", vec![("transform", matrix(4.0))]), + node(30, Some(2), "SYMBOL", "Outer external", vec![]), + node( + 90, + Some(30), + "INSTANCE", + "External body", + vec![("symbolData", symbol(99, vec![row(&[110, 11], vec![("transform", matrix(3.0))])]))], + ), + node(99, None, "SYMBOL", "External orphan", vec![]), + node(100, Some(99), "FRAME", "Body frame", vec![]), + node(101, Some(99), "FRAME", "Other body frame", vec![]), + node( + 110, + Some(100), + "INSTANCE", + "Same slot", + vec![ + ("symbolData", symbol(10, vec![])), + ("visible", Kv::Bool(false)), + ("opacity", Kv::Float(0.3)), + ("mask", Kv::Bool(true)), + ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(200)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ], + ), + node(111, Some(100), "INSTANCE", "Same slot", vec![("symbolData", symbol(10, vec![]))]), + node( + 70, + Some(2), + "INSTANCE", + "External copy", + vec![( + "symbolData", + symbol( + 30, + vec![ + row( + &[90, 110], + vec![ + ("name", s("Renamed owned slot")), + ("visible", Kv::Bool(true)), + ("opacity", Kv::Float(1.0)), + ("mask", Kv::Bool(false)), + ("componentPropAssignments", Kv::Array(vec![])), + ], + ), + row(&[90, 110, 11], vec![("transform", matrix(2.0))]), + ], + ), + )], + ), + ]), + )]) +} + +fn assert_external_namespace(doc: &Document) { + let outer = named(doc, "Outer external"); + let copy = named(doc, "External copy"); + let main = named(doc, "Known main"); + let body = child(doc, outer, 0); + let copied_body = child(doc, copy, 0); + assert!(matches!(doc.node(body).unwrap().kind, NodeKind::Frame)); + assert!(matches!(doc.node(copied_body).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(body).unwrap().source, None, "the absent external main must not acquire a guessed native node"); + assert_eq!(doc.node(copied_body).unwrap().source, Some(body)); + let frame = child(doc, body, 0); + let copied_frame = child(doc, copied_body, 0); + assert_eq!(doc.node(frame).unwrap().source, None, "materialized ordinary layers are owned by the frame"); + assert_eq!(doc.node(copied_frame).unwrap().source, Some(frame)); + for index in 0..2 { + let slot = child(doc, frame, index); + let copied_slot = child(doc, copied_frame, index); + assert_eq!(doc.node(slot).unwrap().source, Some(main), "known owned instance has its real root main"); + assert_eq!(doc.node(copied_slot).unwrap().source, Some(slot), "copied slots preserve the contextual owned layer identity"); + assert!(matches!(&doc.node(slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == main)); + assert!(matches!(&doc.node(copied_slot).unwrap().kind, NodeKind::Instance {component, ..} if *component == main)); + let inherited = child(doc, slot, 0); + let current = child(doc, copied_slot, 0); + assert_eq!(doc.node(inherited).unwrap().source, Some(child(doc, main, 0))); + assert_eq!(doc.node(current).unwrap().source, Some(inherited)); + assert_eq!(doc.node(inherited).unwrap().transform.translation().x, if index == 0 { 3.0 } else { 2.0 }); + assert_eq!(doc.node(current).unwrap().transform.translation().x, 2.0); + assert_eq!(doc.node(child(doc, current, 0)).unwrap().source, Some(child(doc, inherited, 0))); + assert_eq!(doc.node(child(doc, current, 0)).unwrap().transform.translation().x, 4.0); + } + let old = doc.node(child(doc, frame, 0)).unwrap(); + let current = doc.node(child(doc, copied_frame, 0)).unwrap(); + assert!(!old.visible && old.is_mask && old.opacity < 1.0); + assert!(current.visible && !current.is_mask && current.opacity == 1.0); + assert_eq!(current.name, "Renamed owned slot"); + assert!(matches!(&old.kind, NodeKind::Instance {props, ..} if props.len() == 1)); + assert!(matches!(¤t.kind, NodeKind::Instance {props, ..} if props.is_empty())); +} + +#[test] +fn absent_external_mains_keep_known_owned_instances_and_contextual_copies() { + let (doc, notes) = import(&super::tests::canvas(&schema(), &external_namespace_message()), "External.fig").unwrap(); + assert!(notes.iter().any(|w| w.contains("components that aren't in the file")), "{notes:?}"); + assert!(!notes.iter().any(|w| w.contains("source link")), "materialized own layers are not unresolved copies: {notes:?}"); + assert_external_namespace(&doc); +} + +#[test] +fn external_namespace_is_private_and_requires_actual_wire_instances() { + for mode in 0..3 { + let (mut json, _) = super::map::to_rest(&external_namespace_message(), "External"); + if mode == 0 { + let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); + assert!(doc.nodes().all(|n| n.source.is_none())); + continue; + } + edit_node(&mut json["document"]["children"][0], "1:90", &mut |node| { + if mode == 1 { + node["type"] = json!("FRAME"); + } else { + node[crate::rest::FIG_SOURCE_FIELD]["wireInstance"] = json!(false); + node[crate::rest::FIG_SOURCE_FIELD]["wireFrame"] = json!(true); + } + }); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let raw_slot = child(&doc, child(&doc, child(&doc, named(&doc, "Outer external"), 0), 0), 0); + assert!(doc.node(raw_slot).unwrap().source.is_none(), "ordinary/fake frame mode {mode} must not establish a namespace"); + } +} + +#[test] +fn external_namespace_does_not_cover_present_wrong_kind_or_blocked_main() { + for mode in 0..3 { + let (mut json, _) = super::map::to_rest(&external_namespace_message(), "External"); + let page = &mut json["document"]["children"][0]; + match mode { + 0 => page["children"].as_array_mut().unwrap().push(json!({"id":"1:99", "type":"FRAME", "name":"Wrong kind", + "_figSource":{"origin":"1:99","expanded":false,"wireFrame":true,"wireInstance":false}})), + 1 => edit_node(page, "1:90", &mut |node| { + node["componentId"] = json!("1:30"); + }), + _ => page["children"].as_array_mut().unwrap().push(json!({"id":"1:99", "type":"COMPONENT", "name":"Cycle main", + "_figSource":{"origin":"1:99","expanded":false,"wireFrame":false,"wireInstance":false}, + "children":[{"id":"1:199", "type":"INSTANCE", "name":"Cycle edge", "componentId":"1:30", + "_figSource":{"origin":"1:199","expanded":false,"wireFrame":false,"wireInstance":true}}]})), + } + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let body = child(&doc, named(&doc, "Outer external"), 0); + assert!(matches!(doc.node(body).unwrap().kind, NodeKind::Frame)); + let slot = child(&doc, child(&doc, body, 0), 0); + assert!(doc.node(slot).unwrap().source.is_none(), "existing invalid/cycle/self main mode {mode} must not become a materialized namespace"); + } +} + +#[test] +fn external_namespace_rejects_foreign_siblings_duplicates_and_bad_paths() { + for mode in 0..4 { + let (mut json, _) = super::map::to_rest(&external_namespace_message(), "External"); + let page = &mut json["document"]["children"][0]; + match mode { + 0 => { + // Same root namespace and GUID, but physically under a different frame slot. + let mut moved = None; + edit_node(page, "I1:70;1:90;1:100", &mut |node| { + moved = Some(node["children"].as_array_mut().unwrap().remove(0)); + }); + edit_node(page, "I1:70;1:90;1:101", &mut |node| { + node["children"] = json!([moved.take().unwrap()]); + }); + } + 1 => edit_node(page, "I1:70;1:90;1:110", &mut |node| { + node["id"] = json!("I1:70;1:999;1:110"); + }), + 2 => { + let duplicate = page["children"].as_array().unwrap().iter().find(|n| n["id"] == "1:30").unwrap()["children"][0].clone(); + page["children"].as_array_mut().unwrap().push(duplicate); + } + _ => edit_node(page, "I1:90;1:100", &mut |node| { + node["id"] = json!("I1:999;1:100"); + }), + } + let (doc, warnings) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let slot = named(&doc, "Renamed owned slot"); + assert!(doc.node(slot).unwrap().source.is_none(), "invalid frame namespace mode {mode}"); + assert!(warnings.iter().any(|w| w.contains("source link")), "invalid expanded paths remain reported: {warnings:?}"); + } +} diff --git a/crates/format/src/rest/fig_sources.rs b/crates/format/src/rest/fig_sources.rs new file mode 100644 index 00000000..61097e52 --- /dev/null +++ b/crates/format/src/rest/fig_sources.rs @@ -0,0 +1,316 @@ +//! Explicit .fig GUID provenance projected through instance expansion contexts. +//! All strings borrow the bounded JSON; no names, node-kind similarity or child order match links. + +use std::collections::{BTreeMap, BTreeSet}; + +use figcraft_doc::{Document, NodeId, NodeKind}; +use serde_json::Value; + +use super::{FIG_SOURCE_FIELD, MAX_DEPTH}; + +// A GUID has at most two 20-digit u64 parts and a colon; instance paths contain at most +// MAX_DEPTH + 2 GUIDs. Check before constructing any projected path string. +const MAX_SOURCE_ID_BYTES: usize = 44 * (MAX_DEPTH + 2); + +#[derive(Clone, Copy)] +struct Origin<'a> { + rest: &'a str, + guid: &'a str, + expanded: bool, + wire_frame: bool, + wire_instance: bool, + component: Option<&'a str>, +} + +pub(super) struct Sources<'a> { + /// Most layers recorded. + max_nodes: usize, + nodes: BTreeMap>, + duplicate_ids: BTreeSet<&'a str>, + rejected: usize, +} + +fn guid(s: &str) -> bool { + let Some((session, local)) = s.split_once(':') else { return false }; + s.len() <= 41 + && [session, local].into_iter().all(|part| !part.is_empty() && part.bytes().all(|c| c.is_ascii_digit()) && part.parse::().is_ok()) +} + +fn expanded_id(instance: &str, origin: &str) -> Option { + let bytes = instance.len().checked_add(origin.len())?.checked_add(2)?; + if bytes > MAX_SOURCE_ID_BYTES { + return None; + } + Some(if instance.starts_with('I') { format!("{instance};{origin}") } else { format!("I{instance};{origin}") }) +} + +impl<'a> Sources<'a> { + pub(super) fn new(max_nodes: usize) -> Self { + Self { max_nodes, nodes: BTreeMap::new(), duplicate_ids: BTreeSet::new(), rejected: 0 } + } + + pub(super) fn record(&mut self, id: NodeId, node: &'a Value, primary: bool) { + let Some(rest) = node.get("id").and_then(Value::as_str) else { return }; + if !primary { + self.duplicate_ids.insert(rest); + return; + } + let Some(meta) = node.get(FIG_SOURCE_FIELD) else { return }; + let parsed = meta.get("origin").and_then(Value::as_str).zip(meta.get("expanded").and_then(Value::as_bool)); + let Some((origin, expanded)) = parsed else { + self.rejected += 1; + return; + }; + if self.nodes.len() >= self.max_nodes + || rest.len() > MAX_SOURCE_ID_BYTES + || !guid(origin) + || (!expanded && rest != origin) + || (expanded && !rest.starts_with('I')) + { + self.rejected += 1; + return; + } + let wire_frame = meta.get("wireFrame").and_then(Value::as_bool).unwrap_or(false); + let wire_instance = !wire_frame + && meta.get("wireInstance").and_then(Value::as_bool) == Some(true) + && node.get("type").and_then(Value::as_str) == Some("INSTANCE"); + let component = wire_instance.then(|| node.get("componentId").and_then(Value::as_str).filter(|id| guid(id))).flatten(); + self.nodes.insert(id, Origin { rest, guid: origin, expanded, wire_frame, wire_instance, component }); + } + + fn origin(&self, id: NodeId) -> Option> { + let meta = *self.nodes.get(&id)?; + (!self.duplicate_ids.contains(meta.rest)).then_some(meta) + } + + fn raw_in_main(&self, origin: &str, main: NodeId, doc: &Document, ids: &BTreeMap) -> Option { + let target = *ids.get(origin)?; + let meta = self.origin(target)?; + (!meta.expanded && meta.rest == origin && target != main && doc.is_ancestor(main, target)).then_some(target) + } + + fn root_source(&self, id: NodeId, doc: &Document) -> Option { + let NodeKind::Instance { component, .. } = &doc.node(id)?.kind else { return None }; + let main_meta = self.origin(*component)?; + (!main_meta.expanded + && matches!(doc.node(*component)?.kind, NodeKind::Component { .. }) + && *component != id + && !doc.is_ancestor(id, *component) + && !doc.is_ancestor(*component, id)) + .then_some(*component) + } + + fn missing_frame(&self, id: NodeId, doc: &Document, ids: &BTreeMap) -> bool { + self.origin(id).is_some_and(|meta| { + meta.wire_instance + && matches!(doc.node(id).map(|n| &n.kind), Some(NodeKind::Frame)) + // An existing target of the wrong kind or a cycle-blocked main is not missing. + && meta.component.is_some_and(|main| !ids.contains_key(main)) + }) + } + + fn owner(&self, id: NodeId, doc: &Document, ids: &BTreeMap) -> Option { + let mut current = doc.node(id)?.parent; + let mut seen = BTreeSet::from([id]); + for _ in 0..512 { + let parent = current?; + if !seen.insert(parent) { + return None; + } + let node = doc.node(parent)?; + if matches!(node.kind, NodeKind::Instance { .. }) || self.missing_frame(parent, doc, ids) { + return Some(parent); + } + current = node.parent; + } + None + } + + fn namespace(&self, id: NodeId, owner: NodeId, doc: &Document) -> bool { + let Some(owner_meta) = self.origin(owner) else { return false }; + let mut current = id; + let mut seen = BTreeSet::new(); + for _ in 0..512 { + if current == owner { + return true; + } + if !seen.insert(current) { + return false; + } + let Some(meta) = self.origin(current) else { return false }; + if !meta.expanded || expanded_id(owner_meta.rest, meta.guid).as_deref() != Some(meta.rest) { + return false; + } + let Some(parent) = doc.node(current).and_then(|n| n.parent) else { return false }; + current = parent; + } + false + } + + // A missing-main instance is materialized as a frame. Outside real instances its children + // are owned layers; known instances there become ordinary roots with their own actual main. + fn owned_layer(&self, id: NodeId, doc: &Document, ids: &BTreeMap) -> bool { + if doc.ancestors(id).into_iter().any(|p| doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))) { + return false; + } + let mut current = id; + for _ in 0..=MAX_DEPTH { + let Some(owner) = self.owner(current, doc, ids) else { return false }; + if !self.missing_frame(owner, doc, ids) || !self.namespace(current, owner, doc) { + return false; + } + let Some(meta) = self.origin(owner) else { return false }; + if !meta.expanded { + return true; + } + current = owner; + } + false + } + + fn frame_target(&self, id: NodeId, owner: NodeId, source_owner: NodeId, doc: &Document, ids: &BTreeMap) -> Option { + let meta = self.origin(id)?; + let owner_meta = self.origin(owner)?; + let source_meta = self.origin(source_owner)?; + if !self.missing_frame(source_owner, doc, ids) || owner_meta.component != source_meta.component { + return None; + } + let target = *ids.get(expanded_id(source_meta.rest, meta.guid)?.as_str())?; + let target_meta = self.origin(target)?; + if !target_meta.expanded + || target_meta.guid != meta.guid + || self.owner(target, doc, ids) != Some(source_owner) + || !self.namespace(target, source_owner, doc) + { + return None; + } + let parent = doc.node(id)?.parent?; + let target_parent = doc.node(target)?.parent?; + if parent == owner { + (target_parent == source_owner).then_some(target) + } else { + (target_parent != source_owner && self.origin(parent)?.guid == self.origin(target_parent)?.guid).then_some(target) + } + } + + fn source( + &self, + id: NodeId, + doc: &Document, + ids: &BTreeMap, + memo: &mut BTreeMap>, + active: &mut BTreeSet, + depth: usize, + ) -> Option { + if let Some(source) = memo.get(&id) { + return *source; + } + if depth > MAX_DEPTH || !active.insert(id) { + return None; + } + let found = (|| { + let meta = self.origin(id)?; + let _ = doc.node(id)?; + if !meta.expanded { + return self.root_source(id, doc); + } + // An actual wire INSTANCE whose main is absent remains a namespace owner even + // when the REST importer materializes it as a frame. Ordinary frames never do. + let owner = self.owner(id, doc, ids)?; + if !self.namespace(id, owner, doc) { + return None; + } + let source_owner = self.source(owner, doc, ids, memo, active, depth + 1); + if self.missing_frame(owner, doc, ids) { + let target = match source_owner { + Some(source_owner) => self.frame_target(id, owner, source_owner, doc, ids)?, + None if self.owned_layer(id, doc, ids) => return self.root_source(id, doc), + _ => return None, + }; + return (target != id && !doc.is_ancestor(id, target)).then_some(target); + } + let NodeKind::Instance { component: main, .. } = &doc.node(owner)?.kind else { return None }; + let source_owner = source_owner?; + let target = match &doc.node(source_owner)?.kind { + NodeKind::Component { .. } if source_owner == *main => self.raw_in_main(meta.guid, *main, doc, ids)?, + NodeKind::Instance { component: source_main, .. } if source_main != main => { + // A resolved main switch is evidence for resetting to that CURRENT main. + self.raw_in_main(meta.guid, *main, doc, ids)? + } + NodeKind::Instance { component: source_main, .. } if source_main == main => { + let source_meta = self.origin(source_owner)?; + let target = *ids.get(expanded_id(source_meta.rest, meta.guid)?.as_str())?; + let target_meta = self.origin(target)?; + let target_owner = self.owner(target, doc, ids); + if !target_meta.expanded || target_meta.guid != meta.guid || target_owner != Some(source_owner) { + return None; + } + target + } + NodeKind::Frame if self.origin(source_owner)?.wire_frame => { + // An actual wire FRAME becoming INSTANCE has no previous main. Its resolved + // current main is the only proven namespace for its newly expanded children. + self.raw_in_main(meta.guid, *main, doc, ids)? + } + _ => return None, + }; + (target != id && !doc.is_ancestor(id, target)).then_some(target) + })(); + active.remove(&id); + memo.insert(id, found); + found + } + + pub(super) fn apply(self, doc: &mut Document, ids: &BTreeMap) -> usize { + let mut links = BTreeMap::new(); + let mut active = BTreeSet::new(); + for &id in self.nodes.keys() { + let _ = self.source(id, doc, ids, &mut links, &mut active, 0); + } + let mut missing = self.rejected; + for (&id, meta) in &self.nodes { + let needs_source = + (meta.expanded && !self.owned_layer(id, doc, ids)) || doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })); + if !needs_source { + continue; + } + let target = links.get(&id).copied().flatten().filter(|_| acyclic(id, &links)); + if let Some(target) = target { + if let Some(node) = doc.node_mut(id) { + node.source = Some(target); + } + } else { + missing += 1; + } + } + missing + } +} + +fn acyclic(id: NodeId, links: &BTreeMap>) -> bool { + let mut current = id; + for _ in 0..=MAX_DEPTH { + let Some(next) = links.get(¤t).copied().flatten() else { return true }; + if next == id { + return false; + } + current = next; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn self_cycles_and_overlong_source_chains_are_rejected() { + let self_link = BTreeMap::from([(NodeId(1), Some(NodeId(1)))]); + assert!(!acyclic(NodeId(1), &self_link)); + let cycle = BTreeMap::from([(NodeId(1), Some(NodeId(2))), (NodeId(2), Some(NodeId(1)))]); + assert!(!acyclic(NodeId(1), &cycle)); + let long = (1..=MAX_DEPTH as u64 + 2).map(|i| (NodeId(i), Some(NodeId(i + 1)))).collect(); + assert!(!acyclic(NodeId(1), &long)); + assert!(expanded_id(&"x".repeat(MAX_SOURCE_ID_BYTES), "1:2").is_none()); + } +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 798b2645..8c41ffb3 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -16,7 +16,8 @@ //! //! Instances come out with their own layers as the file lists them. Linking those layers to the //! main component's (`source`) and deriving overrides is left to the caller (the engine does it -//! after loading), since that needs the component model. +//! after loading), since that needs the component model. The private .fig entry point additionally +//! preserves explicit GUID/expansion provenance before the engine derives overrides. #[cfg(test)] mod blur_tests; @@ -24,6 +25,7 @@ mod blur_tests; mod conditional_tests; #[cfg(test)] mod effects_tests; +mod fig_sources; #[cfg(test)] mod gamepad_tests; pub(crate) mod props; @@ -53,6 +55,10 @@ pub use variables::export as export_variables; pub const MAX_NODES: usize = 200_000; /// Deepest layer nesting followed. const MAX_DEPTH: usize = 256; +/// Private .fig provenance, consumed only by `import_fig`. +pub(crate) const FIG_SOURCE_FIELD: &str = "_figSource"; +/// Hidden .fig library context; accepted only by [`import_fig`]. +pub(crate) const FIG_LIBRARY_CONTEXTS_FIELD: &str = "_figLibraryContexts"; /// Longest SVG path string read from one geometry entry. const MAX_PATH_BYTES: usize = 4 << 20; /// Most path elements in one vector. @@ -138,12 +144,22 @@ pub fn import(v: &Value, images: ImageResolver) -> Result<(Document, Vec /// in the file JSON (a `meta` with `variableCollections`, or `variableCollections` and /// `variables` at the top level) are read too; the first definition of an id wins. pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) -> Result<(Document, Vec), FormatError> { + import_impl(v, variables, images, false) +} + +/// Internal .fig JSON path: GUID provenance is not part of the public REST import contract. +pub(crate) fn import_fig(v: &Value, images: ImageResolver) -> Result<(Document, Vec), FormatError> { + import_impl(v, None, images, true) +} + +fn import_impl(v: &Value, variables: Option<&Value>, images: ImageResolver, fig_sources: bool) -> Result<(Document, Vec), FormatError> { let entries: Vec<&Value> = if is_document(v) { vec![v] } else { nodes_entries(v).collect() }; if entries.is_empty() { return Err(FormatError::Corrupt("not a REST API file JSON (no `document`)".into())); } let name = text(v, "name").filter(|n| !n.trim().is_empty()).map_or("Imported file", clip_str); let mut cx = Ctx::new(Document::new(name), images); + cx.fig_sources = fig_sources.then(|| fig_sources::Sources::new(MAX_NODES)); let blank = cx.doc.first_page(); let mut loose: Option = None; for e in &entries { @@ -178,6 +194,12 @@ pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) None => cx.warn("entries without a node type were skipped"), } } + if fig_sources { + for context in v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).into_iter().flatten() { + let store = cx.doc.ensure_library_store(); + cx.build(context, store, Space::default(), None, 0, false); + } + } // Drop the empty page the new document started with once real pages exist. if let Some(p) = blank && cx.doc.pages.len() > 1 @@ -291,6 +313,7 @@ pub(crate) struct Ctx<'a> { count: usize, /// REST node id → imported node. ids: BTreeMap, + fig_sources: Option>, component_meta: BTreeMap<&'a str, &'a Value>, set_meta: BTreeMap<&'a str, &'a Value>, style_meta: BTreeMap<&'a str, &'a Value>, @@ -321,6 +344,7 @@ impl<'a> Ctx<'a> { warnings: Warnings::default(), count: 0, ids: BTreeMap::new(), + fig_sources: None, component_meta: BTreeMap::new(), set_meta: BTreeMap::new(), style_meta: BTreeMap::new(), @@ -411,7 +435,10 @@ impl<'a> Ctx<'a> { } self.count += 1; if let Some(rid) = text(j, "id") { - self.ids.entry(rid.to_string()).or_insert(id); + let primary = *self.ids.entry(rid.to_string()).or_insert(id) == id; + if let Some(sources) = &mut self.fig_sources { + sources.record(id, j, primary); + } } if ty == "INSTANCE" { self.instances.push((id, j)); @@ -822,6 +849,12 @@ impl<'a> Ctx<'a> { } } self.resolve_instances(); + if let Some(sources) = self.fig_sources.take() { + let missing = sources.apply(&mut self.doc, &self.ids); + if missing > 0 { + self.warn(format!("{missing} .fig instance source link(s) could not be resolved and were left unlinked")); + } + } self.rename_props(); for (id, j) in std::mem::take(&mut self.protos) { let list = self.interactions(j); From 4b29d8c34de4241b6b4c9cb93a1188e8bec021bd Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 01:39:47 +0200 Subject: [PATCH 02/33] M1: apply instance overrides by key and order, settle bound paints, keep stroke outline styles Override rows of an instance can name a layer of its main by the key the layer had before the main was edited. The key now resolves to the layer's current id when it is unambiguous and the current id does not exist already. Rows of an enclosing instance win over those of the instances nested in it, whatever order the wire lists them in, and an explicit "no style" on an override detaches the style instead of falling back to the older field. Paints bound to variables take the colours of the active modes when a document loads, before the first edit, without touching other pending changes. A stroke-only vector that imports as the outline of its stroke now moves the stroke's shared style and colour variables to the fill it becomes, and vectors whose single segments carry their own stroke style say so in a warning. --- crates/engine/src/cmd/file.rs | 2 + crates/engine/src/cmd/styles.rs | 21 ++ crates/engine/src/lib.rs | 2 + crates/engine/src/variable_tests.rs | 70 +++++++ crates/format/src/fig/map.rs | 164 +++++++++++++--- crates/format/src/fig/network.rs | 14 +- .../format/src/fig/source_identity_tests.rs | 182 ++++++++++++++++++ crates/format/src/fig/tail_tests.rs | 19 ++ .../src/rest/import_regression_tests.rs | 59 ++++++ crates/format/src/rest/mod.rs | 56 ++++-- docs/file-format-parity.md | 2 +- 11 files changed, 548 insertions(+), 43 deletions(-) create mode 100644 crates/format/src/rest/import_regression_tests.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f50c5edc..f9175b9c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -520,6 +520,7 @@ fn load_with( if loaded.doc.name.is_empty() { loaded.doc.name = file_stem(name_hint); } + super::styles::apply_paint_bindings_on_load(&mut loaded.doc); Ok((loaded, false)) } } @@ -529,6 +530,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)); 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/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/variable_tests.rs b/crates/engine/src/variable_tests.rs index 536b2699..f5808b2c 100644 --- a/crates/engine/src/variable_tests.rs +++ b/crates/engine/src/variable_tests.rs @@ -5,6 +5,76 @@ 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(); + 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 9f9bacab..eb982827 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -77,11 +77,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)}) @@ -224,7 +246,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)] @@ -236,6 +258,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)] @@ -262,6 +303,10 @@ 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. @@ -329,6 +374,8 @@ impl<'a> Mapper<'a> { 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, @@ -369,6 +416,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()); @@ -622,24 +713,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())); @@ -932,7 +1023,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)); @@ -1093,10 +1184,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))); @@ -1277,7 +1371,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); @@ -1286,30 +1391,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")) { diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index 112a767a..d251dff1 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -65,7 +65,14 @@ impl Reader<'_> { } /// The network in blob `b`; `None` when it's truncated, out of range or not finite. +#[allow(dead_code, reason = "for readers that don't need the count of styled segments")] pub(super) fn decode(b: &[u8]) -> Option { + decode_styled(b).map(|(net, _)| net) +} + +/// 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` as for `decode`. +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 +82,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 +103,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 index 955df1da..6667e4a2 100644 --- a/crates/format/src/fig/source_identity_tests.rs +++ b/crates/format/src/fig/source_identity_tests.rs @@ -455,6 +455,166 @@ fn fill_rgb_node(doc: &Document, id: NodeId) -> Option<[f64; 3]> { } } +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![ @@ -936,3 +1096,25 @@ fn external_namespace_rejects_foreign_siblings_duplicates_and_bad_paths() { 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..5d34f23b 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(); 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 8c41ffb3..282c6a75 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -28,6 +28,8 @@ 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)] @@ -232,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, @@ -240,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); @@ -259,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] { @@ -585,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 { @@ -608,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"), } @@ -636,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). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8e51711..e56eda19 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, per-segment stroke styles, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From d2606b3f51142df017efbcc36d922e0b68a553b7 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 01:45:25 +0200 Subject: [PATCH 03/33] M1: let local text values and instance overrides win over a shared text style in .fig import A text layer that links a shared text style keeps its own, possibly stale, copy of the typography. The style's typography now beats that copy, a case, decoration, paragraph spacing or paragraph indent set on the layer itself beats the style (so "original", "none" and zero clear the style's value), and an override row of an instance beats both. An explicit "no style" on a text layer unlinks it even when the older style field still names one. Letter spacing given as a percentage of the font size stays a percentage. A text property value on an instance no longer inherits the character styles written for the main's text, which count other characters. --- crates/format/src/fig/map.rs | 84 +++++-- crates/format/src/fig/mod.rs | 2 + crates/format/src/fig/text_style_tests.rs | 292 ++++++++++++++++++++++ crates/format/src/rest/text.rs | 6 + 4 files changed, 363 insertions(+), 21 deletions(-) create mode 100644 crates/format/src/fig/text_style_tests.rs diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index eb982827..0fe44662 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -167,6 +167,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)) } @@ -1095,19 +1105,30 @@ impl<'a> Mapper<'a> { } 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)); } @@ -1117,20 +1138,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) @@ -1318,6 +1326,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); + } + } } } _ => {} @@ -2026,6 +2044,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) @@ -2063,7 +2101,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 24f304a3..81457f8d 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -24,6 +24,8 @@ mod network; mod source_identity_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod text_style_tests; use std::io::Read; 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/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, From 0b409642022daea63ab33868f563c388c47d3627 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:32:56 +0200 Subject: [PATCH 04/33] M1: keep grid track sizes in .fig import Grid columns and rows keep fixed pixel sizes, Hug sizing and fractional Fill weights when the minimum and maximum sizing definitions agree. Track GUIDs join the sizing records to the ordered tracks, so reordered records do not change cell placement. Unsupported min/max pairs, invalid sizes and duplicate records fall back with a reported note, and imports are bounded to 1,000 tracks per axis. Public REST snapshots ignore the private track metadata. --- crates/format/src/fig/map.rs | 104 +++++++++++++++++--- crates/format/src/fig/tests.rs | 169 ++++++++++++++++++++++++++++++++ crates/format/src/rest/mod.rs | 2 +- crates/format/src/rest/props.rs | 29 +++++- crates/format/src/rest/tests.rs | 2 + 5 files changed, 290 insertions(+), 16 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..92d7ff75 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -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. @@ -1089,21 +1091,75 @@ 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 = 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. @@ -2018,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?; 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/rest/mod.rs b/crates/format/src/rest/mod.rs index 282c6a75..4aa252a7 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -599,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); 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)); } From 8408cc72b8db4af3c9408eaa2ced97dc34e7c213 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:40:50 +0200 Subject: [PATCH 05/33] M1: import component slot content from .fig files Slot properties of a component, the frames that reference them and the content that instances assign to them now come across from .fig files. A slot definition becomes a native slot property. The referencing frame of an expanded instance keeps following the frame of its main, and when the instance assigns slot content (including an explicitly empty slot) the frame carries that content as its own, independently owned children. Nested instances inside assigned content keep their own source links. An assignment on an outer instance wins over one made by an instance nested in the main, and a slot without an assignment keeps the default content of its main. Assignments that are malformed, use the empty-GUID sentinel or point at a layer that is not slot content are skipped with an import note. Preferred component keys cannot be mapped to nodes of the file and are dropped with a note, as are slot settings the editor cannot represent. Public REST snapshots, the experimental .fig writer and property resources are not changed. --- crates/format/src/fig/map.rs | 107 +++++++++++- crates/format/src/fig/mod.rs | 2 + crates/format/src/fig/slot_tests.rs | 225 ++++++++++++++++++++++++++ crates/format/src/rest/fig_sources.rs | 11 +- crates/format/src/rest/mod.rs | 8 +- 5 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 crates/format/src/fig/slot_tests.rs diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..6860327f 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -295,6 +295,10 @@ struct Inst<'a> { path: Vec, overrides: Rc>, props: Rc>>, + /// Slot property GUID → private slot-content node GUID. + slot_contents: Rc>, + /// Layers imported from a slot's independent content subtree. + slot_content: bool, /// Mains being expanded (an instance inside its own main stops there). symbols: Vec, } @@ -768,7 +772,13 @@ impl<'a> Mapper<'a> { // 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"}), + json!({ + "origin": id, + "expanded": inst.is_some(), + "wireFrame": own == "FRAME", + "wireInstance": own == "INSTANCE", + "slotContent": inst.is_some_and(|i| i.slot_content), + }), ); o.insert("type".into(), json!(ty)); copy_str(&mut o, &view, "name", "name"); @@ -797,6 +807,16 @@ impl<'a> Mapper<'a> { self.component(&mut o, &view, &rest_id); } self.prop_refs(&mut o, &view, inst); + let slot_content = if own == "FRAME" { + prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&def).cloned())) + } else { + None + }; + if slot_content.is_some() + && let Some(source) = o.get_mut(crate::rest::FIG_SOURCE_FIELD).and_then(Value::as_object_mut) + { + source.insert("slotOverride".into(), json!(true)); + } let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY }; let mut kids = Vec::new(); if ty == "INSTANCE" { @@ -808,6 +828,14 @@ impl<'a> Mapper<'a> { kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None)); } } + } else if let Some(content) = slot_content { + let mut content_inst = inst.cloned(); + if let Some(content_inst) = &mut content_inst { + content_inst.slot_content = true; + } + for c in self.children(&content) { + kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(&view), None, depth + 1, included)); + } } else { // Inside an instance a layer's children are its main's: they follow it from the main's // size to its own. @@ -1282,6 +1310,11 @@ impl<'a> Mapper<'a> { None => continue, }, "VARIANT" => json!({"type": "VARIANT"}), + "SLOT" => { + self.warn_non_default_slot_config(d); + self.warn_dropped_slot_preferred_values(d); + json!({"type": "SLOT"}) + } _ => { self.warn(format!("{} component properties are not supported and were skipped", ty.to_ascii_lowercase())); continue; @@ -1298,6 +1331,34 @@ impl<'a> Mapper<'a> { } } + /// Preferred slot values are source component keys, but this mapper has no proven way to map + /// those keys to file GUIDs. They are dropped instead of guessing native NodeIds. + fn warn_dropped_slot_preferred_values(&mut self, def: &Kv<'a>) { + let entries = def.get("preferredValues").and_then(|v| v.get("instanceSwapValues")).map(Kv::list).unwrap_or_default(); + if !entries.is_empty() { + self.warn("preferred slot component keys cannot be resolved to file nodes and were skipped"); + } + } + + fn warn_non_default_slot_config(&mut self, def: &Kv<'a>) { + let Some(config) = def.get("slotPropConfig") else { return }; + let non_default = [ + ("stretchChildOnInsert", Some(false)), + ("displayByDefault", Some(false)), + ("minChildren", None), + ("maxChildren", None), + ("allowPreferredValuesOnly", Some(false)), + ] + .into_iter() + .any(|(field, bool_default)| { + let Some(value) = config.get(field) else { return false }; + if let Some(default) = bool_default { value.bool() != Some(default) } else { value.f64() != Some(0.0) } + }); + if non_default { + self.warn("non-default slot settings are not represented and were skipped"); + } + } + /// A layer's component property references, and inside an instance the values they take. fn prop_refs(&mut self, o: &mut Obj, v: &View<'a>, inst: Option<&Inst<'a>>) { let mut refs = Obj::new(); @@ -1306,6 +1367,7 @@ impl<'a> Mapper<'a> { Some("VISIBLE") => "visible", Some("TEXT_DATA") => "characters", Some("OVERRIDDEN_SYMBOL_ID") => "mainComponent", + Some("SLOT_CONTENT_ID") => "slot", _ => continue, }; let Some(def) = @@ -1313,7 +1375,11 @@ impl<'a> Mapper<'a> { else { continue; }; - if let Some((name, _)) = self.prop_defs.get(&def) { + // Expanded frames must follow their main frame's source link. A direct slot reference + // makes the engine resolve ownership against the instance, where the property is not defined. + if !(field == "slot" && inst.is_some()) + && let Some((name, _)) = self.prop_defs.get(&def) + { refs.insert(field.into(), json!(name)); } let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue }; @@ -1445,15 +1511,50 @@ impl<'a> Mapper<'a> { 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(); + let mut slot_contents: BTreeMap = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default(); for a in assignments { if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) { all_props.entry(d).or_insert(val); } + let Some(def) = a.get("defID").and_then(guid) else { continue }; + if self.prop_defs.get(&def).is_none_or(|(_, ty)| *ty != "SLOT") { + continue; + } + let var_value = a.get("varValue"); + if var_value.and_then(|v| v.get("dataType")).and_then(Kv::str) != Some("SLOT_CONTENT_ID") { + self.warn("malformed slot content assignment was skipped"); + continue; + } + let target = var_value.and_then(|v| v.get("value")).and_then(|v| v.get("slotContentIdValue")).and_then(|v| v.get("guid")).and_then(guid); + let Some(target) = target else { + self.warn("malformed slot content assignment was skipped"); + continue; + }; + if target == "4294967295:4294967295" { + self.warn("slot content assignment uses the empty GUID sentinel and was skipped"); + continue; + } + if !self.nodes.get(&target).is_some_and(|n| n.get("isSlotContent").and_then(Kv::bool) == Some(true)) { + self.warn("slot content assignment target is missing or is not slot content"); + continue; + } + slot_contents.entry(def).or_insert(target); } let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default(); symbols.push(sym.clone()); let root = outer.map_or_else(|| rest_id.to_string(), |i| i.root.clone()); - Some((Inst { root, path: own_path, overrides: Rc::new(overrides), props: Rc::new(all_props), symbols }, sym)) + Some(( + Inst { + root, + path: own_path, + overrides: Rc::new(overrides), + props: Rc::new(all_props), + slot_contents: Rc::new(slot_contents), + slot_content: false, + symbols, + }, + sym, + )) } fn prototype(&mut self, o: &mut Obj, v: &View<'a>) { diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 81457f8d..ceb13346 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -21,6 +21,8 @@ pub mod kiwi; mod map; mod network; #[cfg(test)] +mod slot_tests; +#[cfg(test)] mod source_identity_tests; #[cfg(test)] mod tests; diff --git a/crates/format/src/fig/slot_tests.rs b/crates/format/src/fig/slot_tests.rs new file mode 100644 index 00000000..670e2f6f --- /dev/null +++ b/crates/format/src/fig/slot_tests.rs @@ -0,0 +1,225 @@ +//! Component slot content from `.fig` files: SLOT definitions, the frames that reference them and +//! the content subtrees that instances assign, on synthetic messages. + +use serde_json::Value; + +use super::kiwi::Kv; +use super::map::to_rest; + +fn g(local: u32) -> Kv<'static> { + Kv::Object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(u64::from(local)))]) +} + +fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn node(local: u32, kind: &'static str, parent: Option, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut all = vec![("guid", g(local)), ("type", Kv::Enum(kind)), ("name", Kv::Str("Synthetic".into()))]; + if let Some(parent) = parent { + all.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str("!".into()))]))); + } + all.append(&mut fields); + o(all) +} + +fn mapped(msg: &Kv<'_>) -> Value { + to_rest(msg, "Synthetic resources").0 +} + +fn named_id<'b>(v: &'b Value, id: &str) -> Option<&'b Value> { + if v.get("id").and_then(Value::as_str) == Some(id) { + return Some(v); + } + v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(|v| named_id(v, id)) +} + +fn changes<'a>(msg: &'a mut Kv<'static>) -> &'a mut Vec> { + let Kv::Object(fields) = msg else { panic!() }; + let (_, Kv::Array(rows)) = fields.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap() else { panic!() }; + rows +} + +fn slot_message(content: Option, preferred: bool) -> Kv<'static> { + let slot_ref = o(vec![ + ("variableField", Kv::Enum("SLOT_CONTENT_ID")), + ("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(70))]))]))])), + ]); + let mut instance_fields = vec![("symbolData", o(vec![("symbolID", g(10))]))]; + if let Some(content) = content { + instance_fields.push(( + "componentPropAssignments", + Kv::Array(vec![o(vec![ + ("defID", g(70)), + ("value", o(vec![])), + ( + "varValue", + o(vec![ + ("value", o(vec![("slotContentIdValue", o(vec![("guid", g(content))]))])), + ("dataType", Kv::Enum("SLOT_CONTENT_ID")), + ("resolvedDataType", Kv::Enum("SLOT_CONTENT_ID")), + ]), + ), + ])]), + )); + } + let mut definition = vec![("id", g(70)), ("name", Kv::Str("Content".into())), ("type", Kv::Enum("SLOT"))]; + if preferred { + definition.push(( + "preferredValues", + o(vec![("instanceSwapValues", Kv::Array(vec![o(vec![("type", Kv::Enum("COMPONENT")), ("key", Kv::Str("unresolved-key".into()))])]))]), + )); + } + o(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, "DOCUMENT", None, vec![]), + node(2, "CANVAS", Some(1), vec![]), + node(3, "CANVAS", Some(1), vec![("internalOnly", Kv::Bool(true))]), + node(10, "SYMBOL", Some(2), vec![("componentPropDefs", Kv::Array(vec![o(definition)]))]), + node(12, "FRAME", Some(10), vec![("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![slot_ref]))]))]), + node(13, "RECTANGLE", Some(12), vec![]), + node(20, "INSTANCE", Some(2), instance_fields), + node(30, "FRAME", Some(3), vec![("isSlotContent", Kv::Bool(true))]), + node(31, "RECTANGLE", Some(30), vec![]), + node(32, "ELLIPSE", Some(30), vec![]), + node(33, "FRAME", Some(3), vec![("isSlotContent", Kv::Bool(true))]), + ]), + )]) +} +#[test] +fn slot_definition_reference_and_arbitrary_override_children_are_imported() { + let rest = mapped(&slot_message(Some(30), false)); + let main = named_id(&rest["document"], "9:10").unwrap(); + assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT"); + let main_slot = named_id(&rest["document"], "9:12").unwrap(); + assert_eq!(main_slot["componentPropertyReferences"]["slot"], "Content#9:70"); + let slot = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert!(slot.get("componentPropertyReferences").is_none()); + assert_eq!(slot[crate::rest::FIG_SOURCE_FIELD]["slotOverride"], true); + let kids = slot["children"].as_array().unwrap(); + assert_eq!(kids.len(), 2); + assert_eq!(kids[0]["id"], "I9:20;9:31"); + assert_eq!(kids[1]["id"], "I9:20;9:32"); + + let (doc, warnings) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let main = doc + .node(*doc.pages.first().unwrap()) + .unwrap() + .children + .iter() + .find_map(|id| doc.node(*id).filter(|n| n.name == "Synthetic" && matches!(n.kind, figcraft_doc::NodeKind::Component { .. }))) + .unwrap(); + assert!( + matches!(&main.kind, figcraft_doc::NodeKind::Component { props, .. } if matches!(props.get("Content"), Some(figcraft_doc::PropDef::Slot { preferred }) if preferred.is_empty())) + ); + let instance = doc.ids().filter_map(|id| doc.node(id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).unwrap(); + let slot = doc.node(instance.children[0]).unwrap(); + assert!(!slot.prop_refs.contains_key("slot")); + assert_eq!(slot.source, Some(main.children[0])); + assert!(slot.overrides.contains("slot")); + assert_eq!(slot.children.len(), 2); + assert!(slot.children.iter().all(|id| doc.node(*id).is_some_and(|child| child.source.is_none()))); + + let (public, _) = crate::rest::import(&rest, &crate::rest::no_images).unwrap(); + let public_instance = public.ids().filter_map(|id| public.node(id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).unwrap(); + let public_slot = public.node(public_instance.children[0]).unwrap(); + assert!(!public_slot.overrides.contains("slot")); +} +#[test] +fn nested_instance_in_own_slot_content_keeps_its_own_source_tree() { + let mut msg = slot_message(Some(30), false); + let rows = changes(&mut msg); + let slot_frame = rows.iter_mut().find(|row| row.get("guid").and_then(|guid| guid.get("localID")).and_then(Kv::f64) == Some(31.0)).unwrap(); + let Kv::Object(fields) = slot_frame else { panic!() }; + fields.iter_mut().find(|(key, _)| *key == "type").unwrap().1 = Kv::Enum("FRAME"); + rows.extend([ + node(34, "INSTANCE", Some(31), vec![("symbolData", o(vec![("symbolID", g(40))]))]), + node(40, "SYMBOL", Some(2), vec![]), + node(41, "FRAME", Some(40), vec![]), + node(42, "RECTANGLE", Some(41), vec![]), + ]); + + let rest = mapped(&msg); + let (doc, warnings) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let outer = doc + .ids() + .filter_map(|id| doc.node(id)) + .find(|n| { + matches!(n.kind, figcraft_doc::NodeKind::Instance { .. }) + && !doc.ancestors(n.id).iter().any(|a| doc.node(*a).is_some_and(|p| matches!(p.kind, figcraft_doc::NodeKind::Instance { .. }))) + }) + .unwrap(); + let slot = doc.node(outer.children[0]).unwrap(); + let own_frame = slot.children.iter().filter_map(|id| doc.node(*id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Frame)).unwrap(); + assert!(own_frame.source.is_none()); + + let nested = slot + .children + .iter() + .filter_map(|id| doc.node(*id)) + .flat_map(|n| n.children.iter()) + .filter_map(|id| doc.node(*id)) + .find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })) + .unwrap(); + let figcraft_doc::NodeKind::Instance { component, .. } = nested.kind else { panic!("not an instance") }; + assert_eq!(nested.source, Some(component)); + let main_frame = doc.node(doc.node(component).unwrap().children[0]).unwrap(); + let expanded_frame = doc.node(nested.children[0]).unwrap(); + assert_eq!(expanded_frame.source, Some(main_frame.id)); + assert_eq!(expanded_frame.children.len(), 1); + assert_eq!(doc.node(expanded_frame.children[0]).unwrap().source, Some(main_frame.children[0])); +} +#[test] +fn outer_slot_assignment_wins_over_a_nested_instances_own() { + let mut msg = slot_message(Some(30), false); + let assignment = |content: u32| { + Kv::Array(vec![o(vec![ + ("defID", g(70)), + ("value", o(vec![])), + ( + "varValue", + o(vec![ + ("value", o(vec![("slotContentIdValue", o(vec![("guid", g(content))]))])), + ("dataType", Kv::Enum("SLOT_CONTENT_ID")), + ("resolvedDataType", Kv::Enum("SLOT_CONTENT_ID")), + ]), + ), + ])]) + }; + // The nested instance inside the main assigns the empty slot content; the outer instance's content wins. + let slot_ref = o(vec![ + ("variableField", Kv::Enum("SLOT_CONTENT_ID")), + ("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(70))]))]))])), + ]); + changes(&mut msg).extend([ + node(14, "INSTANCE", Some(10), vec![("symbolData", o(vec![("symbolID", g(40))])), ("componentPropAssignments", assignment(33))]), + node(40, "SYMBOL", Some(2), vec![]), + node(41, "FRAME", Some(40), vec![("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![slot_ref]))]))]), + ]); + let rest = mapped(&msg); + let slot = named_id(&rest["document"], "I9:20;9:14;9:41").unwrap(); + assert_eq!(slot["children"].as_array().map_or(0, Vec::len), 2, "{slot}"); +} +#[test] +fn unassigned_slot_keeps_default_and_bad_content_target_warns() { + let rest = mapped(&slot_message(None, false)); + let slot = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert_eq!(slot["children"].as_array().unwrap().len(), 1); + assert!(slot.get("overrides").is_none()); + + let (rest, warnings) = to_rest(&slot_message(Some(999), false), "Synthetic"); + assert!(warnings.iter().any(|w| w.contains("slot content assignment target is missing or is not slot content")), "{warnings:?}"); + let slot = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert_eq!(slot["children"].as_array().unwrap().len(), 1); + + let (rest, warnings) = to_rest(&slot_message(Some(33), true), "Synthetic"); + let main = named_id(&rest["document"], "9:10").unwrap(); + assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT"); + assert!(warnings.iter().any(|w| w.contains("preferred slot component keys cannot be resolved")), "{warnings:?}"); + let empty = named_id(&rest["document"], "I9:20;9:12").unwrap(); + assert_eq!(empty["children"].as_array().map_or(0, Vec::len), 0); + assert_eq!(empty[crate::rest::FIG_SOURCE_FIELD]["slotOverride"], true); +} diff --git a/crates/format/src/rest/fig_sources.rs b/crates/format/src/rest/fig_sources.rs index 61097e52..2793f932 100644 --- a/crates/format/src/rest/fig_sources.rs +++ b/crates/format/src/rest/fig_sources.rs @@ -17,6 +17,7 @@ struct Origin<'a> { rest: &'a str, guid: &'a str, expanded: bool, + slot_content: bool, wire_frame: bool, wire_instance: bool, component: Option<&'a str>, @@ -75,7 +76,8 @@ impl<'a> Sources<'a> { && 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 }); + let slot_content = meta.get("slotContent").and_then(Value::as_bool).unwrap_or(false); + self.nodes.insert(id, Origin { rest, guid: origin, expanded, slot_content, wire_frame, wire_instance, component }); } fn origin(&self, id: NodeId) -> Option> { @@ -211,6 +213,9 @@ impl<'a> Sources<'a> { let found = (|| { let meta = self.origin(id)?; let _ = doc.node(id)?; + if meta.slot_content { + return if matches!(doc.node(id)?.kind, NodeKind::Instance { .. }) { self.root_source(id, doc) } else { None }; + } if !meta.expanded { return self.root_source(id, doc); } @@ -269,8 +274,8 @@ impl<'a> Sources<'a> { } 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 { .. })); + let needs_source = (!meta.slot_content && meta.expanded && !self.owned_layer(id, doc, ids)) + || doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })); if !needs_source { continue; } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 282c6a75..6088519b 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -451,6 +451,10 @@ impl<'a> Ctx<'a> { let id = self.doc.alloc_id(); let label = kind.label(); let mut n = Node::new(id, kind, text(j, "name").map_or(label, clip_str), w, h); + if self.fig_sources.is_some() && j.get(FIG_SOURCE_FIELD).and_then(|source| source.get("slotOverride")).and_then(Value::as_bool) == Some(true) + { + n.overrides.insert("slot".into()); + } n.transform = local(space.world, world); self.common(&mut n, j, parent_mode); let (container, follows, mode) = (n.kind.is_container(), n.kind.bounds_follow_children(), n.layout.as_ref().map(|l| l.mode)); @@ -605,7 +609,8 @@ impl<'a> Ctx<'a> { n.child_layout = props::child_layout(j, n.layout.as_ref().map(|l| l.mode), parent_mode); n.exports = props::export_settings(j); for (field, prop) in j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten() { - if let (Some(p), true) = (prop.as_str(), matches!(field.as_str(), "visible" | "characters" | "mainComponent")) { + let supported = matches!(field.as_str(), "visible" | "characters" | "mainComponent") || (field == "slot" && self.fig_sources.is_some()); + if let (Some(p), true) = (prop.as_str(), supported) { n.prop_refs.insert(field.clone(), p.to_string()); } } @@ -709,6 +714,7 @@ impl<'a> Ctx<'a> { continue; } Some("VARIANT") => continue, + Some("SLOT") if self.fig_sources.is_some() => PropDef::Slot { preferred: Vec::new() }, _ => { self.warn("unknown component property types were skipped"); continue; From a52e2bb6b3773e9ebc328104c887aab2bf58bfdf Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:34:42 +0200 Subject: [PATCH 06/33] M1: distinguish unresolved vector segment style metadata in .fig import The vector network decoder now keeps each segment's style id. Import reports a segment style that names a row of the layer's style table as metadata that was not interpreted, and one that names no row as a reference to a missing style entry, each at most once per file. The earlier note claimed that stroke styles were left out, which overstated what is known about the metadata. Segment geometry, paint and width are unchanged, and the style ids are not kept in the native vector model. --- crates/format/src/fig/export.rs | 8 +++- crates/format/src/fig/map.rs | 25 ++++++++--- crates/format/src/fig/network.rs | 11 ++--- crates/format/src/fig/tail_tests.rs | 64 ++++++++++++++++++++++------- 4 files changed, 83 insertions(+), 25 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index bfd0b615..7443c8de 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1669,7 +1669,13 @@ impl<'d> Exporter<'d> { let segments = net .segments .iter() - .map(|sg| network::Segment { a: sg.a, ta: (finite(sg.ta.0), finite(sg.ta.1)), b: sg.b, tb: (finite(sg.tb.0), finite(sg.tb.1)) }) + .map(|sg| network::Segment { + style: 0, + a: sg.a, + ta: (finite(sg.ta.0), finite(sg.ta.1)), + b: sg.b, + tb: (finite(sg.tb.0), finite(sg.tb.1)), + }) .collect(); let regions = net .regions diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..05fa8566 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1192,12 +1192,29 @@ 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, styled_segments)) = network::decode_styled(blob) else { + let Some((net, _)) = 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"); + let styles: BTreeMap = + vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); + let mut has_uninterpreted = false; + let mut has_missing = false; + for segment in &net.segments { + if segment.style == 0 { + continue; + } + if styles.contains_key(&u64::from(segment.style)) { + has_uninterpreted = true; + } else { + has_missing = true; + } + } + if has_uninterpreted { + self.warn("metadata for styled vector network segments was not interpreted"); + } + if has_missing { + self.warn("vector network segments reference missing style entries"); } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = @@ -1205,8 +1222,6 @@ impl<'a> Mapper<'a> { if net.segments.is_empty() || (net.regions.is_empty() && fills_shown && o.contains_key("fillGeometry")) { return false; } - let styles: BTreeMap = - vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0); let scale = |k: &str| { let size = v.size().map(|s| if k == "x" { s.0 } else { s.1 }); diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index d251dff1..4e8c3ad7 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -17,6 +17,7 @@ pub(super) struct Vertex { } pub(super) struct Segment { + pub style: u32, pub a: usize, pub ta: (f64, f64), pub b: usize, @@ -70,8 +71,7 @@ pub(super) fn decode(b: &[u8]) -> Option { decode_styled(b).map(|(net, _)| net) } -/// 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` as for `decode`. +/// The network in blob `b` and how many of its segments have a nonzero style id; `None` as for `decode`. 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); @@ -84,10 +84,11 @@ pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { } let mut styled = 0usize; for _ in 0..ns { - if r.u32()? != 0 { + let style = r.u32()?; + if style != 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()?) }); + net.segments.push(Segment { style, a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); } let mut entries = 0usize; for _ in 0..nr { @@ -120,7 +121,7 @@ pub(super) fn encode(net: &Network) -> Vec { fl(v.y, &mut out); } for s in &net.segments { - u(0, &mut out); + u(s.style, &mut out); u(s.a as u32, &mut out); fl(s.ta.0, &mut out); fl(s.ta.1, &mut out); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 5d34f23b..657ff6ff 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -9,7 +9,7 @@ use super::super::network::{self, Network, Region, Segment, Vertex}; use super::*; fn seg(a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment { - Segment { a, ta, b, tb } + Segment { style: 0, a, ta, b, tb } } fn vtx(style: u32, x: f64, y: f64) -> Vertex { @@ -264,21 +264,33 @@ fn vectors_import_as_editable_networks() { #[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)); + let styled_import = |segment_style: u32| { + 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(&segment_style.to_le_bytes()); + let (net, styled) = network::decode_styled(&blob).unwrap(); + assert_eq!((net.segments.len(), net.segments[0].style, styled), (4, segment_style, 1)); + 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_eq!(network_of(&doc, "Wave").segments.len(), 4); + warnings + }; 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); + assert!(!plain.iter().any(|w| w.contains("vector network segments")), "{plain:?}"); + let uninterpreted = |w: &String| w.contains("metadata for styled vector network segments was not interpreted"); + let missing = |w: &String| w.contains("vector network segments reference missing style entries"); + // Style 1 has a row in the layer's override table; its contents are not applied. + let known = styled_import(1); + assert_eq!(known.iter().filter(|w| uninterpreted(w)).count(), 1, "{known:?}"); + assert!(!known.iter().any(missing), "{known:?}"); + // Style 7 names no row at all. + let unknown = styled_import(7); + assert_eq!(unknown.iter().filter(|w| missing(w)).count(), 1, "{unknown:?}"); + assert!(!unknown.iter().any(uninterpreted), "{unknown:?}"); } #[test] @@ -385,6 +397,30 @@ fn damaged_network_blobs_never_panic() { } } +#[test] +fn segment_styles_round_trip_without_changing_geometry() { + let original = Network { + vertices: vec![vtx(0, 2.0, 3.0), vtx(0, 10.0, 20.0), vtx(0, 40.0, 50.0), vtx(0, 60.0, 70.0)], + segments: vec![ + seg(0, 1, (1.0, 2.0), (3.0, 4.0)), + Segment { style: 17, a: 1, ta: (5.0, 6.0), b: 2, tb: (7.0, 8.0) }, + Segment { style: 27, a: 2, ta: (9.0, 10.0), b: 3, tb: (11.0, 12.0) }, + ], + regions: vec![], + }; + let bytes = network::encode(&original); + let (decoded, styled) = network::decode_styled(&bytes).unwrap(); + assert_eq!(styled, 2); + assert_eq!(decoded.segments.len(), 3); + for (got, want) in decoded.segments.iter().zip(&original.segments) { + assert_eq!(got.style, want.style); + assert_eq!((got.a, got.ta, got.b, got.tb), (want.a, want.ta, want.b, want.tb)); + } + for cut in 0..bytes.len() { + assert!(network::decode_styled(&bytes[..cut]).is_none(), "truncated at {cut}"); + } +} + #[test] fn mangled_tail_files_never_panic() { let c = tail_canvas(); From 120b6830ff94385e6d8c3738673570199fceb466 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:36:24 +0200 Subject: [PATCH 07/33] M1: import editable text on a path from .fig files Supported TEXT_PATH layers import as editable native text on their vector path, including mixed text styles, a start inside a later segment and reversed text direction. The importer converts the segment parameter stored in the file to the native distance along the complete path. The mapping accepts a connected, unbranched path with one recognized start-segment marker and the default placement above the path. Damaged or unsupported path data keeps the text readable off its path and adds an import note. Other vertical placements, multiline text and ambiguous or disconnected networks are not converted. Underline and strikethrough remain a native path-rendering limitation and are reported. Public REST TEXT_PATH layers and the experimental .fig writer are unchanged. --- crates/format/src/fig/map.rs | 95 ++++++- crates/format/src/fig/mod.rs | 3 + crates/format/src/fig/text_path.rs | 315 +++++++++++++++++++++++ crates/format/src/fig/text_path_tests.rs | 211 +++++++++++++++ crates/format/src/rest/text.rs | 12 +- docs/gaps.md | 4 + 6 files changed, 637 insertions(+), 3 deletions(-) create mode 100644 crates/format/src/fig/text_path.rs create mode 100644 crates/format/src/fig/text_path_tests.rs diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 05fa8566..1dc8e138 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -759,7 +759,7 @@ impl<'a> Mapper<'a> { "VARIABLE" | "VARIABLE_SET" => return None, t => stand.map_or(t, |s| s.0), }; - if let Some((_, what)) = stand { + if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") { self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); } self.count += 1; @@ -774,7 +774,12 @@ impl<'a> Mapper<'a> { copy_str(&mut o, &view, "name", "name"); self.common(&mut o, &view, ty, space, parent); match ty { - "TEXT" => self.text(&mut o, &view), + "TEXT" => { + self.text(&mut o, &view); + if own == "TEXT_PATH" { + self.text_path(&mut o, &view); + } + } "VECTOR" | "STAR" | "REGULAR_POLYGON" => self.geometry(&mut o, &view), "ELLIPSE" => { if let Some(a) = view.get("arcData") { @@ -1155,6 +1160,92 @@ impl<'a> Mapper<'a> { } } + /// Carry supported text-path data through the private .fig REST ingress only. + fn text_path(&mut self, o: &mut Obj, v: &View<'a>) { + let fail = |this: &mut Self, reason: &str| { + this.warn(format!("text path {reason}; text imports off its path")); + }; + let Some(start) = v.get("textPathStart") else { + fail(self, "has no start data"); + return; + }; + let (Some(t), Some(forward)) = (num(start, "tValue"), start.get("forward").and_then(Kv::bool)) else { + fail(self, "has invalid start data"); + return; + }; + let Some(vd) = v.get("vectorData") else { + fail(self, "has no vector data"); + return; + }; + let Some(blob) = num(vd, "vectorNetworkBlob") + .filter(|i| i.is_finite() && *i >= 0.0 && i.fract() == 0.0) + .and_then(|i| usize::try_from(i as u64).ok()) + .and_then(|i| self.blobs.get(i)) + .copied() + else { + fail(self, "has no vector network"); + return; + }; + let Some((net, _)) = network::decode_styled(blob) else { + fail(self, "has damaged vector data"); + return; + }; + let Some(norm) = vd.get("normalizedSize") else { + fail(self, "has invalid normalized size"); + return; + }; + let Some(size) = v.get("size") else { + fail(self, "has invalid dimensions"); + return; + }; + let (Some(nx), Some(ny), Some(w), Some(h)) = (num(norm, "x"), num(norm, "y"), num(size, "x"), num(size, "y")) else { + fail(self, "has invalid dimensions"); + return; + }; + let axis_scale = |size: f64, normalized: f64, values: &[f64]| -> Option { + if !size.is_finite() || size < 0.0 || !normalized.is_finite() || normalized < 0.0 { + return None; + } + if normalized > 1e-9 { + if size <= 0.0 { + return None; + } + return Some(size / normalized); + } + (size == 0.0 && values.iter().all(|v| *v == 0.0)).then_some(1.0) + }; + let xs: Vec = net.vertices.iter().map(|p| p.x).chain(net.segments.iter().flat_map(|s| [s.ta.0, s.tb.0])).collect(); + let ys: Vec = net.vertices.iter().map(|p| p.y).chain(net.segments.iter().flat_map(|s| [s.ta.1, s.tb.1])).collect(); + let sx = axis_scale(w, nx, &xs); + let sy = axis_scale(h, ny, &ys); + let (Some(sx), Some(sy)) = (sx, sy) else { + fail(self, "has invalid path scale"); + return; + }; + if v.str("textAlignVertical").is_some_and(|x| x != "TOP") { + fail(self, "uses unsupported vertical placement"); + return; + } + if v.get("textData").and_then(|td| td.get("characters")).and_then(Kv::str).is_some_and(|s| s.chars().any(|c| matches!(c, '\n' | '\r'))) { + fail(self, "contains multiple lines"); + return; + } + let path = match super::text_path::from_network(&net, sx, sy, t, forward) { + Ok(path) => path, + Err(reason) => { + fail(self, reason); + return; + } + }; + let has_decoration = |style: &Value| style.get("textDecoration").and_then(Value::as_str).is_some_and(|x| x != "NONE"); + let decorated = o.get("style").is_some_and(has_decoration) + || o.get("styleOverrideTable").and_then(Value::as_object).into_iter().flat_map(|styles| styles.values()).any(has_decoration); + if decorated { + self.warn("text path underline or strikethrough decoration is not rendered"); + } + o.insert("_figTextPath".into(), json!({"d": path.d, "start": path.start, "flip": path.flip})); + } + /// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters. fn geometry(&mut self, o: &mut Obj, v: &View<'a>) { for key in ["fillGeometry", "strokeGeometry"] { diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 81457f8d..2ad6e276 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -24,6 +24,9 @@ mod network; mod source_identity_tests; #[cfg(test)] mod tests; +mod text_path; +#[cfg(test)] +mod text_path_tests; #[cfg(test)] mod text_style_tests; diff --git a/crates/format/src/fig/text_path.rs b/crates/format/src/fig/text_path.rs new file mode 100644 index 00000000..8a93f7e4 --- /dev/null +++ b/crates/format/src/fig/text_path.rs @@ -0,0 +1,315 @@ +//! Convert the simple vector network used by text-on-path layers in `.fig` files into a native text path. + +use figcraft_doc::{ + TextPath, + geom::{ + BezPath, + kurbo::{CubicBez, Line, ParamCurve, ParamCurveArclen, Point}, + }, +}; + +use super::network::{Network, Segment}; + +const START_SEGMENT_STYLE: u32 = 0x01ff_ffff; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Use { + segment: usize, + forward: bool, +} + +/// Build a native path from a validated connected chain or cycle. The marked source segment is +/// oriented a→b; `t` is local to that line/cubic. Coordinates are scaled into layer space. +pub(super) fn from_network(net: &Network, sx: f64, sy: f64, t: f64, forward: bool) -> Result { + if !t.is_finite() || !(0.0..=1.0).contains(&t) { + return Err("invalid start parameter"); + } + if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 { + return Err("invalid path scale"); + } + let markers: Vec = net.segments.iter().enumerate().filter_map(|(i, s)| (s.style == START_SEGMENT_STYLE).then_some(i)).collect(); + let [marked] = markers.as_slice() else { + return Err("missing or ambiguous start segment marker"); + }; + + let (uses, closed) = ordered_chain(net, *marked)?; + let mut d = BezPath::new(); + let mut total = 0.0; + let mut prefix = 0.0; + let mut start_segment_length = 0.0; + for use_ in &uses { + let (p0, p1, p2, p3) = cubic(net, *use_, sx, sy)?; + if d.elements().is_empty() { + d.move_to((p0.0, p0.1)); + } + let line = p1 == p0 && p2 == p3; + let (length, partial) = if line { + d.line_to((p3.0, p3.1)); + let line = Line::new(Point::new(p0.0, p0.1), Point::new(p3.0, p3.1)); + (line.arclen(1e-9), line.arclen(1e-9) * t) + } else { + d.curve_to((p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1)); + let curve = CubicBez::new((p0.0, p0.1), (p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1)); + let partial = if t <= 0.0 { 0.0 } else { curve.subsegment(0.0..t).arclen(1e-9) }; + (curve.arclen(1e-9), partial) + }; + if !length.is_finite() || length <= 1e-10 { + return Err("zero-length path segment"); + } + if use_.segment == *marked { + start_segment_length = partial; + if !start_segment_length.is_finite() { + return Err("invalid path length"); + } + prefix = total; + } + total += length; + } + if closed { + d.close_path(); + } + if !total.is_finite() || total <= 1e-10 { + return Err("zero-length path"); + } + let mut start = ((prefix + start_segment_length) / total).clamp(0.0, 1.0); + let flip = !forward; + if flip { + start = 1.0 - start; + } + let path = TextPath { d: d.to_svg(), start, flip }; + let lines = figcraft_doc::geom::measure::polylines(&path.path()).ok_or("cannot be measured by text renderer")?; + if lines.first().is_none_or(|line| line.length() <= 1e-9) { + return Err("cannot be measured by text renderer"); + } + Ok(path) +} + +/// Return every segment in chain order. Open chains are oriented so the marked segment runs a→b; +/// cycles start at the marked segment's a vertex and traverse it first. +fn ordered_chain(net: &Network, marked: usize) -> Result<(Vec, bool), &'static str> { + let marker = net.segments.get(marked).ok_or("invalid start segment index")?; + let mut adjacency = vec![Vec::::new(); net.vertices.len()]; + for (i, s) in net.segments.iter().enumerate() { + if s.a >= adjacency.len() || s.b >= adjacency.len() { + return Err("invalid path vertex index"); + } + if s.a == s.b { + return Err("path contains a segment that starts and ends at the same vertex"); + } + adjacency[s.a].push(i); + adjacency[s.b].push(i); + } + let mut endpoints = Vec::new(); + for edges in &adjacency { + if edges.len() > 2 { + return Err("branched path network"); + } + if edges.len() == 1 { + endpoints.push(edges[0]); + } + } + if !endpoints.is_empty() && endpoints.len() != 2 { + return Err("disconnected or malformed path network"); + } + let cycle = endpoints.is_empty(); + let mut used = vec![false; net.segments.len()]; + let mut out = Vec::with_capacity(net.segments.len()); + if cycle { + out.push(Use { segment: marked, forward: true }); + used[marked] = true; + let mut at = marker.b; + let mut visited = vec![false; net.vertices.len()]; + visited[marker.a] = true; + visited[marker.b] = true; + while at != marker.a { + let next = adjacency[at].iter().copied().find(|i| !used[*i]).ok_or("disconnected path cycle")?; + used[next] = true; + let s = &net.segments[next]; + let is_forward = s.a == at; + at = if is_forward { s.b } else { s.a }; + if at != marker.a && std::mem::replace(&mut visited[at], true) { + return Err("path cycle is not simple"); + } + out.push(Use { segment: next, forward: is_forward }); + if out.len() > net.segments.len() { + return Err("invalid path cycle"); + } + } + } else { + let endpoint = adjacency.iter().position(|e| e.len() == 1).ok_or("missing path endpoint")?; + let mut at = endpoint; + while let Some(next) = adjacency[at].iter().copied().find(|i| !used[*i]) { + used[next] = true; + let s = &net.segments[next]; + let is_forward = s.a == at; + at = if is_forward { s.b } else { s.a }; + out.push(Use { segment: next, forward: is_forward }); + } + let Some(marker_use) = out.iter().find(|u| u.segment == marked) else { return Err("disconnected start segment") }; + if !marker_use.forward { + out.reverse(); + for u in &mut out { + u.forward = !u.forward; + } + } + } + if !used.iter().all(|x| *x) { + return Err("disconnected path network"); + } + Ok((out, cycle)) +} + +type P = (f64, f64); + +fn cubic(net: &Network, use_: Use, sx: f64, sy: f64) -> Result<(P, P, P, P), &'static str> { + let s: &Segment = net.segments.get(use_.segment).ok_or("invalid path segment index")?; + let a = net.vertices.get(s.a).ok_or("invalid path vertex index")?; + let b = net.vertices.get(s.b).ok_or("invalid path vertex index")?; + let p0 = (a.x * sx, a.y * sy); + let p3 = (b.x * sx, b.y * sy); + let ta = (s.ta.0 * sx, s.ta.1 * sy); + let tb = (s.tb.0 * sx, s.tb.1 * sy); + if [p0.0, p0.1, p3.0, p3.1, ta.0, ta.1, tb.0, tb.1].iter().any(|x| !x.is_finite()) { + return Err("invalid path coordinates"); + } + let (p0, p1, p2, p3) = + if ta == (0.0, 0.0) && tb == (0.0, 0.0) { (p0, p0, p3, p3) } else { (p0, (p0.0 + ta.0, p0.1 + ta.1), (p3.0 + tb.0, p3.1 + tb.1), p3) }; + if use_.forward { Ok((p0, p1, p2, p3)) } else { Ok((p3, p2, p1, p0)) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fig::network::{Segment, Vertex}; + use figcraft_doc::geom::kurbo::PathEl; + + fn vertex(x: f64, y: f64) -> Vertex { + Vertex { style: 0, x, y } + } + + fn segment(style: u32, a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment { + Segment { style, a, ta, b, tb } + } + + fn curved() -> Network { + Network { + vertices: vec![vertex(0.0, 82.5), vertex(300.0, 82.5), vertex(600.0, 82.5)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (70.0, -110.0), (-70.0, -110.0)), segment(0, 1, 2, (70.0, 110.0), (-70.0, 110.0))], + ..Network::default() + } + } + + #[test] + fn local_cubic_parameter_converts_to_path_distance() { + let path = from_network(&curved(), 1.0, 1.0, 0.0086954795, true).unwrap(); + assert!((path.start - 0.004744078).abs() < 0.00001); + // Cubic t is not its fraction of total path length. + assert!((path.start - 0.0086954795).abs() > 0.003); + let elements = path.path().elements().to_vec(); + assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 82.5)); + assert!(matches!(elements.get(1), Some(PathEl::CurveTo(..)))); + } + + #[test] + fn second_segment_marker_and_t_are_respected() { + let mut net = curved(); + net.segments[0].style = 0; + net.segments[1].style = START_SEGMENT_STYLE; + let path = from_network(&net, 1.0, 1.0, 0.49800667, true).unwrap(); + assert!((path.start - 0.7490379757).abs() < 0.00002); + } + + #[test] + fn reverse_direction_inverts_normalized_start() { + let net = Network { + vertices: vec![vertex(0.0, 0.0), vertex(600.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + let path = from_network(&net, 1.0, 1.0, 0.83986014, false).unwrap(); + assert!(path.flip); + assert!((path.start - 0.16013986).abs() < 1e-8); + assert!(matches!(path.path().elements().get(1), Some(PathEl::LineTo(p)) if p.x == 600.0 && p.y == 0.0)); + } + + #[test] + fn rejects_missing_duplicate_and_zero_length_segments() { + let mut net = curved(); + net.segments[0].style = 0; + assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker")); + net.segments[1].style = START_SEGMENT_STYLE; + net.segments[0].style = START_SEGMENT_STYLE; + assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker")); + let zero = Network { + vertices: vec![vertex(0.0, 0.0), vertex(0.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&zero, 1.0, 1.0, 0.5, true), Err("zero-length path segment")); + } + + #[test] + fn rejects_branching_and_disconnected_segments() { + let branch = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(1.0, 1.0)], + segments: vec![ + segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 3, (0.0, 0.0), (0.0, 0.0)), + ], + ..Network::default() + }; + assert_eq!(from_network(&branch, 1.0, 1.0, 0.5, true), Err("branched path network")); + let disconnected = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(3.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), segment(0, 2, 3, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&disconnected, 1.0, 1.0, 0.5, true), Err("disconnected or malformed path network")); + } + + #[test] + fn scales_geometry_and_accepts_closed_cycles() { + let cycle = Network { + vertices: vec![vertex(0.0, 0.0), vertex(100.0, 0.0), vertex(100.0, 100.0)], + segments: vec![ + segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)), + segment(0, 2, 0, (0.0, 0.0), (0.0, 0.0)), + ], + ..Network::default() + }; + let path = from_network(&cycle, 2.0, 3.0, 0.5, true).unwrap(); + let elements = path.path().elements().to_vec(); + assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 0.0)); + assert!(matches!(elements.get(1), Some(PathEl::LineTo(p)) if p.x == 200.0 && p.y == 0.0)); + let total = 200.0 + 300.0 + 130_000.0_f64.sqrt(); + assert!((path.start - 100.0 / total).abs() < 1e-8); + assert!(matches!(elements.last(), Some(PathEl::ClosePath))); + } + + #[test] + fn rejects_paths_the_native_renderer_cannot_measure() { + let count = 40_000usize; + let many_lines = Network { + vertices: (0..=count).map(|i| vertex(i as f64, 0.0)).collect(), + segments: (0..count).map(|i| segment(if i == 0 { START_SEGMENT_STYLE } else { 0 }, i, i + 1, (0.0, 0.0), (0.0, 0.0))).collect(), + ..Network::default() + }; + assert_eq!(from_network(&many_lines, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + + let huge = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1e9, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&huge, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + + let tiny = Network { + vertices: vec![vertex(0.0, 0.0), vertex(5e-10, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&tiny, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + } +} diff --git a/crates/format/src/fig/text_path_tests.rs b/crates/format/src/fig/text_path_tests.rs new file mode 100644 index 00000000..66148930 --- /dev/null +++ b/crates/format/src/fig/text_path_tests.rs @@ -0,0 +1,211 @@ +//! Mapper → private REST ingress tests for source text-path fields. + +use super::kiwi::Kv; + +const SCHEMA: &str = " +enum NodeType DOCUMENT CANVAS TEXT TEXT_PATH +enum StyleType NONE TEXT +enum TextDecoration NONE UNDERLINE STRIKETHROUGH +struct GUID uint sessionID, uint localID +struct StyleId GUID guid +struct ParentIndex GUID guid, string position +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +struct FontName string family, string style +message TextOverride uint styleID, FontName fontName, float fontSize +message TextData string characters, uint[] characterStyleIDs, TextOverride[] styleOverrideTable +message TextPathStart float tValue, bool forward +message VectorData uint vectorNetworkBlob, Vector normalizedSize +message Blob byte[] bytes +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, TextData textData, float fontSize, FontName fontName, TextPathStart textPathStart, VectorData vectorData, StyleType styleType, StyleId styleIdForText, TextDecoration textDecoration +message Message NodeChange[] nodeChanges, Blob[] blobs +"; +use figcraft_doc::{NodeKind, TextPath}; + +fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn a(values: Vec>) -> Kv<'static> { + Kv::Array(values) +} + +fn f(value: f32) -> Kv<'static> { + Kv::Float(value) +} + +fn u(value: u64) -> Kv<'static> { + Kv::Uint(value) +} + +fn s(value: &'static str) -> Kv<'static> { + Kv::Str(value.into()) +} + +fn point(x: f32, y: f32) -> Kv<'static> { + o(vec![("x", f(x)), ("y", f(y))]) +} + +fn guid(id: u64) -> Kv<'static> { + o(vec![("sessionID", u(1)), ("localID", u(id))]) +} + +fn node(id: u64, parent: Option, ty: &'static str, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut node = vec![("guid", guid(id)), ("type", s(ty))]; + if let Some(parent) = parent { + node.push(("parentIndex", o(vec![("guid", guid(parent)), ("position", s("!"))]))); + } + node.extend(fields); + o(node) +} + +fn blob() -> Vec { + let mut bytes = Vec::new(); + let u32le = |out: &mut Vec, n: u32| out.extend_from_slice(&n.to_le_bytes()); + let f32le = |out: &mut Vec, n: f32| out.extend_from_slice(&n.to_le_bytes()); + u32le(&mut bytes, 2); // vertices + u32le(&mut bytes, 1); // segments + u32le(&mut bytes, 0); // regions + for (x, y) in [(0.0, 0.0), (100.0, 0.0)] { + u32le(&mut bytes, 0); + f32le(&mut bytes, x); + f32le(&mut bytes, y); + } + u32le(&mut bytes, 0x01ff_ffff); // source start-segment marker + u32le(&mut bytes, 0); + f32le(&mut bytes, 0.0); + f32le(&mut bytes, 0.0); + u32le(&mut bytes, 1); + f32le(&mut bytes, 0.0); + f32le(&mut bytes, 0.0); + bytes +} + +fn canvas() -> Vec { + let schema = super::kiwi::Schema::parse(SCHEMA).unwrap(); + let message = message(true); + let message = super::kiwi::encode::message(&schema, schema.find("Message").unwrap(), &message).unwrap(); + let mut out = b"fig-kiwi\0\0\0\0".to_vec(); + for raw in [super::kiwi::encode::schema(&schema), message] { + let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &raw).unwrap(); + let compressed = encoder.finish().unwrap(); + out.extend_from_slice(&(compressed.len() as u32).to_le_bytes()); + out.extend_from_slice(&compressed); + } + out +} + +fn message(with_path: bool) -> Kv<'static> { + let mut nodes = vec![node(0, None, "DOCUMENT", vec![]), node(1, Some(0), "CANVAS", vec![])]; + let mut blobs = Vec::new(); + if with_path { + blobs.push(o(vec![("bytes", Kv::Bytes(blob()))])); + let text = o(vec![ + ("characters", s("alpha")), + ("characterStyleIDs", a(vec![u(0), u(0), u(0), u(2), u(2)])), + ( + "styleOverrideTable", + a(vec![o(vec![("styleID", u(2)), ("fontName", o(vec![("family", s("Inter")), ("style", s("Medium"))])), ("fontSize", f(30.0))])]), + ), + ]); + nodes.push(node( + 2, + Some(1), + "TEXT_PATH", + vec![ + ("name", s("alpha path")), + ("size", point(100.0, 0.0)), + ("transform", o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(0.0)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(0.0))])), + ("textData", text), + ("fontSize", f(24.0)), + ("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])), + ("textPathStart", o(vec![("tValue", f(0.5)), ("forward", Kv::Bool(true))])), + ("vectorData", o(vec![("vectorNetworkBlob", u(0)), ("normalizedSize", point(100.0, 0.0))])), + ], + )); + } else { + nodes.push(node(2, Some(1), "TEXT_PATH", vec![("name", s("broken path")), ("textData", o(vec![("characters", s("fallback"))]))])); + } + o(vec![("nodeChanges", a(nodes)), ("blobs", a(blobs))]) +} + +fn linked_underline_message() -> Kv<'static> { + let Kv::Object(mut root) = message(true) else { panic!("expected message object") }; + let (_, Kv::Array(nodes)) = root.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap() else { panic!("expected node changes array") }; + let text = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("alpha path")).unwrap(); + let Kv::Object(fields) = text else { panic!("expected text node object") }; + fields.push(("styleIdForText", o(vec![("guid", guid(3))]))); + nodes.push(node( + 3, + Some(1), + "TEXT", + vec![ + ("name", s("Shared style")), + ("styleType", s("TEXT")), + ("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])), + ("fontSize", f(24.0)), + ("textDecoration", s("UNDERLINE")), + ], + )); + Kv::Object(root) +} + +#[test] +fn mapped_text_path_reaches_native_model_with_runs_and_no_standin_warning() { + let message = message(true); + let (json, warnings) = super::map::to_rest(&message, "Text path"); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); + let (doc, warnings) = crate::rest::import_fig(&json, &|_| None).unwrap(); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + let path = text.path.as_deref().unwrap(); + assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false }); + assert_eq!(text.characters, "alpha"); + assert_eq!(text.runs.len(), 1); + assert_eq!(text.runs[0].start, 3); + assert_eq!(text.runs[0].end, 5); +} + +#[test] +fn public_rest_import_ignores_private_fig_text_path_field() { + let message = message(true); + let (json, _) = super::map::to_rest(&message, "Text path"); + // The mapper's private field is accepted only by import_fig, never by public REST import. + let path = json["document"]["children"][0]["children"][0]["_figTextPath"].clone(); + assert!(path.is_object()); + let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + assert!(text.path.is_none()); +} + +#[test] +fn serialized_kiwi_canvas_import_decodes_start_and_builds_native_path() { + let (doc, warnings) = super::import(&canvas(), "Serialized text path").unwrap(); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + let path = text.path.as_deref().unwrap(); + assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false }); + assert_eq!(text.characters, "alpha"); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); +} + +#[test] +fn malformed_text_path_falls_back_with_specific_warning() { + let message = message(false); + let (json, warnings) = super::map::to_rest(&message, "Text path"); + assert!(warnings.iter().any(|w| w.contains("text path has no start data") && w.contains("text imports off its path")), "{warnings:?}"); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let node = doc.nodes().find(|n| n.name == "broken path").unwrap(); + assert!(matches!(&node.kind, NodeKind::Text(text) if text.path.is_none())); +} + +#[test] +fn linked_text_style_decoration_warns_from_effective_mapped_style() { + let message = linked_underline_message(); + let (json, warnings) = super::map::to_rest(&message, "Text path"); + assert_eq!(json["document"]["children"][0]["children"][0]["style"]["textDecoration"], "UNDERLINE"); + assert!(warnings.iter().any(|w| w == "text path underline or strikethrough decoration is not rendered"), "{warnings:?}"); +} diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 3875204f..9b8e9be4 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use figcraft_doc::{ DecorationLength, DecorationStyle, LetterSpacing, LineHeight, ListStyle, ParaList, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase, - TextData, TextDecoration, TextStyle, + TextData, TextDecoration, TextPath, TextStyle, }; use serde_json::Value; @@ -29,6 +29,16 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData { td.runs = runs(cx, v, &td); td.normalize(); lists(v, &mut td); + if cx.fig_sources.is_some() + && let Some(path) = v.get("_figTextPath") + { + match (path.get("d").and_then(Value::as_str), path.get("start").and_then(Value::as_f64), path.get("flip").and_then(Value::as_bool)) { + (Some(d), Some(start), Some(flip)) if !d.is_empty() && start.is_finite() && (0.0..=1.0).contains(&start) => { + td.path = Some(Box::new(TextPath { d: d.to_owned(), start, flip })); + } + _ => cx.warn("invalid internal .fig text path data was ignored"), + } + } td } diff --git a/docs/gaps.md b/docs/gaps.md index e8b111a8..a5cd6a3b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -41,6 +41,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ## Entries ### 1. `.fig` files: the import's tail +- **Text paths (2026-10-15):** local `.fig` import maps supported connected `TEXT_PATH` networks + to existing editable native text paths, preserving the start segment, direction and styled + runs. Other vertical placements, multiline/ambiguous networks, path decorations, public REST + text paths and `.fig` text-path export remain limited. - **Landed (2026-10-11):** `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors, auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). Then (2026-10-11): vectors open as editable vector networks (per-point corner radius, handle mirroring, end caps; outlines when the network blob is missing or damaged), sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning, and set-variable, set-variable-mode and conditional prototype actions come across. `file.open`, `file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved over. From 3723fcffbd84faff68912a68426b64669a44aae8 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:42:33 +0200 Subject: [PATCH 08/33] M1: keep text decoration skip ink from .fig files Text layers, styled runs and text styles in .fig files carry a skip-ink boolean for their decoration. The importer now reads it into the native decoration look: an omitted run value inherits the layer's, a local value overrides a linked shared text style without changing the style itself, and instance overrides keep an explicit false over a true main. A malformed value counts as unset. The exporter writes the actual value of the layer and of each styled run instead of a constant true, and a decoration look that differs from the default only in skip ink no longer triggers the unsupported-decoration note, so default native text exports its real false value. --- crates/format/src/fig/export.rs | 10 +- crates/format/src/fig/interop_tests.rs | 3 +- crates/format/src/fig/map.rs | 6 + crates/format/src/fig/mod.rs | 2 + crates/format/src/fig/text_skip_ink_tests.rs | 237 +++++++++++++++++++ 5 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 crates/format/src/fig/text_skip_ink_tests.rs diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index bfd0b615..dd3f2e88 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1502,7 +1502,6 @@ impl<'d> Exporter<'d> { ("textExplicitLayoutVersion", u(1)), ("textBidiVersion", u(1)), ("textTracking", fl(0.0)), - ("textDecorationSkipInk", b(true)), ("emojiImageSet", e("APPLE")), ]); if td.auto_resize == TextAutoResize::Truncate { @@ -1589,7 +1588,7 @@ impl<'d> Exporter<'d> { self.warn("nested list levels were written as the first level"); } let styles = std::iter::once(&td.style).chain(td.runs.iter().map(|r| &r.style)); - if styles.clone().any(|st| st.decoration_look.is_some() || !st.variations.is_empty()) { + if styles.clone().any(|st| unsupported_decoration_look(st) || !st.variations.is_empty()) { self.warn("text decoration looks and variable font settings were left out"); } if td.path.is_some() { @@ -2394,6 +2393,12 @@ fn key_codes(key: &str) -> Option<(Vec, bool)> { } /// The text style fields of a layer or of an override table entry. +fn unsupported_decoration_look(st: &TextStyle) -> bool { + let mut look = st.look(); + look.skip_ink = false; + look != Default::default() +} + fn style_fields(st: &TextStyle, f: &mut Fields) { let line_height = match st.line_height { // Percent units count from the font's own line height (100 % is "auto"); a share of the @@ -2425,6 +2430,7 @@ fn style_fields(st: &TextStyle, f: &mut Fields) { ("letterSpacing", letter_spacing), ("textCase", e(case)), ("textDecoration", e(decoration)), + ("textDecorationSkipInk", b(st.look().skip_ink)), ]); if let Some(url) = &st.link { f.push(("hyperlink", o(vec![("url", s(url))]))); diff --git a/crates/format/src/fig/interop_tests.rs b/crates/format/src/fig/interop_tests.rs index b51760c5..56391975 100644 --- a/crates/format/src/fig/interop_tests.rs +++ b/crates/format/src/fig/interop_tests.rs @@ -187,7 +187,8 @@ fn text_carries_its_layout_settings_and_a_line_per_paragraph() { with_changes(&sample(), |of, _| { let t = of("TEXT"); assert!(!flag(&t, "autoRename")); - assert!(flag(&t, "fontVariantCommonLigatures") && flag(&t, "fontVariantContextualLigatures") && flag(&t, "textDecorationSkipInk")); + assert!(flag(&t, "fontVariantCommonLigatures") && flag(&t, "fontVariantContextualLigatures")); + assert!(!flag(&t, "textDecorationSkipInk"), "default native text exports its actual false value"); assert_eq!(name(&t, "fontVersion"), ""); assert_eq!((num(&t, "textUserLayoutVersion"), num(&t, "textExplicitLayoutVersion"), num(&t, "textBidiVersion")), (5.0, 1.0, 1.0)); assert_eq!(num(&t, "textTracking"), 0.0); diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..745b6d81 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1119,6 +1119,9 @@ impl<'a> Mapper<'a> { if let Some(decoration @ ("NONE" | "UNDERLINE" | "STRIKETHROUGH")) = v.str("textDecoration") { style.insert("textDecoration".into(), json!(decoration)); } + if let Some(skip_ink) = v.flag("textDecorationSkipInk") { + style.insert("textDecorationSkipInk".into(), json!(skip_ink)); + } if let Some(case @ ("ORIGINAL" | "UPPER" | "LOWER" | "TITLE" | "SMALL_CAPS" | "SMALL_CAPS_FORCED")) = v.str("textCase") { style.insert("textCase".into(), json!(case)); } @@ -2096,6 +2099,9 @@ fn text_style(v: &View, size: Option) -> Obj { copy_num(&mut s, v, "paragraphIndent", "paragraphIndent"); copy_str(&mut s, v, "textCase", "textCase"); copy_str(&mut s, v, "textDecoration", "textDecoration"); + if let Some(skip_ink) = v.flag("textDecorationSkipInk") { + s.insert("textDecorationSkipInk".into(), json!(skip_ink)); + } let font_size = v.num("fontSize").or(size).unwrap_or(12.0); if let Some(ls) = v.get("letterSpacing") { let val = num(ls, "value").unwrap_or(0.0); diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 81457f8d..8b3f02a0 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -25,6 +25,8 @@ mod source_identity_tests; #[cfg(test)] mod tests; #[cfg(test)] +mod text_skip_ink_tests; +#[cfg(test)] mod text_style_tests; use std::io::Read; diff --git a/crates/format/src/fig/text_skip_ink_tests.rs b/crates/format/src/fig/text_skip_ink_tests.rs new file mode 100644 index 00000000..8d1cd295 --- /dev/null +++ b/crates/format/src/fig/text_skip_ink_tests.rs @@ -0,0 +1,237 @@ +//! `.fig` text skip-ink values map, inherit and round-trip as native decoration look state. + +use super::kiwi::Kv; +use super::{export, import}; +use figcraft_doc::{Document, NodeKind, StyleKind, StyleRun, TextData, TextStyle}; +use serde_json::Value; + +fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn guid(id: u64) -> Kv<'static> { + object(vec![("sessionID", Kv::Uint(8)), ("localID", Kv::Uint(id))]) +} + +fn node(id: u64, ty: &'static str, name: &str, parent: Option, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut out = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + out.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))]))); + } + out.append(&mut fields); + object(out) +} + +fn font() -> Kv<'static> { + object(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))]) +} + +fn raw(base: Option>, runs: Vec>, chars: &str, ids: Vec, shared: Option>>) -> Value { + let mut layer = vec![ + ("fontName", font()), + ("fontSize", Kv::Float(20.0)), + ( + "textData", + object(vec![ + ("characters", Kv::Str(chars.into())), + ("characterStyleIDs", Kv::Array(ids.into_iter().map(Kv::Uint).collect())), + ("styleOverrideTable", Kv::Array(runs)), + ]), + ), + ]; + if let Some(value) = base { + layer.push(("textDecorationSkipInk", value)); + } + let mut nodes = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; + if let Some(shared) = shared { + let mut fields = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font()), ("fontSize", Kv::Float(20.0))]; + if let Some(value) = shared { + fields.push(("textDecorationSkipInk", value)); + } + nodes.push(node(10, "TEXT", "Body", Some(2), fields)); + layer.push(("styleIdForText", object(vec![("guid", guid(10))]))); + } + nodes.push(node(11, "TEXT", "Label", Some(2), layer)); + let message = object(vec![("nodeChanges", Kv::Array(nodes))]); + super::map::to_rest(&message, "Synthetic skip ink").0 +} + +fn imported(value: &Value) -> Document { + let (doc, warnings) = crate::rest::import_fig(value, &|_| None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + doc +} + +fn label(doc: &Document) -> &TextData { + doc.nodes().find(|n| n.name == "Label").unwrap().kind.text().unwrap() +} + +fn skip_at(text: &TextData, index: usize) -> bool { + text.runs.iter().find(|r| r.start <= index && index < r.end).map_or_else(|| text.style.look().skip_ink, |r| r.style.look().skip_ink) +} + +#[test] +fn node_boolean_false_true_omitted_and_malformed_keep_typed_semantics() { + for (raw_value, expected) in [(Some(Kv::Bool(true)), true), (Some(Kv::Bool(false)), false), (None, false), (Some(Kv::Str("true".into())), false)] + { + let doc = imported(&raw(raw_value.clone(), vec![], "A", vec![0], None)); + assert_eq!(label(&doc).style.look().skip_ink, expected, "source value {raw_value:?}"); + } +} + +#[test] +fn runs_inherit_explicit_false_and_true_by_utf16_character_ids() { + let table = vec![ + object(vec![("styleID", Kv::Uint(1))]), + object(vec![("styleID", Kv::Uint(2)), ("textDecorationSkipInk", Kv::Bool(false))]), + object(vec![("styleID", Kv::Uint(3)), ("textDecorationSkipInk", Kv::Bool(true))]), + ]; + let doc = imported(&raw(Some(Kv::Bool(true)), table, "A😀BC", vec![0, 1, 1, 2, 3], None)); + let text = label(&doc); + assert_eq!(text.characters.chars().count(), 4); + assert_eq!(text.characters.encode_utf16().count(), 5, "the emoji occupies two source style-ID units"); + assert_eq!((0..text.char_count()).map(|i| skip_at(text, i)).collect::>(), vec![true, true, false, true]); + + let false_base = imported(&raw( + Some(Kv::Bool(false)), + vec![object(vec![("styleID", Kv::Uint(1)), ("textDecorationSkipInk", Kv::Bool(true))])], + "ab", + vec![1, 0], + None, + )); + assert_eq!((0..2).map(|i| skip_at(label(&false_base), i)).collect::>(), vec![true, false]); +} + +#[test] +fn local_skip_ink_override_preserves_shared_style_and_omitted_value_inherits() { + for (local, expected) in [(Some(false), false), (None, true)] { + let value = raw(local.map(Kv::Bool), vec![], "ab", vec![0, 0], Some(Some(Kv::Bool(true)))); + let doc = imported(&value); + let node = doc.nodes().find(|n| n.name == "Label").unwrap(); + assert_eq!(node.kind.text().unwrap().style.look().skip_ink, expected); + let linked = doc.style(node.styles.text.as_deref().unwrap()).unwrap(); + let StyleKind::Text { style } = &linked.kind else { panic!("linked definition must remain text") }; + assert!(style.look().skip_ink, "local value must not alter definition"); + } +} + +#[test] +fn instance_path_override_preserves_explicit_false_over_true_main() { + let main_text = node( + 21, + "TEXT", + "Label", + Some(20), + vec![ + ("fontName", font()), + ("fontSize", Kv::Float(20.0)), + ("textDecorationSkipInk", Kv::Bool(true)), + ("textData", object(vec![("characters", Kv::Str("A".into()))])), + ], + ); + let instance = node( + 30, + "INSTANCE", + "Copy", + Some(2), + vec![( + "symbolData", + object(vec![ + ("symbolID", guid(20)), + ( + "symbolOverrides", + Kv::Array(vec![object(vec![ + ("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))])), + ("textDecorationSkipInk", Kv::Bool(false)), + ])]), + ), + ]), + )], + ); + let message = object(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, "DOCUMENT", "Document", None, vec![]), + node(2, "CANVAS", "Page", Some(1), vec![]), + node(20, "SYMBOL", "Main", Some(2), vec![]), + main_text, + instance, + ]), + )]); + let value = super::map::to_rest(&message, "Instance skip ink").0; + let copy = value["document"]["children"][0]["children"].as_array().unwrap().iter().find(|n| n["name"] == "Copy").unwrap(); + let label = copy["children"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap(); + assert_eq!(label["style"]["textDecorationSkipInk"], false); + let doc = imported(&value); + let copy = doc.nodes().find(|n| n.name == "Copy").unwrap(); + let instance_label = doc.node(*copy.children.first().unwrap()).unwrap(); + assert!(!instance_label.kind.text().unwrap().style.look().skip_ink); +} + +#[test] +fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only_warning() { + let mut doc = Document::new("Skip ink export"); + let mut text = TextData::new("abcd"); + text.style.set_look(|look| look.skip_ink = false); + let mut skipped = text.style.clone(); + skipped.set_look(|look| look.skip_ink = true); + text.runs = vec![StyleRun { start: 1, end: 3, style: skipped }]; + doc.create(doc.first_page().unwrap(), NodeKind::Text(text), "Label", 0.0, 0.0, 100.0, 30.0).unwrap(); + + let (bytes, warnings) = export(&doc, None).unwrap(); + assert!(warnings.is_empty(), "skip-ink is supported: {warnings:?}"); + let archive = super::Archive::open(&bytes).unwrap(); + let (schema, body) = super::decode_canvas(&archive.canvas).unwrap(); + let message = schema.decode_message(schema.find("Message").unwrap(), &body).unwrap(); + let value = super::debug_json(&message); + let layer = value["nodeChanges"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap(); + assert_eq!(layer["textDecorationSkipInk"], false); + assert_eq!(layer.as_object().unwrap().keys().filter(|k| *k == "textDecorationSkipInk").count(), 1); + let rows = layer["textData"]["styleOverrideTable"].as_array().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["textDecorationSkipInk"], true); + let (back, warnings) = import(&bytes, "Skip ink export").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!((0..4).map(|i| skip_at(label(&back), i)).collect::>(), vec![false, true, true, false]); + + let mut custom = TextData::new("x"); + custom.style.set_look(|look| look.skip_ink = true); + custom.style.set_look(|look| look.thickness = figcraft_doc::DecorationLength::Pixels(2.0)); + let mut custom_doc = Document::new("Unsupported decoration"); + custom_doc.create(custom_doc.first_page().unwrap(), NodeKind::Text(custom), "Custom", 0.0, 0.0, 100.0, 30.0).unwrap(); + let (_, warnings) = export(&custom_doc, None).unwrap(); + assert!(warnings.iter().any(|w| w.contains("text decoration looks")), "custom look warning missing: {warnings:?}"); + + let mut variable = TextData::new("x"); + variable.style.variations.insert("wght".into(), 400.0); + let mut variable_doc = Document::new("Variable font warning"); + variable_doc.create(variable_doc.first_page().unwrap(), NodeKind::Text(variable), "Variable", 0.0, 0.0, 100.0, 30.0).unwrap(); + let (_, warnings) = export(&variable_doc, None).unwrap(); + assert!(warnings.iter().any(|w| w.contains("variable font settings")), "variation warning missing: {warnings:?}"); + + let mut shared = TextStyle::default(); + shared.set_look(|look| look.skip_ink = true); + let mut local = TextStyle::default(); + local.set_look(|look| look.skip_ink = false); + let mut linked_doc = Document::new("Shared skip ink"); + linked_doc.styles.push(figcraft_doc::Style { + id: "body".into(), + name: "Body".into(), + description: String::new(), + kind: StyleKind::Text { style: shared }, + bound: Default::default(), + }); + let id = linked_doc + .create(linked_doc.first_page().unwrap(), NodeKind::Text(TextData { style: local, ..TextData::new("x") }), "Label", 0.0, 0.0, 100.0, 30.0) + .unwrap(); + linked_doc.node_mut(id).unwrap().styles.text = Some("body".into()); + let (bytes, warnings) = export(&linked_doc, None).unwrap(); + assert!(warnings.is_empty(), "skip-ink-only shared styles are supported: {warnings:?}"); + let (back, warnings) = import(&bytes, "Shared skip ink").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let node = back.nodes().find(|n| n.name == "Label").unwrap(); + assert!(!node.kind.text().unwrap().style.look().skip_ink); + let linked = back.style(node.styles.text.as_deref().unwrap()).unwrap(); + let StyleKind::Text { style } = &linked.kind else { panic!("shared style link must remain text") }; + assert!(style.look().skip_ink, "local false must not alter the shared definition"); +} From 7fd44641f2190a564989062583e3743aaacc2acd Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:45:29 +0200 Subject: [PATCH 09/33] M1: keep paragraph options from .fig files Text layers, text styles and instance overrides in .fig files can carry list spacing, hanging list, hanging punctuation and leading trim. The importer now reads them from the highest layer with a valid value, so an invalid instance override row falls through to the valid value below it, while explicit zero, false and NONE values stay resets. Both text layers and text styles share one mapping. The native text data stores these options once per text node, so differing values on separate runs are not represented, and the experimental .fig exporter keeps its existing note that these settings are left out. --- crates/format/src/fig/map.rs | 37 +++ crates/format/src/fig/mod.rs | 2 + crates/format/src/fig/text_paragraph_tests.rs | 288 ++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 crates/format/src/fig/text_paragraph_tests.rs diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 745b6d81..f5306acc 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -140,6 +140,41 @@ fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) { } } +/// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through. +fn text_num(v: &View, key: &str) -> Option { + v.layers.iter().rev().find_map(|layer| { + layer.get(key).and_then(|value| match value { + Kv::Bool(_) => None, + _ => value.f64(), + }) + }) +} + +fn text_flag(v: &View, key: &str) -> Option { + v.layers.iter().rev().find_map(|layer| layer.get(key).and_then(Kv::bool)) +} + +fn insert_text_options(style: &mut Obj, v: &View) { + if let Some(value) = text_num(v, "listSpacing") { + style.insert("listSpacing".into(), json!(value)); + } + for k in ["hangingList", "hangingPunctuation"] { + if let Some(value) = text_flag(v, k) { + style.insert(k.into(), json!(value)); + } + } + if let Some(value) = leading_trim(v) { + style.insert("leadingTrim".into(), json!(value)); + } +} + +fn leading_trim<'a>(v: &View<'a>) -> Option<&'a str> { + v.layers.iter().rev().find_map(|layer| match layer.get("leadingTrim").and_then(Kv::str) { + Some(value @ ("NONE" | "CAP_HEIGHT")) => Some(value), + _ => None, + }) +} + /// A node seen through override layers: the last layer with a field wins. #[derive(Clone)] struct View<'a> { @@ -1132,6 +1167,7 @@ impl<'a> Mapper<'a> { for k in ["paragraphSpacing", "paragraphIndent"] { copy_num(&mut style, v, k, k); } + insert_text_options(&mut style, v); if let Some(n) = v.num("maxLines").filter(|n| *n >= 1.0) { style.insert("maxLines".into(), json!(n)); } @@ -2097,6 +2133,7 @@ fn text_style(v: &View, size: Option) -> Obj { copy_num(&mut s, v, "fontSize", "fontSize"); copy_num(&mut s, v, "paragraphSpacing", "paragraphSpacing"); copy_num(&mut s, v, "paragraphIndent", "paragraphIndent"); + insert_text_options(&mut s, v); copy_str(&mut s, v, "textCase", "textCase"); copy_str(&mut s, v, "textDecoration", "textDecoration"); if let Some(skip_ink) = v.flag("textDecorationSkipInk") { diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 8b3f02a0..4ae8c67a 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -25,6 +25,8 @@ mod source_identity_tests; #[cfg(test)] mod tests; #[cfg(test)] +mod text_paragraph_tests; +#[cfg(test)] mod text_skip_ink_tests; #[cfg(test)] mod text_style_tests; diff --git a/crates/format/src/fig/text_paragraph_tests.rs b/crates/format/src/fig/text_paragraph_tests.rs new file mode 100644 index 00000000..b3f05b05 --- /dev/null +++ b/crates/format/src/fig/text_paragraph_tests.rs @@ -0,0 +1,288 @@ +//! Paragraph options (list spacing, hanging list and punctuation, leading trim) in encoded `.fig` +//! files: the highest valid value wins, invalid instance rows fall through, native saves keep them. + +use super::kiwi::Kv; +use super::{export, import}; + +fn paragraph_fig(shared: Vec<(&'static str, Kv<'static>)>, local: Vec<(&'static str, Kv<'static>)>) -> Vec { + use super::kiwi::{Schema, encode}; + + let schema = Schema::parse( + "enum NodeType NONE DOCUMENT CANVAS TEXT\n\ + enum StyleType NONE FILL TEXT\n\ + enum LeadingTrim NONE CAP_HEIGHT\n\ + struct GUID uint sessionID, uint localID\n\ + struct ParentIndex GUID guid, string position\n\ + struct FontName string family, string style, string postscript\n\ + struct StyleId GUID guid\n\ + message TextData string characters\n\ + message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, StyleType styleType, FontName fontName, float fontSize, StyleId styleIdForText, TextData textData, float paragraphSpacing, float paragraphIndent, float listSpacing, bool hangingList, bool hangingPunctuation, LeadingTrim leadingTrim\n\ + message Message NodeChange[] nodeChanges", + ) + .unwrap(); + fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) + } + fn guid(id: u64) -> Kv<'static> { + object(vec![("sessionID", Kv::Uint(7)), ("localID", Kv::Uint(id))]) + } + fn node(id: u64, ty: &'static str, name: &str, parent: Option, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut out = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + out.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))]))); + } + out.extend(fields); + object(out) + } + fn font(family: &str) -> Kv<'static> { + object(vec![("family", Kv::Str(family.into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))]) + } + let mut base = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font("Inter")), ("fontSize", Kv::Float(20.0))]; + base.extend(shared); + let mut layer = vec![ + ("fontName", font("Stale")), + ("fontSize", Kv::Float(40.0)), + ("styleIdForText", object(vec![("guid", guid(10))])), + ("textData", object(vec![("characters", Kv::Str("a\nb".into()))])), + ]; + layer.extend(local); + let msg = object(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, "DOCUMENT", "Document", None, vec![]), + node(2, "CANVAS", "Page", Some(1), vec![]), + node(10, "TEXT", "Shared", Some(2), base), + node(11, "TEXT", "Label", Some(2), layer), + ]), + )]); + let root = schema.find("Message").unwrap(); + let mut deflated = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + use std::io::Write; + deflated.write_all(&encode::schema(&schema)).unwrap(); + let schema_chunk = deflated.finish().unwrap(); + let message = encode::message(&schema, root, &msg).unwrap(); + let message_chunk = ruzstd::encoding::compress_to_vec(message.as_slice(), ruzstd::encoding::CompressionLevel::Fastest); + let mut bytes = b"fig-kiwi".to_vec(); + bytes.extend_from_slice(&101u32.to_le_bytes()); + for chunk in [schema_chunk, message_chunk] { + bytes.extend_from_slice(&(chunk.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&chunk); + } + bytes +} + +#[test] +fn encoded_fig_maps_paragraph_list_fields_through_native_round_trip() { + let shared = vec![ + ("listSpacing", Kv::Float(18.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("CAP_HEIGHT")), + ]; + let (inherited, warnings) = import(¶graph_fig(shared.clone(), vec![]), "Synthetic paragraph options").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let text = inherited.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); + assert_eq!(text.style.font_family, "Inter"); + + let reset = vec![ + ("listSpacing", Kv::Float(0.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("NONE")), + ]; + let (cleared, warnings) = import(¶graph_fig(shared, reset), "Synthetic paragraph resets").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let text = cleared.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap(); + assert_eq!(text.list_spacing, 0.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None); + assert_eq!(text.style.font_family, "Inter"); + + let bytes = crate::save_bytes(&inherited, None).unwrap(); + let (back, warnings) = crate::load_bytes(&bytes).unwrap(); + assert!(warnings.is_empty(), "native round trip: {warnings:?}"); + let text = back.nodes().find(|node| node.name == "Label").unwrap().kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); + + let (_, export_warnings) = export(&inherited, None).unwrap(); + assert!( + export_warnings.iter().any(|warning| warning.contains("list spacing, hanging punctuation, leading trim")), + "existing .fig export loss must remain visible: {export_warnings:?}" + ); +} + +fn instance_paragraph_fig(overrides: Vec<(&'static str, Kv<'static>)>, nonfinite_spacing: bool) -> Vec { + use super::kiwi::{Schema, encode}; + + let schema = Schema::parse( + "enum NodeType NONE DOCUMENT CANVAS TEXT SYMBOL INSTANCE\n\ + enum StyleType NONE FILL TEXT\n\ + enum LeadingTrim NONE CAP_HEIGHT\n\ + struct GUID uint sessionID, uint localID\n\ + struct ParentIndex GUID guid, string position\n\ + struct FontName string family, string style, string postscript\n\ + struct StyleId GUID guid\n\ + struct GUIDPath GUID[] guids\n\ + struct SymbolOverride GUIDPath guidPath, float listSpacing, bool hangingList, bool hangingPunctuation, LeadingTrim leadingTrim\n\ + struct SymbolData GUID symbolID, SymbolOverride[] symbolOverrides\n\ + message TextData string characters\n\ + message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, StyleType styleType, FontName fontName, float fontSize, StyleId styleIdForText, TextData textData, float listSpacing, bool hangingList, bool hangingPunctuation, LeadingTrim leadingTrim, SymbolData symbolData\n\ + message Message NodeChange[] nodeChanges", + ) + .unwrap(); + fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) + } + fn guid(id: u64) -> Kv<'static> { + object(vec![("sessionID", Kv::Uint(7)), ("localID", Kv::Uint(id))]) + } + fn node(id: u64, ty: &'static str, name: &str, parent: Option, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut out = vec![("guid", guid(id)), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + out.push(("parentIndex", object(vec![("guid", guid(parent)), ("position", Kv::Str("a".into()))]))); + } + out.extend(fields); + object(out) + } + fn font() -> Kv<'static> { + object(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into())), ("postscript", Kv::Str(String::new()))]) + } + let main_text = node( + 21, + "TEXT", + "Label", + Some(20), + vec![ + ("fontName", font()), + ("fontSize", Kv::Float(20.0)), + ("styleIdForText", object(vec![("guid", guid(10))])), + ("textData", object(vec![("characters", Kv::Str("a\nb".into()))])), + ], + ); + let mut override_fields = vec![("guidPath", object(vec![("guids", Kv::Array(vec![guid(21)]))]))]; + override_fields.extend(overrides); + let instance = node( + 30, + "INSTANCE", + "Copy", + Some(2), + vec![("symbolData", object(vec![("symbolID", guid(20)), ("symbolOverrides", Kv::Array(vec![object(override_fields)]))]))], + ); + let message = object(vec![( + "nodeChanges", + Kv::Array(vec![ + node(1, "DOCUMENT", "Document", None, vec![]), + node(2, "CANVAS", "Page", Some(1), vec![]), + node( + 10, + "TEXT", + "Shared", + Some(2), + vec![ + ("styleType", Kv::Enum("TEXT")), + ("fontName", font()), + ("fontSize", Kv::Float(20.0)), + ("listSpacing", Kv::Float(18.0)), + ("hangingList", Kv::Bool(true)), + ("hangingPunctuation", Kv::Bool(true)), + ("leadingTrim", Kv::Enum("CAP_HEIGHT")), + ], + ), + node(20, "SYMBOL", "Main", Some(2), vec![]), + main_text, + instance, + ]), + )]); + let root = schema.find("Message").unwrap(); + let mut deflated = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + use std::io::Write; + deflated.write_all(&encode::schema(&schema)).unwrap(); + let schema_chunk = deflated.finish().unwrap(); + let mut message = encode::message(&schema, root, &message).unwrap(); + if nonfinite_spacing { + let finite = 37.0_f32.to_bits().rotate_right(23).to_le_bytes(); + let nan = f32::NAN.to_bits().rotate_right(23).to_le_bytes(); + let matches: Vec = message.windows(finite.len()).enumerate().filter_map(|(i, w)| (w == finite).then_some(i)).collect(); + assert_eq!(matches.len(), 1, "finite test token must identify the instance listSpacing field"); + let offset = matches[0]; + message[offset..offset + nan.len()].copy_from_slice(&nan); + } + let message_chunk = ruzstd::encoding::compress_to_vec(message.as_slice(), ruzstd::encoding::CompressionLevel::Fastest); + let mut bytes = b"fig-kiwi".to_vec(); + bytes.extend_from_slice(&101u32.to_le_bytes()); + for chunk in [schema_chunk, message_chunk] { + bytes.extend_from_slice(&(chunk.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&chunk); + } + bytes +} + +#[test] +fn encoded_instance_paragraph_overrides_fall_back_only_when_invalid() { + let valid = vec![ + ("listSpacing", Kv::Float(24.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("NONE")), + ]; + let (doc, warnings) = import(&instance_paragraph_fig(valid, false), "Synthetic instance paragraph values").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let copy = doc.nodes().find(|node| node.name == "Copy").unwrap(); + let label = doc.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 24.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None); + + let reset = vec![ + ("listSpacing", Kv::Float(0.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Enum("NONE")), + ]; + let (doc, warnings) = import(&instance_paragraph_fig(reset, false), "Synthetic instance paragraph resets").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let copy = doc.nodes().find(|node| node.name == "Copy").unwrap(); + let label = doc.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 0.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::None); + + let invalid = vec![ + ("listSpacing", Kv::Float(37.0)), + ("hangingList", Kv::Bool(false)), + ("hangingPunctuation", Kv::Bool(false)), + ("leadingTrim", Kv::Uint(77)), + ]; + let (doc, warnings) = import(&instance_paragraph_fig(invalid, true), "Synthetic malformed instance paragraph values").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let copy = doc.nodes().find(|node| node.name == "Copy").unwrap(); + let label = doc.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); + + let bytes = crate::save_bytes(&doc, None).unwrap(); + let (back, warnings) = crate::load_bytes(&bytes).unwrap(); + assert!(warnings.is_empty(), "native round trip: {warnings:?}"); + let copy = back.nodes().find(|node| node.name == "Copy").unwrap(); + let label = back.node(*copy.children.first().unwrap()).unwrap(); + let text = label.kind.text().unwrap(); + assert_eq!(text.list_spacing, 18.0); + assert!(!text.hanging_list); + assert!(!text.hanging_punctuation); + assert_eq!(text.leading_trim, figcraft_doc::LeadingTrim::CapHeight); +} From c4536f412b950ac12cb87350323180a9ea028fbe Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:46:01 +0200 Subject: [PATCH 10/33] M1: import labels of straight connectors from .fig files Straight connectors saved with a manual routing snapshot (connector and derived-frame version 1) now come in as an editable vector plus their label text layers, instead of an empty frame. Label placement composes the saved background and text transforms, and label paints and text styles use the existing import path. Connectors of other versions or line styles keep the existing fallback with their compatibility notes. At the layer depth or node limit the saved vector is kept without extra label layers, and skipped labels are reported once. Live endpoint bindings and routing are not retained. --- .../format/src/fig/connector_label_tests.rs | 308 ++++++++++++++++++ crates/format/src/fig/map.rs | 262 ++++++++++++++- .../map_connector_snapshot_budget_tests.rs | 145 +++++++++ crates/format/src/fig/mod.rs | 2 + 4 files changed, 712 insertions(+), 5 deletions(-) create mode 100644 crates/format/src/fig/connector_label_tests.rs create mode 100644 crates/format/src/fig/map_connector_snapshot_budget_tests.rs diff --git a/crates/format/src/fig/connector_label_tests.rs b/crates/format/src/fig/connector_label_tests.rs new file mode 100644 index 00000000..56cea368 --- /dev/null +++ b/crates/format/src/fig/connector_label_tests.rs @@ -0,0 +1,308 @@ +use super::{Archive, decode_canvas, kiwi::Kv, map}; + +fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn scalar(value: f32) -> Kv<'static> { + Kv::Float(value) +} + +fn guid(local_id: u64) -> Kv<'static> { + obj(vec![("sessionID", Kv::Uint(40_000_000)), ("localID", Kv::Uint(local_id))]) +} + +fn record(local_id: u64, children: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut fields = vec![("guidPath", obj(vec![("guids", Kv::Array(vec![guid(local_id)]))]))]; + fields.extend(children); + obj(fields) +} + +fn transform(x: f32, y: f32) -> Kv<'static> { + obj(vec![("m00", scalar(1.0)), ("m01", scalar(0.0)), ("m02", scalar(x)), ("m10", scalar(0.0)), ("m11", scalar(1.0)), ("m12", scalar(y))]) +} + +fn point(x: f32, y: f32) -> Kv<'static> { + obj(vec![("x", scalar(x)), ("y", scalar(y))]) +} + +fn path_blob() -> Vec { + let mut bytes = vec![1]; // MoveTo + bytes.extend(0.0f32.to_le_bytes()); + bytes.extend(0.0f32.to_le_bytes()); + bytes.push(2); // LineTo + bytes.extend(20.0f32.to_le_bytes()); + bytes.extend(10.0f32.to_le_bytes()); + bytes +} + +fn paint() -> Kv<'static> { + obj(vec![ + ("type", Kv::Enum("SOLID")), + ("color", obj(vec![("r", scalar(0.1)), ("g", scalar(0.2)), ("b", scalar(0.8)), ("a", scalar(1.0))])), + ("visible", Kv::Bool(true)), + ]) +} + +fn synthetic_connector(version: f32, include_text: bool, text_x: f32) -> Kv<'static> { + let line_geometry = Kv::Array(vec![obj(vec![("commandsBlob", Kv::Uint(0))])]); + let line = record(0, vec![("transform", transform(7.0, 9.0)), ("size", point(20.0, 10.0)), ("fillGeometry", line_geometry)]); + let text = record(1, vec![("transform", transform(text_x, 3.0)), ("size", point(80.0, 24.0))]); + let background = record(2, vec![("transform", transform(100.0, 30.0)), ("size", point(90.0, 30.0))]); + let mut derived = vec![line]; + if include_text { + derived.extend([text, background]); + } + let line_style = record(0, vec![("strokePaints", Kv::Array(vec![paint()]))]); + let text_style = record( + 1, + vec![ + ("textData", obj(vec![("characters", Kv::Str("synthetic label".into()))])), + ("fontSize", scalar(24.0)), + ("fontName", obj(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into()))])), + ("fillPaints", Kv::Array(vec![paint()])), + ("visible", Kv::Bool(false)), + ], + ); + let bg_style = record(2, vec![("fillPaints", Kv::Array(vec![])), ("visible", Kv::Bool(false))]); + let mut styles = vec![line_style]; + if include_text { + styles.extend([text_style, bg_style]); + } + obj(vec![ + ( + "nodeChanges", + Kv::Array(vec![ + obj(vec![("guid", guid(0)), ("type", Kv::Enum("DOCUMENT")), ("name", Kv::Str("Doc".into()))]), + obj(vec![ + ("guid", guid(1)), + ("parentIndex", obj(vec![("guid", guid(0)), ("position", Kv::Str("!".into()))])), + ("type", Kv::Enum("CANVAS")), + ("name", Kv::Str("Page".into())), + ]), + obj(vec![ + ("guid", obj(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(123))])), + ("parentIndex", obj(vec![("guid", guid(1)), ("position", Kv::Str("!".into()))])), + ("type", Kv::Enum("CONNECTOR")), + ("name", Kv::Str("synthetic connector".into())), + ("size", point(140.0, 50.0)), + ("opacity", scalar(0.5)), + ("connectorType", Kv::Enum("MANUAL")), + ("connectorVersion", scalar(version)), + ("connectorLineStyle", Kv::Enum("STRAIGHT")), + ("connectorStartCap", Kv::Enum("NONE")), + ("connectorEndCap", Kv::Enum("ARROW")), + ("derivedImmutableFrameData", obj(vec![("version", scalar(1.0)), ("overrides", Kv::Array(derived))])), + ("nodeGenerationData", obj(vec![("overrides", Kv::Array(styles))])), + ]), + ]), + ), + ("blobs", Kv::Array(vec![obj(vec![("bytes", Kv::Bytes(path_blob()))])])), + ]) +} + +fn map_synthetic(version: f32, include_text: bool, text_x: f32) -> (serde_json::Value, Vec) { + map::to_rest(&synthetic_connector(version, include_text, text_x), "synthetic") +} + +fn find_node<'a>(doc: &'a figcraft_doc::Document, name: &str) -> &'a figcraft_doc::Node { + doc.nodes().find(|node| node.name == name).expect("native node") +} + +#[test] +fn synthetic_label_style_transform_and_container_opacity_survive_native_roundtrip() { + let (rest, warnings) = map_synthetic(1.0, true, 8.5); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["type"], "FRAME"); + assert_eq!(connector["opacity"], 0.5); + assert_eq!(connector["children"].as_array().unwrap().len(), 2, "empty/hidden background is omitted; hidden text remains explicit"); + let text = &connector["children"][1]; + assert_eq!(text["type"], "TEXT"); + assert_eq!(text["visible"], false); + assert_eq!(text["fills"].as_array().unwrap().len(), 1); + assert!((text["fills"][0]["color"]["b"].as_f64().unwrap() - 0.8).abs() < 1e-6); + assert_eq!(text["style"]["fontSize"], 24.0); + assert_eq!(text["relativeTransform"], serde_json::json!([[1.0, 0.0, 108.5], [0.0, 1.0, 33.0]])); + assert!(warnings.iter().any(|warning| warning.contains("live endpoint bindings are not retained"))); + + let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap(); + let bytes = crate::save_bytes(&native, None).unwrap(); + let (reopened, _) = crate::load_bytes(&bytes).unwrap(); + let frame = find_node(&reopened, "synthetic connector"); + assert_eq!(frame.opacity, 0.5); + let text = find_node(&reopened, "synthetic label"); + assert!(!text.visible); + assert_eq!(text.opacity, 1.0, "root opacity must not be copied to child"); + assert_eq!(text.transform.as_coeffs()[4..], [108.5, 33.0]); + assert_eq!(text.fills.len(), 1); + let figcraft_doc::NodeKind::Text(data) = &text.kind else { panic!("text child kind") }; + assert_eq!(data.style.font_size, 24.0); +} + +#[test] +fn synthetic_connector_missing_optional_derived_label_keeps_vector() { + let (rest, warnings) = map_synthetic(1.0, false, 0.0); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["type"], "VECTOR", "an unlabelled connector keeps the single-vector shape"); + assert!(connector.get("children").is_none()); + assert!(warnings.iter().all(|warning| !warning.contains("connector labels were skipped"))); +} + +#[test] +fn synthetic_unlabelled_connector_imports_as_one_vector_with_line_style() { + let (rest, warnings) = map_synthetic(1.0, false, 0.0); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["type"], "VECTOR"); + assert_eq!(connector["name"], "synthetic connector"); + assert_eq!(connector["opacity"], 0.5); + assert_eq!(connector["strokes"].as_array().map(Vec::len), Some(1)); + assert_eq!(connector["strokeCapEnd"], "ARROW"); + assert!(warnings.iter().all(|warning| !warning.contains("connector label")), "{warnings:?}"); + + let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap(); + let node = find_node(&native, "synthetic connector"); + assert!(matches!(node.kind, figcraft_doc::NodeKind::Vector { .. })); +} + +#[test] +fn synthetic_connector_unsupported_version_uses_legacy_warning() { + let (rest, warnings) = map_synthetic(2.0, true, 8.5); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["type"], "VECTOR"); + assert!(warnings.iter().any(|warning| warning == "connector labels were skipped")); +} + +#[test] +fn synthetic_connector_malformed_label_bounds_or_transform_keep_vector() { + let mut malformed_bounds = synthetic_connector(1.0, true, 8.5); + set_override_field(&mut malformed_bounds, "derivedImmutableFrameData", 1, "size", point(f32::NAN, 24.0)); + let (rest, warnings) = map::to_rest(&malformed_bounds, "synthetic"); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["children"].as_array().unwrap().len(), 1); + assert_eq!(connector["children"][0]["type"], "VECTOR"); + assert!(warnings.iter().any(|warning| warning == "connector label was skipped: text bounds are invalid")); + + let mut malformed_transform = synthetic_connector(1.0, true, 8.5); + set_override_field(&mut malformed_transform, "derivedImmutableFrameData", 1, "transform", transform(f32::NAN, 3.0)); + let (rest, warnings) = map::to_rest(&malformed_transform, "synthetic"); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["children"].as_array().unwrap().len(), 1); + assert_eq!(connector["children"][0]["type"], "VECTOR"); + assert!(warnings.iter().any(|warning| warning == "connector label was skipped: text transform is invalid")); +} + +#[test] +fn synthetic_connector_authored_hidden_background_is_retained() { + let mut message = synthetic_connector(1.0, true, 8.5); + set_override_field(&mut message, "nodeGenerationData", 2, "fillPaints", Kv::Array(vec![paint()])); + let (rest, _) = map::to_rest(&message, "synthetic"); + let connector = &rest["document"]["children"][0]["children"][0]; + let background = connector["children"].as_array().unwrap().iter().find(|child| child["type"] == "RECTANGLE").expect("authored background"); + assert_eq!(background["visible"], false); + assert_eq!(background["fills"].as_array().unwrap().len(), 1); +} + +fn connector<'a>(layers: &'a [serde_json::Value], name: &str) -> &'a serde_json::Value { + layers.iter().find(|layer| layer.get("name").and_then(serde_json::Value::as_str) == Some(name)).expect("connector in fixture") +} + +fn field_mut<'a, 's>(value: &'a mut Kv<'s>, key: &str) -> Option<&'a mut Kv<'s>> { + let Kv::Object(fields) = value else { return None }; + fields.iter_mut().find(|(name, _)| *name == key).map(|(_, value)| value) +} + +fn connector_mut<'a, 's>(message: &'a mut Kv<'s>, local_id: u64) -> Option<&'a mut Kv<'s>> { + let Kv::Object(fields) = message else { return None }; + let node_changes = fields.iter_mut().find(|(name, _)| *name == "nodeChanges").map(|(_, value)| value)?; + let Kv::Array(nodes) = node_changes else { return None }; + nodes.iter_mut().find(|node| { + node.get("type").and_then(Kv::str) == Some("CONNECTOR") + && node.get("guid").and_then(|guid| guid.get("localID")).and_then(Kv::f64) == Some(local_id as f64) + }) +} + +fn set_override_field(message: &mut Kv<'_>, field: &str, local_id: u64, key: &'static str, value: Kv<'static>) { + let connector = connector_mut(message, 123).expect("synthetic connector"); + let section = field_mut(connector, field).expect("connector override section"); + let overrides = field_mut(section, "overrides").expect("override list"); + let Kv::Array(rows) = overrides else { panic!("override list array") }; + let row = rows + .iter_mut() + .find(|row| { + row.get("guidPath") + .and_then(|path| path.get("guids")) + .and_then(|ids| ids.list().first()) + .and_then(|id| id.get("localID")) + .and_then(Kv::f64) + == Some(local_id as f64) + }) + .expect("override row"); + let Kv::Object(fields) = row else { panic!("override row object") }; + if let Some((_, current)) = fields.iter_mut().find(|(name, _)| *name == key) { + *current = value; + } else { + fields.push((key, value)); + } +} + +#[test] +#[ignore = "set FIGCRAFT_CONNECTOR_LABEL_FIXTURE to the retained synthetic source-initial.zip"] +fn synthetic_export_keeps_saved_vector_gap_and_composed_label_position() { + let path = std::env::var_os("FIGCRAFT_CONNECTOR_LABEL_FIXTURE").expect("fixture path is required when running this test"); + let bytes = std::fs::read(path).expect("read synthetic connector export"); + let archive = Archive::open(&bytes).expect("open synthetic connector export"); + let (schema, raw_message) = decode_canvas(&archive.canvas).expect("decode synthetic canvas"); + let root = schema.find("Message").expect("Message definition"); + let message = schema.decode_message(root, &raw_message).expect("decode synthetic message"); + let (rest, warnings) = map::to_rest(&message, "connector labels"); + let layers = rest.pointer("/document/children/0/children").and_then(serde_json::Value::as_array).expect("page layers"); + + let plain = connector(layers, "Connector line"); + assert_eq!(plain["type"], "VECTOR", "an unlabelled connector stays one vector"); + assert_eq!(plain["strokeCapStart"], "ROUND"); + assert_eq!(plain["strokeCapEnd"], "LINE_ARROW"); + assert!(plain.get("children").is_none()); + + let alpha = connector(layers, "STRAIGHT alpha"); + let alpha_children = alpha["children"].as_array().expect("snapshot children"); + let alpha_vector = alpha_children.iter().find(|child| child["type"] == "VECTOR").expect("saved connector vector"); + let segments = alpha_vector.pointer("/vectorNetwork/segments").and_then(serde_json::Value::as_array).expect("editable vector network"); + assert_eq!(segments.len(), 4, "network preserves the label gap as two disconnected runs"); + assert_eq!(alpha_children.iter().filter(|child| child["type"] == "TEXT").count(), 1); + let alpha_text = alpha_children.iter().find(|child| child["type"] == "TEXT").expect("label text"); + assert_eq!(alpha_text["characters"], "STRAIGHT alpha"); + assert_eq!(alpha_text["relativeTransform"], serde_json::json!([[1.0, 0.0, 201.5], [0.0, 1.0, 44.0]])); + assert!(warnings.iter().all(|warning| !warning.contains("connector labels were skipped"))); + + let mut malformed = message.clone(); + let alpha_node = connector_mut(&mut malformed, 127).expect("alpha node in decoded message"); + let derived = field_mut(alpha_node, "derivedImmutableFrameData").expect("derived frame data"); + let overrides = field_mut(derived, "overrides").expect("derived overrides"); + let Kv::Array(overrides) = overrides else { panic!("derived overrides array") }; + let text = overrides + .iter_mut() + .find(|row| { + row.get("guidPath") + .and_then(|path| path.get("guids")) + .and_then(|ids| ids.list().first()) + .and_then(|id| id.get("localID")) + .and_then(Kv::f64) + == Some(1.0) + }) + .expect("text template record"); + let transform = field_mut(text, "transform").expect("text transform"); + *transform = Kv::Object(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(f32::NAN)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]); + let (malformed_rest, malformed_warnings) = map::to_rest(&malformed, "connector labels"); + let malformed_layers = malformed_rest.pointer("/document/children/0/children").and_then(serde_json::Value::as_array).expect("page layers"); + let malformed_alpha = connector(malformed_layers, "STRAIGHT alpha"); + assert_eq!(malformed_alpha["children"].as_array().map(Vec::len), Some(1)); + assert_eq!(malformed_alpha["children"][0]["type"], "VECTOR"); + assert!(malformed_warnings.iter().any(|warning| warning == "connector label was skipped: text transform is invalid")); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..d6983a74 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -90,6 +90,10 @@ fn normalize_override_segment(path: &mut [String], index: usize, scope: &Overrid } } +#[cfg(test)] +#[path = "map_connector_snapshot_budget_tests.rs"] +mod connector_snapshot_budget_tests; + /// A `GUID`-holding reference (`StyleId`, `VariableID`, …): its `guid`. fn ref_guid(v: &Kv) -> Option { v.get("guid").and_then(guid).filter(|g| g != NO_STYLE) @@ -750,18 +754,28 @@ impl<'a> Mapper<'a> { _ => id.to_string(), }; let own = view.str("type").unwrap_or(""); - let stand = stand_in(own); + let connector_snapshot = own == "CONNECTOR" && self.connector_snapshot_supported(&view); + // Only a connector with a saved label needs the frame wrapper; an unlabelled one stays a single vector. + let connector_terminal = + connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= MAX_NODES); + // A supported connector snapshot has its own note below instead of the generic stand-in one. + let stand = stand_in(own).filter(|_| !connector_snapshot); let ty = match own { "ROUNDED_RECTANGLE" => "RECTANGLE", "SYMBOL" => "COMPONENT", "FRAME" if view.flag("isStateGroup") == Some(true) => "COMPONENT_SET", "FRAME" if view.flag("resizeToFit") == Some(true) => "GROUP", "VARIABLE" | "VARIABLE_SET" => return None, + "CONNECTOR" if connector_snapshot && connector_terminal => "VECTOR", + "CONNECTOR" if connector_snapshot => "FRAME", t => stand.map_or(t, |s| s.0), }; if let Some((_, what)) = stand { self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); } + if connector_snapshot { + self.warn("connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained"); + } self.count += 1; let mut o = Obj::new(); o.insert("id".into(), json!(rest_id)); @@ -790,7 +804,18 @@ impl<'a> Mapper<'a> { } _ => {} } - if own == "CONNECTOR" { + if own == "CONNECTOR" && connector_snapshot { + if connector_terminal { + self.connector_snapshot_flatten(&mut o, &view, &rest_id, space); + } else { + o.insert("fills".into(), json!([])); + o.insert("strokes".into(), json!([])); + for key in ["styles", "strokeWeight", "strokeAlign", "strokeJoin", "strokeDashes", "strokeCap"] { + o.remove(key); + } + o.insert("clipsContent".into(), json!(false)); + } + } else if own == "CONNECTOR" { self.connector(&mut o, &view, space); } if ty == "COMPONENT" || ty == "COMPONENT_SET" { @@ -798,8 +823,10 @@ impl<'a> Mapper<'a> { } self.prop_refs(&mut o, &view, inst); let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY }; - let mut kids = Vec::new(); - if ty == "INSTANCE" { + let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(&view, &rest_id) } else { Vec::new() }; + if connector_terminal { + // The saved vector is flattened into the connector node when no child slot remains. + } else if ty == "INSTANCE" { if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) { let main = self.nodes.get(&sym).map(|m| View::of(m)); let fit = Fit::frame(main.and_then(|m| m.size()), view.size()); @@ -898,7 +925,7 @@ impl<'a> Mapper<'a> { o.insert(to.into(), json!(cap_name(c))); } } - if own_text(v).is_some() { + if connector_has_saved_label(v) { self.warn("connector labels were skipped"); } if ["vectorNetwork", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { @@ -938,6 +965,207 @@ impl<'a> Mapper<'a> { ); } + fn connector_snapshot_supported(&self, v: &View<'a>) -> bool { + if v.str("connectorType") != Some("MANUAL") + || v.get("connectorVersion").and_then(Kv::f64) != Some(1.0) + || v.str("connectorLineStyle") != Some("STRAIGHT") + || v.get("derivedImmutableFrameData").and_then(|data| data.get("version")).and_then(Kv::f64) != Some(1.0) + { + return false; + } + let Some(line) = connector_record(v, "derivedImmutableFrameData", 0) else { return false }; + if line.get("transform").and_then(matrix).is_none() || valid_size(line.get("size")).is_none() { + return false; + } + let geometry = ["fillGeometry", "strokeGeometry"].iter().any(|key| { + line.get(key).map(Kv::list).unwrap_or_default().iter().any(|path| { + num(path, "commandsBlob") + .and_then(|index| self.blobs.get(index as usize)) + .and_then(|blob| svg_path(blob)) + .is_some_and(|data| !data.is_empty()) + }) + }); + let network = line + .get("vectorData") + .and_then(|data| num(data, "vectorNetworkBlob")) + .and_then(|index| self.blobs.get(index as usize)) + .and_then(|blob| network::decode_styled(blob)) + .is_some_and(|(network, _)| !network.segments.is_empty()); + (geometry || network) && connector_record(v, "nodeGenerationData", 0).is_some() + } + + fn connector_snapshot_flatten(&mut self, out: &mut Obj, v: &View<'a>, rest_id: &str, space: Affine) { + let parent = space * v.get("transform").and_then(matrix).unwrap_or_default(); + let Some(vector) = self.connector_snapshot_vector(v, rest_id, parent, false) else { + self.warn("connector snapshot vector data is missing; connector was left empty"); + return; + }; + for key in [ + "size", + "relativeTransform", + "fills", + "strokes", + "strokeWeight", + "strokeAlign", + "strokeJoin", + "strokeDashes", + "strokeCap", + "strokeCapStart", + "strokeCapEnd", + "styles", + "fillGeometry", + "strokeGeometry", + "vectorNetwork", + ] { + if let Some(value) = vector.get(key) { + out.insert(key.to_string(), value.clone()); + } + } + if vector.get("visible").and_then(Value::as_bool) == Some(false) { + out.insert("visible".into(), json!(false)); + } + if let Some(line_opacity) = vector.get("opacity").and_then(Value::as_f64) { + let root_opacity = out.get("opacity").and_then(Value::as_f64).unwrap_or(1.0); + out.insert("opacity".into(), json!(root_opacity * line_opacity)); + } + if connector_has_saved_label(v) { + let reason = if self.count >= MAX_NODES { "node budget" } else { "depth limit" }; + self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children")); + } + } + + /// Build the saved vector child or flattened terminal connector from the same child-only view. + fn connector_snapshot_vector(&mut self, v: &View<'a>, rest_id: &str, space: Affine, nested: bool) -> Option { + let (Some(line), Some(line_style)) = (connector_record(v, "derivedImmutableFrameData", 0), connector_record(v, "nodeGenerationData", 0)) + else { + return None; + }; + let mut vector = Obj::new(); + vector.insert("id".into(), json!(if nested { format!("{rest_id};vector") } else { rest_id.to_string() })); + vector.insert("type".into(), json!("VECTOR")); + if let Some(name) = v.str("name") { + vector.insert("name".into(), json!(if nested { format!("{name} vector") } else { name.to_string() })); + } + let line_view = View::of(line).with([line_style]); + self.common(&mut vector, &line_view, "VECTOR", space, None); + self.geometry(&mut vector, &line_view); + for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] { + if let Some(cap) = v.str(from) { + vector.entry(to).or_insert_with(|| json!(cap_name(cap))); + } + } + Some(vector) + } + + /// Snapshot the straight, manual connector contract carried by its generated children. + fn connector_snapshot_children(&mut self, v: &View<'a>, rest_id: &str) -> Vec { + if self.count >= MAX_NODES { + self.warn(format!("the file has more than {MAX_NODES} layers; connector snapshot children were skipped")); + return Vec::new(); + } + let Some(vector) = self.connector_snapshot_vector(v, rest_id, Affine::IDENTITY, true) else { + self.warn("connector snapshot vector data is missing; connector was left empty"); + return Vec::new(); + }; + let mut children = Vec::new(); + children.push(Value::Object(vector)); + self.count += 1; + + let Some(text_frame) = connector_record(v, "derivedImmutableFrameData", 1) else { + if connector_has_saved_label(v) { + self.warn("connector label was skipped: derived text bounds are missing"); + } + return children; + }; + let Some(background) = connector_record(v, "derivedImmutableFrameData", 2) else { + if connector_has_saved_label(v) || valid_size(text_frame.get("size")).is_some_and(|(w, h)| w > 0.0 && h > 0.0) { + self.warn("connector label was skipped: derived background bounds are missing"); + } + return children; + }; + let Some(text_style) = connector_record(v, "nodeGenerationData", 1) else { + if valid_size(text_frame.get("size")).is_some_and(|(w, h)| w > 0.0 && h > 0.0) { + self.warn("connector label was skipped: text style data is missing"); + } + return children; + }; + let text_view = View::of(text_frame).with([text_style]); + let Some(characters) = text_view.get("textData").and_then(|d| d.get("characters")).and_then(Kv::str).filter(|s| !s.trim().is_empty()) else { + return children; + }; + let Some(text_transform) = text_frame.get("transform").and_then(matrix) else { + self.warn("connector label was skipped: text transform is invalid"); + return children; + }; + let Some(background_transform) = background.get("transform").and_then(matrix) else { + self.warn("connector label was skipped: background transform is invalid"); + return children; + }; + let Some(text_size) = valid_size(text_frame.get("size")) else { + self.warn("connector label was skipped: text bounds are invalid"); + return children; + }; + let Some(background_size) = valid_size(background.get("size")) else { + self.warn("connector label was skipped: background bounds are invalid"); + return children; + }; + let composed_text_transform = background_transform * text_transform; + if !composed_text_transform.as_coeffs().iter().all(|coefficient| coefficient.is_finite()) { + self.warn("connector label was skipped: composed text transform is invalid"); + return children; + } + + let Some(background_style) = connector_record(v, "nodeGenerationData", 2) else { + self.warn("connector label background was skipped: style data is missing"); + self.add_connector_text(&mut children, rest_id, characters, text_size, composed_text_transform, &text_view); + return children; + }; + let background_view = View::of(background).with([background_style]); + if !background_view.list("fillPaints").is_empty() { + if self.count >= MAX_NODES { + self.warn(format!("the file has more than {MAX_NODES} layers; connector label background was skipped")); + } else { + let mut rect = Obj::new(); + rect.insert("id".into(), json!(format!("{rest_id};label-background"))); + rect.insert("type".into(), json!("RECTANGLE")); + rect.insert("name".into(), json!("connector label background")); + self.common(&mut rect, &background_view, "RECTANGLE", Affine::IDENTITY, None); + rect.insert("size".into(), json!({"x": background_size.0, "y": background_size.1})); + rect.insert("relativeTransform".into(), rows(background_transform)); + rect.insert("clipsContent".into(), json!(false)); + children.push(Value::Object(rect)); + self.count += 1; + } + } + self.add_connector_text(&mut children, rest_id, characters, text_size, composed_text_transform, &text_view); + children + } + + fn add_connector_text( + &mut self, + children: &mut Vec, + rest_id: &str, + characters: &str, + size: (f64, f64), + transform: Affine, + view: &View<'a>, + ) { + if self.count >= MAX_NODES { + self.warn(format!("the file has more than {MAX_NODES} layers; connector label was skipped")); + return; + } + let mut text = Obj::new(); + text.insert("id".into(), json!(format!("{rest_id};label-text"))); + text.insert("type".into(), json!("TEXT")); + text.insert("name".into(), json!(characters)); + text.insert("size".into(), json!({"x": size.0, "y": size.1})); + self.common(&mut text, view, "TEXT", Affine::IDENTITY, None); + text.insert("relativeTransform".into(), rows(transform)); + self.text(&mut text, view); + children.push(Value::Object(text)); + self.count += 1; + } + /// Fields every layer type shares. fn common(&mut self, o: &mut Obj, v: &View<'a>, ty: &str, space: Affine, parent: Option<&View<'a>>) { for (from, to) in [("visible", "visible"), ("locked", "locked"), ("mask", "isMask")] { @@ -1852,6 +2080,30 @@ fn own_text<'a>(v: &View<'a>) -> Option<&'a str> { v.get("textData").and_then(|t| t.get("characters")).and_then(Kv::str).filter(|c| !c.trim().is_empty()) } +/// A generated connector child is identified by one exact template GUID, not its array position. +fn connector_record<'a>(v: &View<'a>, field: &str, local_id: u32) -> Option<&'a Kv<'a>> { + v.get(field)?.get("overrides")?.list().iter().find(|row| { + row.get("guidPath").and_then(|path| path.get("guids")).map(Kv::list).is_some_and(|guids| { + guids.len() == 1 && num(&guids[0], "sessionID") == Some(40_000_000.0) && num(&guids[0], "localID") == Some(f64::from(local_id)) + }) + }) +} + +fn connector_has_saved_label(v: &View<'_>) -> bool { + own_text(v).is_some() + || connector_record(v, "nodeGenerationData", 1) + .and_then(|r| r.get("textData")) + .and_then(|d| d.get("characters")) + .and_then(Kv::str) + .is_some_and(|s| !s.trim().is_empty()) +} + +fn valid_size(value: Option<&Kv<'_>>) -> Option<(f64, f64)> { + let x = num(value?, "x")?; + let y = num(value?, "y")?; + (x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0).then_some((x, y)) +} + /// The REST name of a stroke cap. fn cap_name(cap: &str) -> &str { match cap { diff --git a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs new file mode 100644 index 00000000..a14e671c --- /dev/null +++ b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs @@ -0,0 +1,145 @@ +//! Connector snapshot flattening stays within the node and depth budgets. + +use super::{MAX_DEPTH, MAX_NODES, Mapper, View}; +use crate::fig::kiwi::Kv; +use figcraft_doc::geom::Affine; + +fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn guid(local_id: u64) -> Kv<'static> { + obj(vec![("sessionID", Kv::Uint(40_000_000)), ("localID", Kv::Uint(local_id))]) +} + +fn path_blob() -> Vec { + let mut bytes = vec![1]; + bytes.extend(0.0f32.to_le_bytes()); + bytes.extend(0.0f32.to_le_bytes()); + bytes.push(2); + bytes.extend(20.0f32.to_le_bytes()); + bytes.extend(10.0f32.to_le_bytes()); + bytes +} + +fn record(local_id: u64, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + fields.push(("guidPath", obj(vec![("guids", Kv::Array(vec![guid(local_id)]))]))); + obj(fields) +} + +fn message() -> Kv<'static> { + let line = record( + 0, + vec![ + ( + "transform", + obj(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(0.0)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]), + ), + ("size", obj(vec![("x", Kv::Float(20.0)), ("y", Kv::Float(10.0))])), + ("fillGeometry", Kv::Array(vec![obj(vec![("commandsBlob", Kv::Uint(0))])])), + ], + ); + let text = record( + 1, + vec![ + ( + "transform", + obj(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(0.0)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]), + ), + ("size", obj(vec![("x", Kv::Float(50.0)), ("y", Kv::Float(20.0))])), + ], + ); + let background = record( + 2, + vec![ + ( + "transform", + obj(vec![ + ("m00", Kv::Float(1.0)), + ("m01", Kv::Float(0.0)), + ("m02", Kv::Float(0.0)), + ("m10", Kv::Float(0.0)), + ("m11", Kv::Float(1.0)), + ("m12", Kv::Float(0.0)), + ]), + ), + ("size", obj(vec![("x", Kv::Float(60.0)), ("y", Kv::Float(30.0))])), + ], + ); + let line_style = record(0, vec![]); + let text_style = record(1, vec![("textData", obj(vec![("characters", Kv::Str("budget label".into()))]))]); + let background_style = record(2, vec![("fillPaints", Kv::Array(vec![]))]); + obj(vec![ + ( + "nodeChanges", + Kv::Array(vec![obj(vec![ + ("guid", obj(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(123))])), + ("type", Kv::Enum("CONNECTOR")), + ("name", Kv::Str("budget connector".into())), + ("connectorType", Kv::Enum("MANUAL")), + ("connectorVersion", Kv::Float(1.0)), + ("connectorLineStyle", Kv::Enum("STRAIGHT")), + ("derivedImmutableFrameData", obj(vec![("version", Kv::Float(1.0)), ("overrides", Kv::Array(vec![line, text, background]))])), + ("nodeGenerationData", obj(vec![("overrides", Kv::Array(vec![line_style, text_style, background_style]))])), + ])]), + ), + ("blobs", Kv::Array(vec![obj(vec![("bytes", Kv::Bytes(path_blob()))])])), + ]) +} + +#[test] +fn connector_snapshot_respects_last_and_zero_child_slots() { + let message = message(); + let node = message.get("nodeChanges").unwrap().list().first().unwrap(); + let view = View::of(node); + let mut mapper = Mapper::new(&message); + mapper.count = MAX_NODES - 1; + let children = mapper.connector_snapshot_children(&view, "1:123"); + assert_eq!(children.len(), 1, "the final available slot preserves the saved vector"); + assert_eq!(children[0]["type"], "VECTOR"); + assert!(!children[0]["fillGeometry"].as_array().unwrap().is_empty(), "saved outline survives the budget limit"); + assert_eq!(children[0]["fillGeometry"][0]["path"], "M 0 0 L 20 10"); + assert_eq!(mapper.count, MAX_NODES); + assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped"))); + + let mut mapper = Mapper::new(&message); + mapper.count = MAX_NODES; + let children = mapper.connector_snapshot_children(&view, "1:123"); + assert!(children.is_empty()); + assert_eq!(mapper.count, MAX_NODES, "the node budget is never exceeded"); + assert!(mapper.warnings.iter().any(|warning| warning.contains("connector snapshot children were skipped"))); +} + +#[test] +fn connector_snapshot_flattens_vector_at_depth_and_node_limits() { + let message = message(); + let mut mapper = Mapper::new(&message); + let depth_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); + assert_eq!(depth_limited["type"], "VECTOR"); + assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty()); + assert!(depth_limited.get("children").is_none()); + assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children")); + + let mut mapper = Mapper::new(&message); + mapper.count = MAX_NODES - 1; + let budget_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, 0).expect("last allowed connector"); + assert_eq!(budget_limited["type"], "VECTOR"); + assert!(!budget_limited["fillGeometry"].as_array().unwrap().is_empty()); + assert!(budget_limited.get("children").is_none()); + assert_eq!(mapper.count, MAX_NODES); + assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children")); +} diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 81457f8d..3c736854 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -12,6 +12,8 @@ //! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped //! with a warning; malformed input never panics. +#[cfg(test)] +mod connector_label_tests; mod export; #[cfg(test)] mod export_tests; From d8e64a73bf0686277c0a06c2fdec2bb51261d064 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 09:15:25 +0200 Subject: [PATCH 11/33] M1: reword the vector segment style import notes --- crates/format/src/fig/map.rs | 4 ++-- crates/format/src/fig/tail_tests.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 1dc8e138..712a90fd 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1302,10 +1302,10 @@ impl<'a> Mapper<'a> { } } if has_uninterpreted { - self.warn("metadata for styled vector network segments was not interpreted"); + self.warn("style settings of some vector segments aren't supported; those segments use the layer's stroke"); } if has_missing { - self.warn("vector network segments reference missing style entries"); + self.warn("some vector segments refer to style settings missing from the file; they use the layer's stroke"); } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 657ff6ff..bc4f31cc 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -280,9 +280,9 @@ fn per_segment_stroke_styles_are_reported() { }; 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("vector network segments")), "{plain:?}"); - let uninterpreted = |w: &String| w.contains("metadata for styled vector network segments was not interpreted"); - let missing = |w: &String| w.contains("vector network segments reference missing style entries"); + assert!(!plain.iter().any(|w| w.contains("vector segments")), "{plain:?}"); + let uninterpreted = |w: &String| w.contains("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + let missing = |w: &String| w.contains("some vector segments refer to style settings missing from the file; they use the layer's stroke"); // Style 1 has a row in the layer's override table; its contents are not applied. let known = styled_import(1); assert_eq!(known.iter().filter(|w| uninterpreted(w)).count(), 1, "{known:?}"); From 41d465a678411260bb1c414e078d774a3b3b4d3b Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 09:15:25 +0200 Subject: [PATCH 12/33] M1: reword the vector segment style import notes --- crates/format/src/fig/map.rs | 4 ++-- crates/format/src/fig/tail_tests.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 05fa8566..36f45f31 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1211,10 +1211,10 @@ impl<'a> Mapper<'a> { } } if has_uninterpreted { - self.warn("metadata for styled vector network segments was not interpreted"); + self.warn("style settings of some vector segments aren't supported; those segments use the layer's stroke"); } if has_missing { - self.warn("vector network segments reference missing style entries"); + self.warn("some vector segments refer to style settings missing from the file; they use the layer's stroke"); } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 657ff6ff..bc4f31cc 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -280,9 +280,9 @@ fn per_segment_stroke_styles_are_reported() { }; 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("vector network segments")), "{plain:?}"); - let uninterpreted = |w: &String| w.contains("metadata for styled vector network segments was not interpreted"); - let missing = |w: &String| w.contains("vector network segments reference missing style entries"); + assert!(!plain.iter().any(|w| w.contains("vector segments")), "{plain:?}"); + let uninterpreted = |w: &String| w.contains("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + let missing = |w: &String| w.contains("some vector segments refer to style settings missing from the file; they use the layer's stroke"); // Style 1 has a row in the layer's override table; its contents are not applied. let known = styled_import(1); assert_eq!(known.iter().filter(|w| uninterpreted(w)).count(), 1, "{known:?}"); From d1d8c3f652cea20bee02bdbc3ded9a2f5362ba08 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 16 Oct 2026 12:08:58 -0400 Subject: [PATCH 13/33] ci: standardize FreeBSD workflow on shared FreeBSD 15.1 action (#640) * ci: standardize FreeBSD workflow on shared FreeBSD 15.1 action * ui: widen About window so contributor table fits with Inter font --- .github/workflows/freebsd.yml | 1 - crates/ui-egui/src/dialogs.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index ca0cd49f..b01af052 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -31,6 +31,5 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - freebsd-release: "14.3" run-package-check: true craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 236f8e0d..6e0ee3a6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -84,7 +84,7 @@ impl AboutTab { } /// The About window's width, the same on every tab (the Contributors table needs the room). -pub const ABOUT_WIDTH: f32 = 640.0; +pub const ABOUT_WIDTH: f32 = 660.0; /// The About window's tab row, drawn like the Design / Prototype tabs of the right panel. /// A row of tabs (the About window's, the shortcuts panel's): the current one highlighted. From 40c744da1092fce41803da7f7ef884cac8d68aab Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 17 Oct 2026 13:03:49 +0700 Subject: [PATCH 14/33] M1: convert frames to sections and sections to frames (#719) (#721) Frames on the page or in a section convert to sections (object.convertToSection) and sections back to frames (object.convertToFrame), in place, one undo step each, from the canvas context menu; nested frames, instances, main components and sections holding sections are refused. --- ROADMAP.md | 4 +- crates/engine/src/cmd/structure.rs | 62 ++++++++++++++++++-- crates/engine/src/convert_container_tests.rs | 52 +++++++++++++++- crates/ui-egui/locales/en.toml | 2 + crates/ui-egui/locales/es.toml | 3 + crates/ui-egui/src/menus.rs | 21 +++++++ docs/mcp.md | 2 + docs/target-app-parity.md | 3 +- 8 files changed, 140 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..bbde8bc5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. @@ -313,6 +314,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index bed70d3d..5643c058 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -26,10 +26,17 @@ pub fn specs() -> Vec { spec!( "object.convertToFrame", "Convert to frame", - "{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)", + "{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping; sections become frames that keep their fill and don't clip. Frames are left alone; instances, main components, layers inside instances and sections holding sections are refused)", true, |s, p| convert(s, p, "object.convertToFrame", true) ), + spec!( + "object.convertToSection", + "Convert to section", + "{ids?} (frames on the page or in a section become sections in place, keeping their fill and layers; what only a frame has goes: auto layout, clipping, layout grids, guides and scrolling. Sections are left alone; nested frames, instances and main components are refused)", + true, + convert_to_section + ), spec!( "object.wrapInSection", "Wrap in new section", @@ -199,6 +206,13 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res let n = doc.get(i)?; let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name))); match (&n.kind, to_frame) { + // A frame can't hold sections. + (NodeKind::Section { .. }, true) + if n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Section { .. }))) => + { + return refuse("holds sections, which can't go inside a frame: convert those first"); + } + (NodeKind::Section { .. }, true) => todo.push(i), (NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"), (NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"), (NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => { @@ -212,12 +226,15 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res } for i in &todo { if to_frame { - // The group's box as it is; nothing painted or clipped, so the look stays. + // The box as it is and nothing clipped, so the look stays: a group has nothing painted, a + // section keeps its fill. let n = doc.get_mut(*i)?; + if matches!(n.kind, NodeKind::Group) { + n.fills.clear(); + n.strokes.clear(); + } n.kind = NodeKind::Frame; n.clip = false; - n.fills.clear(); - n.strokes.clear(); continue; } let n = doc.get_mut(*i)?; @@ -242,6 +259,43 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res Ok(json!({"ids": todo})) } +/// Frames on the page or in a section become sections in place (sections only sit there). +fn convert_to_section(s: &mut Session, p: &Value) -> Result { + let cmd = "object.convertToSection"; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut todo = Vec::new(); + for i in ids { + let n = doc.get(i)?; + let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name))); + let on_board = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })); + match &n.kind { + NodeKind::Section { .. } => {} + NodeKind::Instance { .. } => return refuse("is an instance: its layers come from its main component (detach it first)"), + NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => return refuse("is a main component, which stays a frame"), + NodeKind::Frame if !on_board => return refuse("is inside a frame or group: only frames on the page or in a section become sections"), + NodeKind::Frame => todo.push(i), + _ => return refuse("is not a frame"), + } + } + for i in &todo { + let n = doc.get_mut(*i)?; + n.kind = NodeKind::Section { contents_hidden: false }; + (n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None); + n.grids.clear(); + n.guides.clear(); + n.styles.grid = None; + n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing")); + let kids = n.children.clone(); + for k in kids { + // Out of the flow there is nothing to fill or to be absolute against. + doc.get_mut(k)?.child_layout.absolute = false; + } + figcraft_layout::coherent_sizing(doc, *i); + } + Ok(json!({"ids": todo})) +} + fn ungroup(s: &mut Session, p: &Value) -> Result { let ids = req_targets("object.ungroup", s, p)?; let doc = s.doc_mut()?; diff --git a/crates/engine/src/convert_container_tests.rs b/crates/engine/src/convert_container_tests.rs index 7c25a620..620388d2 100644 --- a/crates/engine/src/convert_container_tests.rs +++ b/crates/engine/src/convert_container_tests.rs @@ -1,5 +1,5 @@ -//! Frames and groups converted into each other in place: the layers stay where they are on the -//! page, what only a frame has goes with it, what can't convert is refused, one undo step. +//! Frames, groups and sections converted into each other in place: the layers stay where they are on +//! the page, what only a frame has goes with it, what can't convert is refused, one undo step. use serde_json::{Value, json}; @@ -136,7 +136,7 @@ fn what_cannot_convert_is_refused_and_nothing_changes() { #[test] fn junk_parameters_are_refused_not_fatal() { let junk = [json!(null), json!(true), json!(-1), json!(1e308), json!("x"), json!([[1]]), json!({"a": 1}), json!([0, 999_999, "7"])]; - for cmd in ["object.convertToGroup", "object.convertToFrame"] { + for cmd in ["object.convertToGroup", "object.convertToFrame", "object.convertToSection"] { for j in &junk { let mut s = Session::new(); let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50}))); @@ -148,3 +148,49 @@ fn junk_parameters_are_refused_not_fatal() { } } } + +#[test] +fn a_frame_on_the_page_becomes_a_section_and_back() { + let mut s = Session::new(); + let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 50, "width": 300, "height": 200, "name": "Flow", "props": {"fill": "#FFEEDD"}}))); + let (a, b) = (rect(&mut s, f, 20.0, 30.0), rect(&mut s, f, 400.0, 90.0)); + ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"})); + ex(&mut s, "node.set", json!({"id": f, "props": {"width": 300, "height": 200}})); + let before = [page_box(&s, a), page_box(&s, b), page_box(&s, f)]; + assert_eq!(ex(&mut s, "object.convertToSection", json!({"ids": [f]}))["ids"], json!([f])); + let n = s.doc().unwrap().node(f).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Section { contents_hidden: false })); + assert_eq!((n.name.as_str(), n.children.clone(), n.fills.len()), ("Flow", vec![a, b], 1), "same name, layers and fill"); + assert!(n.layout.is_none() && !n.clip, "no auto layout or clipping on a section"); + assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before, "nothing moves"); + // And back: a frame that keeps the fill and doesn't clip, so the look stays. + assert_eq!(ex(&mut s, "object.convertToFrame", json!({"ids": [f]}))["ids"], json!([f])); + let n = s.doc().unwrap().node(f).unwrap().clone(); + assert!(matches!(n.kind, NodeKind::Frame) && n.fills.len() == 1 && !n.clip); + assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before); + // Each conversion is one undo step. + ex(&mut s, "edit.undo", json!({})); + assert!(matches!(kind(&s, f), NodeKind::Section { .. })); + ex(&mut s, "edit.undo", json!({})); + assert!(matches!(kind(&s, f), NodeKind::Frame)); + assert!(s.doc().unwrap().node(f).unwrap().layout.is_some(), "the frame's auto layout is back"); +} + +#[test] +fn only_frames_on_the_page_or_in_a_section_become_sections() { + let mut s = Session::new(); + let outer = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 300}))); + let inner = id(ex(&mut s, "frame.create", json!({"parent": outer, "x": 10, "y": 10, "width": 100, "height": 100}))); + let err = s.execute("object.convertToSection", &json!({"ids": [inner]})).unwrap_err().to_string(); + assert!(err.contains("inside a frame"), "{err}"); + let r = rect(&mut s, outer, 150.0, 150.0); + assert!(s.execute("object.convertToSection", &json!({"ids": [r]})).is_err(), "a rectangle isn't a frame"); + // A frame inside a section converts; a section holding a section can't become a frame. + let sec = id(ex(&mut s, "node.create", json!({"type": "section", "x": 500, "y": 0, "width": 400, "height": 400}))); + let in_sec = id(ex(&mut s, "frame.create", json!({"parent": sec, "x": 20, "y": 20, "width": 100, "height": 100}))); + ex(&mut s, "object.convertToSection", json!({"ids": [in_sec]})); + assert!(matches!(kind(&s, in_sec), NodeKind::Section { .. })); + let err = s.execute("object.convertToFrame", &json!({"ids": [sec]})).unwrap_err().to_string(); + assert!(err.contains("holds sections"), "{err}"); + assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed"); +} diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..c9b6df62 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -201,6 +201,8 @@ name = "English" "Code or invite link" = "Code or invite link" "Color profile: {name}" = "Color profile: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Colour and text styles, Light and Dark variables, buttons, inputs and cards" +"Convert to frame" = "Convert to frame" +"Convert to section" = "Convert to section" "CSS custom properties…" = "CSS custom properties…" "Can edit" = "Can edit" "Can't read this library: {e}" = "Can't read this library: {e}" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..ff3e5a79 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -302,6 +302,8 @@ status = "draft — needs native review" "Copied as SVG" = "Copiado como SVG" "Copied as text" = "Copiado como texto" "Copied {value}" = "Copiado: {value}" +"Convert to frame" = "Convertir en marco" +"Convert to section" = "Convertir en sección" "Copy" = "Copiar" "Copy as" = "Copiar como" "Copy as CSS" = "Copiar como CSS" @@ -1839,6 +1841,7 @@ status = "draft — needs native review" "object.batchRename" = "Cambiar nombre en lote" "object.convertToFrame" = "Convertir en marco" "object.convertToGroup" = "Convertir en grupo" +"object.convertToSection" = "Convertir en sección" "object.flipH" = "Voltear horizontalmente" "object.flipV" = "Voltear verticalmente" "object.frameSelection" = "Enmarcar selección" diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d3299a..498dc8de 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -467,6 +467,22 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { .collect(); (!secs.is_empty()).then(|| if secs.iter().all(|h| *h) { tr("Show section contents") } else { tr("Hide section contents") }) }); + // Frames on the page (or in a section) convert to sections, and sections to frames. + let convert = app.session.doc().ok().and_then(|doc| { + let nodes: Vec<_> = ids.iter().filter_map(|i| doc.node(*i)).collect(); + let on_board = |n: &figcraft_doc::Node| { + n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, figcraft_doc::NodeKind::Section { .. })) + }; + if nodes.is_empty() { + None + } else if nodes.iter().all(|n| matches!(n.kind, figcraft_doc::NodeKind::Section { .. })) { + Some((tr("Convert to frame"), "object.convertToFrame")) + } else if nodes.iter().all(|n| matches!(n.kind, figcraft_doc::NodeKind::Frame) && on_board(n)) { + Some((tr("Convert to section"), "object.convertToSection")) + } else { + None + } + }); let mut entries: Vec<(&str, &str)> = if has_sel { vec![ (tr("Copy"), "edit.copy"), @@ -519,6 +535,11 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { { entries.insert(at + 1, (label, "section.toggleContents")); } + if let Some(entry) = convert + && let Some(at) = entries.iter().position(|e| e.1 == "object.wrapInSection") + { + entries.insert(at + 1, entry); + } // A right-clicked ruler guide (selected, with no layers) can be removed. if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) { entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]); diff --git a/docs/mcp.md b/docs/mcp.md index 4efee72a..64228ea7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -114,6 +114,8 @@ Or in a project's `.mcp.json`: columns (vertical layouts wrap top to bottom into new columns). - `object.convertToGroup` / `object.convertToFrame` turn a frame into a group or back in place (layers stay put; a group has no auto layout, fill, stroke, corner radius, clipping or grids). + - `object.convertToSection` turns a frame on the page or in a section into a section in place, and + `object.convertToFrame` turns a section back into a frame (keeping its fill, not clipping). - `contentAlign` (wrapping layouts): `auto` (default) packs the lines `crossGap` apart at the start, centre or end `crossAlign` names (lines of nothing but fill children stretch instead); `spaceBetween` puts the free space between the lines (one line, or no free space: like `auto`). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e15ddaed..7c89f239 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -628,6 +628,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | | 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | From fa4821bcea03a2a0eae40609c9ffcbda3383f132 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:27:11 +0700 Subject: [PATCH 15/33] #627: settle bound paints once per open, not also in the native loader DocState::new already settles every opened document (native files, branches and live joins included); the .fig/REST path keeps its settlement in link_instances for file.import into an open document. --- crates/engine/src/cmd/file.rs | 1 - crates/engine/src/variable_tests.rs | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f9175b9c..28124b7c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -520,7 +520,6 @@ fn load_with( if loaded.doc.name.is_empty() { loaded.doc.name = file_stem(name_hint); } - super::styles::apply_paint_bindings_on_load(&mut loaded.doc); Ok((loaded, false)) } } diff --git a/crates/engine/src/variable_tests.rs b/crates/engine/src/variable_tests.rs index f5808b2c..dcdd04e3 100644 --- a/crates/engine/src/variable_tests.rs +++ b/crates/engine/src/variable_tests.rs @@ -53,7 +53,10 @@ fn initial_color_bindings_settle_in_constructor_and_loaders() { let native = figcraft_format::save_bytes(&stale, None).unwrap(); let (loaded, _) = crate::cmd::file::load_bytes(&native, "bindings.figcraft").unwrap(); - let loaded_id = assert_dark_bound_color(&loaded, name_transform); + // 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")); From 6e15e30790acc1b8d062b31fb4ce9168eba09207 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:27:12 +0700 Subject: [PATCH 16/33] #627: remove the dead network::decode shim --- crates/format/src/fig/network.rs | 9 ++------- crates/format/src/fig/tail_tests.rs | 12 ++++++------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index d251dff1..92ad4898 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -64,14 +64,9 @@ impl Reader<'_> { } } -/// The network in blob `b`; `None` when it's truncated, out of range or not finite. -#[allow(dead_code, reason = "for readers that don't need the count of styled segments")] -pub(super) fn decode(b: &[u8]) -> Option { - decode_styled(b).map(|(net, _)| net) -} - /// 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` as for `decode`. +/// 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); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 5d34f23b..19618044 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -355,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 { @@ -366,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())); @@ -375,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); } } From b4d58a362e3350fd7416d8dc528d70dc6e372007 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:27:13 +0700 Subject: [PATCH 17/33] #627: leave the progress docs to the maintainer --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e56eda19..d8e51711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, per-segment stroke styles, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From 69806c9edd85d8fac2a76eda32c75bc6ede262d6 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 17 Oct 2026 13:35:00 +0700 Subject: [PATCH 18/33] Plugins: refuse file and network access through path params and collab (#723) Plugins can no longer reach the disk or network through path parameters (variable.export/codeConnect.export/component.playground writes, codeConnect.import/variable.import reads) or through collab.* and window.*; found and fixed by @lucasmartins-ai, split out of #696. --- crates/engine/src/plugins.rs | 15 +++++++++++++++ crates/engine/src/plugins_tests.rs | 7 +++++++ docs/plugins.md | 6 +++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index f1c4075e..ce5aa0c6 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -111,6 +111,10 @@ const DENIED: &[&str] = &[ "edit.redo", "edit.repeat", "command.batch", + // Network sessions, online files and their recovery copies on disk. + "collab.", + // Opens files in new windows. + "window.", "view.theme", ]; /// Read-only exceptions to [`DENIED`]. @@ -128,6 +132,17 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { if command == "image.place" && get(params, "path").is_some() { return Some("plugins can't read files: pass the image as `data`".into()); } + // `path` here is a file on disk (elsewhere it is a vector path or an action's place). + if matches!(command, "variable.export" | "codeConnect.export" | "component.playground") && get(params, "path").is_some() { + return Some("plugins can't write files: leave out `path` to get the result back".into()); + } + if command == "codeConnect.import" && get(params, "path").is_some() { + return Some("plugins can't read files: pass the connections as `json`".into()); + } + // Variables JSON may be given as a path: only objects and JSON text for plugins. + if command == "variable.import" && get(params, "variables").and_then(Value::as_str).is_some_and(|t| !t.trim_start().starts_with('{')) { + return Some("plugins can't read files: pass the variables JSON itself".into()); + } if !m.permissions.allows(command) { return Some(format!("`{command}` isn't in the commands {} asked permission for", m.name)); } diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 102d675a..657b6825 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -169,6 +169,13 @@ fn files_dialogs_history_and_plugins_are_off_limits() { ("plugin.remove", json!({"id": "org.test.deny"})), ("edit.undo", json!({})), ("command.batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": target}}]})), + ("variable.export", json!({"format": "css", "path": target})), + ("codeConnect.export", json!({"path": target})), + ("codeConnect.import", json!({"path": "/etc/passwd"})), + ("component.playground", json!({"id": a, "path": target})), + ("variable.import", json!({"variables": "/etc/passwd"})), + ("collab.recoverFile", json!({"path": "copy.figcraft", "file": "x", "session": 1, "client": 1, "seq": 0})), + ("window.new", json!({"path": "C:/Windows/win.ini"})), ("doc.switch", json!({"index": 0})), ("pref.keepTool", json!({})), // Without a name it would open inline renaming in the layers panel: a UI request. diff --git a/docs/plugins.md b/docs/plugins.md index 6f23e6ec..5867bd25 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -237,7 +237,11 @@ Whatever its manifest says, FigCraft refuses these calls (the plugin gets the re it can handle): - files and the app: `file.*`, `app.*`, `pref.*`, `help.*`, `library.add`, `libraries.*`, - `image.place` with a `path` (pass the image as base64 `data` instead), `view.theme*`; + `image.place` with a `path` (pass the image as base64 `data` instead), `variable.export`, + `codeConnect.export` and `component.playground` with a `path` (leave it out to get the result + back), `codeConnect.import` with a `path` and `variable.import` with a file path instead of JSON, + `window.*` (new windows open files), `view.theme*`; +- live sessions and online files: `collab.*`; - other documents and the version history: `doc.*` and `version.*` (except `doc.list` and `version.list`); - the undo history and nesting: `edit.undo`, `edit.redo`, `edit.repeat`, `command.batch` (a run is From 0ef8966844fd5b174b23f0e66de6b4af13cd799f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:40:30 +0700 Subject: [PATCH 19/33] #639: drop the ignored test that needs a fixture outside the repo --- .../format/src/fig/connector_label_tests.rs | 68 +------------------ 1 file changed, 1 insertion(+), 67 deletions(-) diff --git a/crates/format/src/fig/connector_label_tests.rs b/crates/format/src/fig/connector_label_tests.rs index 56cea368..2aa6d893 100644 --- a/crates/format/src/fig/connector_label_tests.rs +++ b/crates/format/src/fig/connector_label_tests.rs @@ -1,4 +1,4 @@ -use super::{Archive, decode_canvas, kiwi::Kv, map}; +use super::{kiwi::Kv, map}; fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { Kv::Object(fields) @@ -202,10 +202,6 @@ fn synthetic_connector_authored_hidden_background_is_retained() { assert_eq!(background["fills"].as_array().unwrap().len(), 1); } -fn connector<'a>(layers: &'a [serde_json::Value], name: &str) -> &'a serde_json::Value { - layers.iter().find(|layer| layer.get("name").and_then(serde_json::Value::as_str) == Some(name)).expect("connector in fixture") -} - fn field_mut<'a, 's>(value: &'a mut Kv<'s>, key: &str) -> Option<&'a mut Kv<'s>> { let Kv::Object(fields) = value else { return None }; fields.iter_mut().find(|(name, _)| *name == key).map(|(_, value)| value) @@ -244,65 +240,3 @@ fn set_override_field(message: &mut Kv<'_>, field: &str, local_id: u64, key: &'s fields.push((key, value)); } } - -#[test] -#[ignore = "set FIGCRAFT_CONNECTOR_LABEL_FIXTURE to the retained synthetic source-initial.zip"] -fn synthetic_export_keeps_saved_vector_gap_and_composed_label_position() { - let path = std::env::var_os("FIGCRAFT_CONNECTOR_LABEL_FIXTURE").expect("fixture path is required when running this test"); - let bytes = std::fs::read(path).expect("read synthetic connector export"); - let archive = Archive::open(&bytes).expect("open synthetic connector export"); - let (schema, raw_message) = decode_canvas(&archive.canvas).expect("decode synthetic canvas"); - let root = schema.find("Message").expect("Message definition"); - let message = schema.decode_message(root, &raw_message).expect("decode synthetic message"); - let (rest, warnings) = map::to_rest(&message, "connector labels"); - let layers = rest.pointer("/document/children/0/children").and_then(serde_json::Value::as_array).expect("page layers"); - - let plain = connector(layers, "Connector line"); - assert_eq!(plain["type"], "VECTOR", "an unlabelled connector stays one vector"); - assert_eq!(plain["strokeCapStart"], "ROUND"); - assert_eq!(plain["strokeCapEnd"], "LINE_ARROW"); - assert!(plain.get("children").is_none()); - - let alpha = connector(layers, "STRAIGHT alpha"); - let alpha_children = alpha["children"].as_array().expect("snapshot children"); - let alpha_vector = alpha_children.iter().find(|child| child["type"] == "VECTOR").expect("saved connector vector"); - let segments = alpha_vector.pointer("/vectorNetwork/segments").and_then(serde_json::Value::as_array).expect("editable vector network"); - assert_eq!(segments.len(), 4, "network preserves the label gap as two disconnected runs"); - assert_eq!(alpha_children.iter().filter(|child| child["type"] == "TEXT").count(), 1); - let alpha_text = alpha_children.iter().find(|child| child["type"] == "TEXT").expect("label text"); - assert_eq!(alpha_text["characters"], "STRAIGHT alpha"); - assert_eq!(alpha_text["relativeTransform"], serde_json::json!([[1.0, 0.0, 201.5], [0.0, 1.0, 44.0]])); - assert!(warnings.iter().all(|warning| !warning.contains("connector labels were skipped"))); - - let mut malformed = message.clone(); - let alpha_node = connector_mut(&mut malformed, 127).expect("alpha node in decoded message"); - let derived = field_mut(alpha_node, "derivedImmutableFrameData").expect("derived frame data"); - let overrides = field_mut(derived, "overrides").expect("derived overrides"); - let Kv::Array(overrides) = overrides else { panic!("derived overrides array") }; - let text = overrides - .iter_mut() - .find(|row| { - row.get("guidPath") - .and_then(|path| path.get("guids")) - .and_then(|ids| ids.list().first()) - .and_then(|id| id.get("localID")) - .and_then(Kv::f64) - == Some(1.0) - }) - .expect("text template record"); - let transform = field_mut(text, "transform").expect("text transform"); - *transform = Kv::Object(vec![ - ("m00", Kv::Float(1.0)), - ("m01", Kv::Float(0.0)), - ("m02", Kv::Float(f32::NAN)), - ("m10", Kv::Float(0.0)), - ("m11", Kv::Float(1.0)), - ("m12", Kv::Float(0.0)), - ]); - let (malformed_rest, malformed_warnings) = map::to_rest(&malformed, "connector labels"); - let malformed_layers = malformed_rest.pointer("/document/children/0/children").and_then(serde_json::Value::as_array).expect("page layers"); - let malformed_alpha = connector(malformed_layers, "STRAIGHT alpha"); - assert_eq!(malformed_alpha["children"].as_array().map(Vec::len), Some(1)); - assert_eq!(malformed_alpha["children"][0]["type"], "VECTOR"); - assert!(malformed_warnings.iter().any(|warning| warning == "connector label was skipped: text transform is invalid")); -} From ee3e3f0bd423c3fc713f8e2a4672124313332931 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:44:34 +0700 Subject: [PATCH 20/33] #637: skip ink only counts under an underline The public docs don't give a default for skip ink; they say the value is unset when the text isn't underlined, and the exporter always wrote it on. So the export keeps writing on for text without an underline and the real value under one (that's what the canvas draws), and the import leaves a value on text without an underline out of the decoration look, so plain text keeps the default look and round-trips unchanged. The native default stays off, so existing documents draw as before. --- crates/format/src/fig/export.rs | 7 ++- crates/format/src/fig/interop_tests.rs | 3 +- crates/format/src/fig/text_skip_ink_tests.rs | 45 +++++++++++++++++--- crates/format/src/rest/text.rs | 7 +++ 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index dd3f2e88..475f8ceb 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -2392,13 +2392,14 @@ fn key_codes(key: &str) -> Option<(Vec, bool)> { Some((codes, false)) } -/// The text style fields of a layer or of an override table entry. +/// Whether the decoration look has more than skip ink, which the file can't hold. fn unsupported_decoration_look(st: &TextStyle) -> bool { let mut look = st.look(); look.skip_ink = false; look != Default::default() } +/// The text style fields of a layer or of an override table entry. fn style_fields(st: &TextStyle, f: &mut Fields) { let line_height = match st.line_height { // Percent units count from the font's own line height (100 % is "auto"); a share of the @@ -2430,7 +2431,9 @@ fn style_fields(st: &TextStyle, f: &mut Fields) { ("letterSpacing", letter_spacing), ("textCase", e(case)), ("textDecoration", e(decoration)), - ("textDecorationSkipInk", b(st.look().skip_ink)), + // Skip ink only shows on an underline; other text gets the files' default (on), the value + // the import leaves out again. + ("textDecorationSkipInk", b(st.look().skip_ink || st.decoration != TextDecoration::Underline)), ]); if let Some(url) = &st.link { f.push(("hyperlink", o(vec![("url", s(url))]))); diff --git a/crates/format/src/fig/interop_tests.rs b/crates/format/src/fig/interop_tests.rs index 56391975..b51760c5 100644 --- a/crates/format/src/fig/interop_tests.rs +++ b/crates/format/src/fig/interop_tests.rs @@ -187,8 +187,7 @@ fn text_carries_its_layout_settings_and_a_line_per_paragraph() { with_changes(&sample(), |of, _| { let t = of("TEXT"); assert!(!flag(&t, "autoRename")); - assert!(flag(&t, "fontVariantCommonLigatures") && flag(&t, "fontVariantContextualLigatures")); - assert!(!flag(&t, "textDecorationSkipInk"), "default native text exports its actual false value"); + assert!(flag(&t, "fontVariantCommonLigatures") && flag(&t, "fontVariantContextualLigatures") && flag(&t, "textDecorationSkipInk")); assert_eq!(name(&t, "fontVersion"), ""); assert_eq!((num(&t, "textUserLayoutVersion"), num(&t, "textExplicitLayoutVersion"), num(&t, "textBidiVersion")), (5.0, 1.0, 1.0)); assert_eq!(num(&t, "textTracking"), 0.0); diff --git a/crates/format/src/fig/text_skip_ink_tests.rs b/crates/format/src/fig/text_skip_ink_tests.rs index 8d1cd295..2a1ff61d 100644 --- a/crates/format/src/fig/text_skip_ink_tests.rs +++ b/crates/format/src/fig/text_skip_ink_tests.rs @@ -1,8 +1,9 @@ -//! `.fig` text skip-ink values map, inherit and round-trip as native decoration look state. +//! `.fig` text skip-ink values map, inherit and round-trip as native decoration look state. Skip +//! ink only shows on an underline, so the fixtures are underlined. use super::kiwi::Kv; use super::{export, import}; -use figcraft_doc::{Document, NodeKind, StyleKind, StyleRun, TextData, TextStyle}; +use figcraft_doc::{Document, NodeKind, StyleKind, StyleRun, TextData, TextDecoration, TextStyle}; use serde_json::Value; fn object(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { @@ -30,6 +31,7 @@ fn raw(base: Option>, runs: Vec>, chars: &str, ids: Vec< let mut layer = vec![ ("fontName", font()), ("fontSize", Kv::Float(20.0)), + ("textDecoration", Kv::Enum("UNDERLINE")), ( "textData", object(vec![ @@ -44,7 +46,8 @@ fn raw(base: Option>, runs: Vec>, chars: &str, ids: Vec< } let mut nodes = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; if let Some(shared) = shared { - let mut fields = vec![("styleType", Kv::Enum("TEXT")), ("fontName", font()), ("fontSize", Kv::Float(20.0))]; + let mut fields = + vec![("styleType", Kv::Enum("TEXT")), ("fontName", font()), ("fontSize", Kv::Float(20.0)), ("textDecoration", Kv::Enum("UNDERLINE"))]; if let Some(value) = shared { fields.push(("textDecorationSkipInk", value)); } @@ -125,6 +128,7 @@ fn instance_path_override_preserves_explicit_false_over_true_main() { vec![ ("fontName", font()), ("fontSize", Kv::Float(20.0)), + ("textDecoration", Kv::Enum("UNDERLINE")), ("textDecorationSkipInk", Kv::Bool(true)), ("textData", object(vec![("characters", Kv::Str("A".into()))])), ], @@ -172,6 +176,7 @@ fn instance_path_override_preserves_explicit_false_over_true_main() { fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only_warning() { let mut doc = Document::new("Skip ink export"); let mut text = TextData::new("abcd"); + text.style.decoration = TextDecoration::Underline; text.style.set_look(|look| look.skip_ink = false); let mut skipped = text.style.clone(); skipped.set_look(|look| look.skip_ink = true); @@ -209,9 +214,9 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only let (_, warnings) = export(&variable_doc, None).unwrap(); assert!(warnings.iter().any(|w| w.contains("variable font settings")), "variation warning missing: {warnings:?}"); - let mut shared = TextStyle::default(); + let mut shared = TextStyle { decoration: TextDecoration::Underline, ..TextStyle::default() }; shared.set_look(|look| look.skip_ink = true); - let mut local = TextStyle::default(); + let mut local = TextStyle { decoration: TextDecoration::Underline, ..TextStyle::default() }; local.set_look(|look| look.skip_ink = false); let mut linked_doc = Document::new("Shared skip ink"); linked_doc.styles.push(figcraft_doc::Style { @@ -235,3 +240,33 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only let StyleKind::Text { style } = &linked.kind else { panic!("shared style link must remain text") }; assert!(style.look().skip_ink, "local false must not alter the shared definition"); } + +#[test] +fn skip_ink_without_an_underline_stays_out_of_the_look_and_exports_the_default() { + // Plain text with the files' default value imports with the default look... + let mut value = raw(Some(Kv::Bool(true)), vec![], "ab", vec![0, 0], None); + value["document"]["children"][0]["children"][0]["style"].as_object_mut().unwrap().remove("textDecoration"); + let doc = imported(&value); + assert_eq!(label(&doc).style.decoration, TextDecoration::None); + assert!(label(&doc).style.decoration_look.is_none()); + + // ...an underlined run of it keeps the layer's value... + let mut value = + raw(Some(Kv::Bool(true)), vec![object(vec![("styleID", Kv::Uint(1)), ("textDecoration", Kv::Enum("UNDERLINE"))])], "ab", vec![1, 0], None); + value["document"]["children"][0]["children"][0]["style"].as_object_mut().unwrap().remove("textDecoration"); + let doc = imported(&value); + assert_eq!((0..2).map(|i| skip_at(label(&doc), i)).collect::>(), vec![true, false]); + + // ...and default native text writes the default, which reads back as the default look. + let mut doc = Document::new("Plain"); + doc.create(doc.first_page().unwrap(), NodeKind::Text(TextData::new("x")), "Label", 0.0, 0.0, 100.0, 30.0).unwrap(); + let (bytes, _) = export(&doc, None).unwrap(); + let archive = super::Archive::open(&bytes).unwrap(); + let (schema, body) = super::decode_canvas(&archive.canvas).unwrap(); + let message = schema.decode_message(schema.find("Message").unwrap(), &body).unwrap(); + let value = super::debug_json(&message); + let layer = value["nodeChanges"].as_array().unwrap().iter().find(|n| n["name"] == "Label").unwrap(); + assert_eq!(layer["textDecorationSkipInk"], true); + let (back, _) = import(&bytes, "Plain").unwrap(); + assert!(label(&back).style.decoration_look.is_none()); +} diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 3875204f..f607c292 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -27,6 +27,13 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData { let mut td = TextData { characters, style: base, ..TextData::default() }; paragraph(style_json, &mut td); td.runs = runs(cx, v, &td); + // Skip ink only shows on an underline. A file can carry it on any text, so elsewhere it stays out + // of the look (runs have inherited the base value by now). + for st in std::iter::once(&mut td.style).chain(td.runs.iter_mut().map(|r| &mut r.style)) { + if st.decoration != TextDecoration::Underline && st.look().skip_ink { + st.set_look(|l| l.skip_ink = false); + } + } td.normalize(); lists(v, &mut td); td From d9e79d604b8d34fbea7eff367e90afdc67868c51 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:34:42 +0200 Subject: [PATCH 21/33] M1: distinguish unresolved vector segment style metadata in .fig import The vector network decoder now keeps each segment's style id. Import reports a segment style that names a row of the layer's style table as metadata that was not interpreted, and one that names no row as a reference to a missing style entry, each at most once per file. The earlier note claimed that stroke styles were left out, which overstated what is known about the metadata. Segment geometry, paint and width are unchanged, and the style ids are not kept in the native vector model. --- crates/format/src/fig/export.rs | 8 +++- crates/format/src/fig/map.rs | 25 ++++++++--- crates/format/src/fig/network.rs | 11 ++--- crates/format/src/fig/tail_tests.rs | 64 ++++++++++++++++++++++------- 4 files changed, 83 insertions(+), 25 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index bfd0b615..7443c8de 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1669,7 +1669,13 @@ impl<'d> Exporter<'d> { let segments = net .segments .iter() - .map(|sg| network::Segment { a: sg.a, ta: (finite(sg.ta.0), finite(sg.ta.1)), b: sg.b, tb: (finite(sg.tb.0), finite(sg.tb.1)) }) + .map(|sg| network::Segment { + style: 0, + a: sg.a, + ta: (finite(sg.ta.0), finite(sg.ta.1)), + b: sg.b, + tb: (finite(sg.tb.0), finite(sg.tb.1)), + }) .collect(); let regions = net .regions diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..36f45f31 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1192,12 +1192,29 @@ 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, styled_segments)) = network::decode_styled(blob) else { + let Some((net, _)) = 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"); + let styles: BTreeMap = + vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); + let mut has_uninterpreted = false; + let mut has_missing = false; + for segment in &net.segments { + if segment.style == 0 { + continue; + } + if styles.contains_key(&u64::from(segment.style)) { + has_uninterpreted = true; + } else { + has_missing = true; + } + } + if has_uninterpreted { + self.warn("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + } + if has_missing { + self.warn("some vector segments refer to style settings missing from the file; they use the layer's stroke"); } // An open path shows its fill, which regions can't hold: the outline keeps the look. let fills_shown = @@ -1205,8 +1222,6 @@ impl<'a> Mapper<'a> { if net.segments.is_empty() || (net.regions.is_empty() && fills_shown && o.contains_key("fillGeometry")) { return false; } - let styles: BTreeMap = - vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect(); let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0); let scale = |k: &str| { let size = v.size().map(|s| if k == "x" { s.0 } else { s.1 }); diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index d251dff1..4e8c3ad7 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -17,6 +17,7 @@ pub(super) struct Vertex { } pub(super) struct Segment { + pub style: u32, pub a: usize, pub ta: (f64, f64), pub b: usize, @@ -70,8 +71,7 @@ pub(super) fn decode(b: &[u8]) -> Option { decode_styled(b).map(|(net, _)| net) } -/// 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` as for `decode`. +/// The network in blob `b` and how many of its segments have a nonzero style id; `None` as for `decode`. 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); @@ -84,10 +84,11 @@ pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { } let mut styled = 0usize; for _ in 0..ns { - if r.u32()? != 0 { + let style = r.u32()?; + if style != 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()?) }); + net.segments.push(Segment { style, a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); } let mut entries = 0usize; for _ in 0..nr { @@ -120,7 +121,7 @@ pub(super) fn encode(net: &Network) -> Vec { fl(v.y, &mut out); } for s in &net.segments { - u(0, &mut out); + u(s.style, &mut out); u(s.a as u32, &mut out); fl(s.ta.0, &mut out); fl(s.ta.1, &mut out); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 5d34f23b..bc4f31cc 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -9,7 +9,7 @@ use super::super::network::{self, Network, Region, Segment, Vertex}; use super::*; fn seg(a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment { - Segment { a, ta, b, tb } + Segment { style: 0, a, ta, b, tb } } fn vtx(style: u32, x: f64, y: f64) -> Vertex { @@ -264,21 +264,33 @@ fn vectors_import_as_editable_networks() { #[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)); + let styled_import = |segment_style: u32| { + 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(&segment_style.to_le_bytes()); + let (net, styled) = network::decode_styled(&blob).unwrap(); + assert_eq!((net.segments.len(), net.segments[0].style, styled), (4, segment_style, 1)); + 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_eq!(network_of(&doc, "Wave").segments.len(), 4); + warnings + }; 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); + assert!(!plain.iter().any(|w| w.contains("vector segments")), "{plain:?}"); + let uninterpreted = |w: &String| w.contains("style settings of some vector segments aren't supported; those segments use the layer's stroke"); + let missing = |w: &String| w.contains("some vector segments refer to style settings missing from the file; they use the layer's stroke"); + // Style 1 has a row in the layer's override table; its contents are not applied. + let known = styled_import(1); + assert_eq!(known.iter().filter(|w| uninterpreted(w)).count(), 1, "{known:?}"); + assert!(!known.iter().any(missing), "{known:?}"); + // Style 7 names no row at all. + let unknown = styled_import(7); + assert_eq!(unknown.iter().filter(|w| missing(w)).count(), 1, "{unknown:?}"); + assert!(!unknown.iter().any(uninterpreted), "{unknown:?}"); } #[test] @@ -385,6 +397,30 @@ fn damaged_network_blobs_never_panic() { } } +#[test] +fn segment_styles_round_trip_without_changing_geometry() { + let original = Network { + vertices: vec![vtx(0, 2.0, 3.0), vtx(0, 10.0, 20.0), vtx(0, 40.0, 50.0), vtx(0, 60.0, 70.0)], + segments: vec![ + seg(0, 1, (1.0, 2.0), (3.0, 4.0)), + Segment { style: 17, a: 1, ta: (5.0, 6.0), b: 2, tb: (7.0, 8.0) }, + Segment { style: 27, a: 2, ta: (9.0, 10.0), b: 3, tb: (11.0, 12.0) }, + ], + regions: vec![], + }; + let bytes = network::encode(&original); + let (decoded, styled) = network::decode_styled(&bytes).unwrap(); + assert_eq!(styled, 2); + assert_eq!(decoded.segments.len(), 3); + for (got, want) in decoded.segments.iter().zip(&original.segments) { + assert_eq!(got.style, want.style); + assert_eq!((got.a, got.ta, got.b, got.tb), (want.a, want.ta, want.b, want.tb)); + } + for cut in 0..bytes.len() { + assert!(network::decode_styled(&bytes[..cut]).is_none(), "truncated at {cut}"); + } +} + #[test] fn mangled_tail_files_never_panic() { let c = tail_canvas(); From c605bd5781157431b756768e58add61a200d9fb6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:49:46 +0700 Subject: [PATCH 22/33] #631: drop the styled-segment count nobody reads --- crates/format/src/fig/map.rs | 2 +- crates/format/src/fig/network.rs | 12 +++--------- crates/format/src/fig/tail_tests.rs | 23 +++++++++++------------ 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 36f45f31..94678e25 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1192,7 +1192,7 @@ 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_styled(blob) else { + let Some(net) = network::decode(blob) else { self.warn("damaged vector networks import as outlines"); return false; }; diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index 3adb0abf..ccb5c291 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -65,10 +65,8 @@ impl Reader<'_> { } } -/// 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)> { +/// The network in blob `b`; `None` when it's truncated, out of range or not finite. +pub(super) fn decode(b: &[u8]) -> Option { 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) { @@ -78,12 +76,8 @@ pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { 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 { let style = r.u32()?; - if style != 0 { - styled += 1; - } net.segments.push(Segment { style, a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); } let mut entries = 0usize; @@ -100,7 +94,7 @@ pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> { } net.regions.push(Region { style: sw >> 1, non_zero: sw & 1 == 1, loops }); } - Some((net, styled)) + Some(net) } /// The blob of `net` (the export writes it; so do test fixtures). Callers pass finite values. diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 363d2bdf..a15334a5 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -268,8 +268,8 @@ 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(&segment_style.to_le_bytes()); - let (net, styled) = network::decode_styled(&blob).unwrap(); - assert_eq!((net.segments.len(), net.segments[0].style, styled), (4, segment_style, 1)); + let net = network::decode(&blob).unwrap(); + assert_eq!((net.segments.len(), net.segments[0].style), (4, segment_style)); 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!() }; @@ -278,7 +278,7 @@ fn per_segment_stroke_styles_are_reported() { assert_eq!(network_of(&doc, "Wave").segments.len(), 4); warnings }; - assert_eq!(network::decode_styled(&square_blob()).unwrap().1, 0); + assert!(network::decode(&square_blob()).unwrap().segments.iter().all(|s| s.style == 0)); let plain = import(&tail_canvas(), "Tail").unwrap().1; assert!(!plain.iter().any(|w| w.contains("vector segments")), "{plain:?}"); let uninterpreted = |w: &String| w.contains("style settings of some vector segments aren't supported; those segments use the layer's stroke"); @@ -367,9 +367,9 @@ fn unreadable_conditions_skip_the_conditional() { #[test] fn damaged_network_blobs_never_panic() { let good = square_blob(); - assert!(network::decode_styled(&good).is_some()); + assert!(network::decode(&good).is_some()); for cut in 0..good.len() { - assert!(network::decode_styled(&good[..cut]).is_none(), "{cut}"); + assert!(network::decode(&good[..cut]).is_none(), "{cut}"); } let mut r = Lcg(5); for _ in 0..5000 { @@ -378,7 +378,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_styled(&m) { + if let Some(net) = network::decode(&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())); @@ -387,13 +387,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_styled(&huge).is_none()); + assert!(network::decode(&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_styled(&bad).is_none()); + assert!(network::decode(&bad).is_none()); for _ in 0..2000 { let junk: Vec = (0..(r.next() % 120)).map(|_| r.next() as u8).collect(); - let _ = network::decode_styled(&junk); + let _ = network::decode(&junk); } } @@ -409,15 +409,14 @@ fn segment_styles_round_trip_without_changing_geometry() { regions: vec![], }; let bytes = network::encode(&original); - let (decoded, styled) = network::decode_styled(&bytes).unwrap(); - assert_eq!(styled, 2); + let decoded = network::decode(&bytes).unwrap(); assert_eq!(decoded.segments.len(), 3); for (got, want) in decoded.segments.iter().zip(&original.segments) { assert_eq!(got.style, want.style); assert_eq!((got.a, got.ta, got.b, got.tb), (want.a, want.ta, want.b, want.tb)); } for cut in 0..bytes.len() { - assert!(network::decode_styled(&bytes[..cut]).is_none(), "truncated at {cut}"); + assert!(network::decode(&bytes[..cut]).is_none(), "truncated at {cut}"); } } From 6233b90ba7684fc58af66804176c3e54dfa954fe Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:36:24 +0200 Subject: [PATCH 23/33] M1: import editable text on a path from .fig files Supported TEXT_PATH layers import as editable native text on their vector path, including mixed text styles, a start inside a later segment and reversed text direction. The importer converts the segment parameter stored in the file to the native distance along the complete path. The mapping accepts a connected, unbranched path with one recognized start-segment marker and the default placement above the path. Damaged or unsupported path data keeps the text readable off its path and adds an import note. Other vertical placements, multiline text and ambiguous or disconnected networks are not converted. Underline and strikethrough remain a native path-rendering limitation and are reported. Public REST TEXT_PATH layers and the experimental .fig writer are unchanged. --- crates/format/src/fig/map.rs | 95 ++++++- crates/format/src/fig/mod.rs | 3 + crates/format/src/fig/text_path.rs | 315 +++++++++++++++++++++++ crates/format/src/fig/text_path_tests.rs | 211 +++++++++++++++ crates/format/src/rest/text.rs | 12 +- docs/gaps.md | 4 + 6 files changed, 637 insertions(+), 3 deletions(-) create mode 100644 crates/format/src/fig/text_path.rs create mode 100644 crates/format/src/fig/text_path_tests.rs diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 94678e25..9a2b696d 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -759,7 +759,7 @@ impl<'a> Mapper<'a> { "VARIABLE" | "VARIABLE_SET" => return None, t => stand.map_or(t, |s| s.0), }; - if let Some((_, what)) = stand { + if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") { self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); } self.count += 1; @@ -774,7 +774,12 @@ impl<'a> Mapper<'a> { copy_str(&mut o, &view, "name", "name"); self.common(&mut o, &view, ty, space, parent); match ty { - "TEXT" => self.text(&mut o, &view), + "TEXT" => { + self.text(&mut o, &view); + if own == "TEXT_PATH" { + self.text_path(&mut o, &view); + } + } "VECTOR" | "STAR" | "REGULAR_POLYGON" => self.geometry(&mut o, &view), "ELLIPSE" => { if let Some(a) = view.get("arcData") { @@ -1155,6 +1160,92 @@ impl<'a> Mapper<'a> { } } + /// Carry supported text-path data through the private .fig REST ingress only. + fn text_path(&mut self, o: &mut Obj, v: &View<'a>) { + let fail = |this: &mut Self, reason: &str| { + this.warn(format!("text path {reason}; text imports off its path")); + }; + let Some(start) = v.get("textPathStart") else { + fail(self, "has no start data"); + return; + }; + let (Some(t), Some(forward)) = (num(start, "tValue"), start.get("forward").and_then(Kv::bool)) else { + fail(self, "has invalid start data"); + return; + }; + let Some(vd) = v.get("vectorData") else { + fail(self, "has no vector data"); + return; + }; + let Some(blob) = num(vd, "vectorNetworkBlob") + .filter(|i| i.is_finite() && *i >= 0.0 && i.fract() == 0.0) + .and_then(|i| usize::try_from(i as u64).ok()) + .and_then(|i| self.blobs.get(i)) + .copied() + else { + fail(self, "has no vector network"); + return; + }; + let Some((net, _)) = network::decode_styled(blob) else { + fail(self, "has damaged vector data"); + return; + }; + let Some(norm) = vd.get("normalizedSize") else { + fail(self, "has invalid normalized size"); + return; + }; + let Some(size) = v.get("size") else { + fail(self, "has invalid dimensions"); + return; + }; + let (Some(nx), Some(ny), Some(w), Some(h)) = (num(norm, "x"), num(norm, "y"), num(size, "x"), num(size, "y")) else { + fail(self, "has invalid dimensions"); + return; + }; + let axis_scale = |size: f64, normalized: f64, values: &[f64]| -> Option { + if !size.is_finite() || size < 0.0 || !normalized.is_finite() || normalized < 0.0 { + return None; + } + if normalized > 1e-9 { + if size <= 0.0 { + return None; + } + return Some(size / normalized); + } + (size == 0.0 && values.iter().all(|v| *v == 0.0)).then_some(1.0) + }; + let xs: Vec = net.vertices.iter().map(|p| p.x).chain(net.segments.iter().flat_map(|s| [s.ta.0, s.tb.0])).collect(); + let ys: Vec = net.vertices.iter().map(|p| p.y).chain(net.segments.iter().flat_map(|s| [s.ta.1, s.tb.1])).collect(); + let sx = axis_scale(w, nx, &xs); + let sy = axis_scale(h, ny, &ys); + let (Some(sx), Some(sy)) = (sx, sy) else { + fail(self, "has invalid path scale"); + return; + }; + if v.str("textAlignVertical").is_some_and(|x| x != "TOP") { + fail(self, "uses unsupported vertical placement"); + return; + } + if v.get("textData").and_then(|td| td.get("characters")).and_then(Kv::str).is_some_and(|s| s.chars().any(|c| matches!(c, '\n' | '\r'))) { + fail(self, "contains multiple lines"); + return; + } + let path = match super::text_path::from_network(&net, sx, sy, t, forward) { + Ok(path) => path, + Err(reason) => { + fail(self, reason); + return; + } + }; + let has_decoration = |style: &Value| style.get("textDecoration").and_then(Value::as_str).is_some_and(|x| x != "NONE"); + let decorated = o.get("style").is_some_and(has_decoration) + || o.get("styleOverrideTable").and_then(Value::as_object).into_iter().flat_map(|styles| styles.values()).any(has_decoration); + if decorated { + self.warn("text path underline or strikethrough decoration is not rendered"); + } + o.insert("_figTextPath".into(), json!({"d": path.d, "start": path.start, "flip": path.flip})); + } + /// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters. fn geometry(&mut self, o: &mut Obj, v: &View<'a>) { for key in ["fillGeometry", "strokeGeometry"] { diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 81457f8d..2ad6e276 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -24,6 +24,9 @@ mod network; mod source_identity_tests; #[cfg(test)] mod tests; +mod text_path; +#[cfg(test)] +mod text_path_tests; #[cfg(test)] mod text_style_tests; diff --git a/crates/format/src/fig/text_path.rs b/crates/format/src/fig/text_path.rs new file mode 100644 index 00000000..8a93f7e4 --- /dev/null +++ b/crates/format/src/fig/text_path.rs @@ -0,0 +1,315 @@ +//! Convert the simple vector network used by text-on-path layers in `.fig` files into a native text path. + +use figcraft_doc::{ + TextPath, + geom::{ + BezPath, + kurbo::{CubicBez, Line, ParamCurve, ParamCurveArclen, Point}, + }, +}; + +use super::network::{Network, Segment}; + +const START_SEGMENT_STYLE: u32 = 0x01ff_ffff; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Use { + segment: usize, + forward: bool, +} + +/// Build a native path from a validated connected chain or cycle. The marked source segment is +/// oriented a→b; `t` is local to that line/cubic. Coordinates are scaled into layer space. +pub(super) fn from_network(net: &Network, sx: f64, sy: f64, t: f64, forward: bool) -> Result { + if !t.is_finite() || !(0.0..=1.0).contains(&t) { + return Err("invalid start parameter"); + } + if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 { + return Err("invalid path scale"); + } + let markers: Vec = net.segments.iter().enumerate().filter_map(|(i, s)| (s.style == START_SEGMENT_STYLE).then_some(i)).collect(); + let [marked] = markers.as_slice() else { + return Err("missing or ambiguous start segment marker"); + }; + + let (uses, closed) = ordered_chain(net, *marked)?; + let mut d = BezPath::new(); + let mut total = 0.0; + let mut prefix = 0.0; + let mut start_segment_length = 0.0; + for use_ in &uses { + let (p0, p1, p2, p3) = cubic(net, *use_, sx, sy)?; + if d.elements().is_empty() { + d.move_to((p0.0, p0.1)); + } + let line = p1 == p0 && p2 == p3; + let (length, partial) = if line { + d.line_to((p3.0, p3.1)); + let line = Line::new(Point::new(p0.0, p0.1), Point::new(p3.0, p3.1)); + (line.arclen(1e-9), line.arclen(1e-9) * t) + } else { + d.curve_to((p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1)); + let curve = CubicBez::new((p0.0, p0.1), (p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1)); + let partial = if t <= 0.0 { 0.0 } else { curve.subsegment(0.0..t).arclen(1e-9) }; + (curve.arclen(1e-9), partial) + }; + if !length.is_finite() || length <= 1e-10 { + return Err("zero-length path segment"); + } + if use_.segment == *marked { + start_segment_length = partial; + if !start_segment_length.is_finite() { + return Err("invalid path length"); + } + prefix = total; + } + total += length; + } + if closed { + d.close_path(); + } + if !total.is_finite() || total <= 1e-10 { + return Err("zero-length path"); + } + let mut start = ((prefix + start_segment_length) / total).clamp(0.0, 1.0); + let flip = !forward; + if flip { + start = 1.0 - start; + } + let path = TextPath { d: d.to_svg(), start, flip }; + let lines = figcraft_doc::geom::measure::polylines(&path.path()).ok_or("cannot be measured by text renderer")?; + if lines.first().is_none_or(|line| line.length() <= 1e-9) { + return Err("cannot be measured by text renderer"); + } + Ok(path) +} + +/// Return every segment in chain order. Open chains are oriented so the marked segment runs a→b; +/// cycles start at the marked segment's a vertex and traverse it first. +fn ordered_chain(net: &Network, marked: usize) -> Result<(Vec, bool), &'static str> { + let marker = net.segments.get(marked).ok_or("invalid start segment index")?; + let mut adjacency = vec![Vec::::new(); net.vertices.len()]; + for (i, s) in net.segments.iter().enumerate() { + if s.a >= adjacency.len() || s.b >= adjacency.len() { + return Err("invalid path vertex index"); + } + if s.a == s.b { + return Err("path contains a segment that starts and ends at the same vertex"); + } + adjacency[s.a].push(i); + adjacency[s.b].push(i); + } + let mut endpoints = Vec::new(); + for edges in &adjacency { + if edges.len() > 2 { + return Err("branched path network"); + } + if edges.len() == 1 { + endpoints.push(edges[0]); + } + } + if !endpoints.is_empty() && endpoints.len() != 2 { + return Err("disconnected or malformed path network"); + } + let cycle = endpoints.is_empty(); + let mut used = vec![false; net.segments.len()]; + let mut out = Vec::with_capacity(net.segments.len()); + if cycle { + out.push(Use { segment: marked, forward: true }); + used[marked] = true; + let mut at = marker.b; + let mut visited = vec![false; net.vertices.len()]; + visited[marker.a] = true; + visited[marker.b] = true; + while at != marker.a { + let next = adjacency[at].iter().copied().find(|i| !used[*i]).ok_or("disconnected path cycle")?; + used[next] = true; + let s = &net.segments[next]; + let is_forward = s.a == at; + at = if is_forward { s.b } else { s.a }; + if at != marker.a && std::mem::replace(&mut visited[at], true) { + return Err("path cycle is not simple"); + } + out.push(Use { segment: next, forward: is_forward }); + if out.len() > net.segments.len() { + return Err("invalid path cycle"); + } + } + } else { + let endpoint = adjacency.iter().position(|e| e.len() == 1).ok_or("missing path endpoint")?; + let mut at = endpoint; + while let Some(next) = adjacency[at].iter().copied().find(|i| !used[*i]) { + used[next] = true; + let s = &net.segments[next]; + let is_forward = s.a == at; + at = if is_forward { s.b } else { s.a }; + out.push(Use { segment: next, forward: is_forward }); + } + let Some(marker_use) = out.iter().find(|u| u.segment == marked) else { return Err("disconnected start segment") }; + if !marker_use.forward { + out.reverse(); + for u in &mut out { + u.forward = !u.forward; + } + } + } + if !used.iter().all(|x| *x) { + return Err("disconnected path network"); + } + Ok((out, cycle)) +} + +type P = (f64, f64); + +fn cubic(net: &Network, use_: Use, sx: f64, sy: f64) -> Result<(P, P, P, P), &'static str> { + let s: &Segment = net.segments.get(use_.segment).ok_or("invalid path segment index")?; + let a = net.vertices.get(s.a).ok_or("invalid path vertex index")?; + let b = net.vertices.get(s.b).ok_or("invalid path vertex index")?; + let p0 = (a.x * sx, a.y * sy); + let p3 = (b.x * sx, b.y * sy); + let ta = (s.ta.0 * sx, s.ta.1 * sy); + let tb = (s.tb.0 * sx, s.tb.1 * sy); + if [p0.0, p0.1, p3.0, p3.1, ta.0, ta.1, tb.0, tb.1].iter().any(|x| !x.is_finite()) { + return Err("invalid path coordinates"); + } + let (p0, p1, p2, p3) = + if ta == (0.0, 0.0) && tb == (0.0, 0.0) { (p0, p0, p3, p3) } else { (p0, (p0.0 + ta.0, p0.1 + ta.1), (p3.0 + tb.0, p3.1 + tb.1), p3) }; + if use_.forward { Ok((p0, p1, p2, p3)) } else { Ok((p3, p2, p1, p0)) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fig::network::{Segment, Vertex}; + use figcraft_doc::geom::kurbo::PathEl; + + fn vertex(x: f64, y: f64) -> Vertex { + Vertex { style: 0, x, y } + } + + fn segment(style: u32, a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment { + Segment { style, a, ta, b, tb } + } + + fn curved() -> Network { + Network { + vertices: vec![vertex(0.0, 82.5), vertex(300.0, 82.5), vertex(600.0, 82.5)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (70.0, -110.0), (-70.0, -110.0)), segment(0, 1, 2, (70.0, 110.0), (-70.0, 110.0))], + ..Network::default() + } + } + + #[test] + fn local_cubic_parameter_converts_to_path_distance() { + let path = from_network(&curved(), 1.0, 1.0, 0.0086954795, true).unwrap(); + assert!((path.start - 0.004744078).abs() < 0.00001); + // Cubic t is not its fraction of total path length. + assert!((path.start - 0.0086954795).abs() > 0.003); + let elements = path.path().elements().to_vec(); + assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 82.5)); + assert!(matches!(elements.get(1), Some(PathEl::CurveTo(..)))); + } + + #[test] + fn second_segment_marker_and_t_are_respected() { + let mut net = curved(); + net.segments[0].style = 0; + net.segments[1].style = START_SEGMENT_STYLE; + let path = from_network(&net, 1.0, 1.0, 0.49800667, true).unwrap(); + assert!((path.start - 0.7490379757).abs() < 0.00002); + } + + #[test] + fn reverse_direction_inverts_normalized_start() { + let net = Network { + vertices: vec![vertex(0.0, 0.0), vertex(600.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + let path = from_network(&net, 1.0, 1.0, 0.83986014, false).unwrap(); + assert!(path.flip); + assert!((path.start - 0.16013986).abs() < 1e-8); + assert!(matches!(path.path().elements().get(1), Some(PathEl::LineTo(p)) if p.x == 600.0 && p.y == 0.0)); + } + + #[test] + fn rejects_missing_duplicate_and_zero_length_segments() { + let mut net = curved(); + net.segments[0].style = 0; + assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker")); + net.segments[1].style = START_SEGMENT_STYLE; + net.segments[0].style = START_SEGMENT_STYLE; + assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker")); + let zero = Network { + vertices: vec![vertex(0.0, 0.0), vertex(0.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&zero, 1.0, 1.0, 0.5, true), Err("zero-length path segment")); + } + + #[test] + fn rejects_branching_and_disconnected_segments() { + let branch = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(1.0, 1.0)], + segments: vec![ + segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 3, (0.0, 0.0), (0.0, 0.0)), + ], + ..Network::default() + }; + assert_eq!(from_network(&branch, 1.0, 1.0, 0.5, true), Err("branched path network")); + let disconnected = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(3.0, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), segment(0, 2, 3, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&disconnected, 1.0, 1.0, 0.5, true), Err("disconnected or malformed path network")); + } + + #[test] + fn scales_geometry_and_accepts_closed_cycles() { + let cycle = Network { + vertices: vec![vertex(0.0, 0.0), vertex(100.0, 0.0), vertex(100.0, 100.0)], + segments: vec![ + segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), + segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)), + segment(0, 2, 0, (0.0, 0.0), (0.0, 0.0)), + ], + ..Network::default() + }; + let path = from_network(&cycle, 2.0, 3.0, 0.5, true).unwrap(); + let elements = path.path().elements().to_vec(); + assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 0.0)); + assert!(matches!(elements.get(1), Some(PathEl::LineTo(p)) if p.x == 200.0 && p.y == 0.0)); + let total = 200.0 + 300.0 + 130_000.0_f64.sqrt(); + assert!((path.start - 100.0 / total).abs() < 1e-8); + assert!(matches!(elements.last(), Some(PathEl::ClosePath))); + } + + #[test] + fn rejects_paths_the_native_renderer_cannot_measure() { + let count = 40_000usize; + let many_lines = Network { + vertices: (0..=count).map(|i| vertex(i as f64, 0.0)).collect(), + segments: (0..count).map(|i| segment(if i == 0 { START_SEGMENT_STYLE } else { 0 }, i, i + 1, (0.0, 0.0), (0.0, 0.0))).collect(), + ..Network::default() + }; + assert_eq!(from_network(&many_lines, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + + let huge = Network { + vertices: vec![vertex(0.0, 0.0), vertex(1e9, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&huge, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + + let tiny = Network { + vertices: vec![vertex(0.0, 0.0), vertex(5e-10, 0.0)], + segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], + ..Network::default() + }; + assert_eq!(from_network(&tiny, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + } +} diff --git a/crates/format/src/fig/text_path_tests.rs b/crates/format/src/fig/text_path_tests.rs new file mode 100644 index 00000000..66148930 --- /dev/null +++ b/crates/format/src/fig/text_path_tests.rs @@ -0,0 +1,211 @@ +//! Mapper → private REST ingress tests for source text-path fields. + +use super::kiwi::Kv; + +const SCHEMA: &str = " +enum NodeType DOCUMENT CANVAS TEXT TEXT_PATH +enum StyleType NONE TEXT +enum TextDecoration NONE UNDERLINE STRIKETHROUGH +struct GUID uint sessionID, uint localID +struct StyleId GUID guid +struct ParentIndex GUID guid, string position +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +struct FontName string family, string style +message TextOverride uint styleID, FontName fontName, float fontSize +message TextData string characters, uint[] characterStyleIDs, TextOverride[] styleOverrideTable +message TextPathStart float tValue, bool forward +message VectorData uint vectorNetworkBlob, Vector normalizedSize +message Blob byte[] bytes +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, TextData textData, float fontSize, FontName fontName, TextPathStart textPathStart, VectorData vectorData, StyleType styleType, StyleId styleIdForText, TextDecoration textDecoration +message Message NodeChange[] nodeChanges, Blob[] blobs +"; +use figcraft_doc::{NodeKind, TextPath}; + +fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn a(values: Vec>) -> Kv<'static> { + Kv::Array(values) +} + +fn f(value: f32) -> Kv<'static> { + Kv::Float(value) +} + +fn u(value: u64) -> Kv<'static> { + Kv::Uint(value) +} + +fn s(value: &'static str) -> Kv<'static> { + Kv::Str(value.into()) +} + +fn point(x: f32, y: f32) -> Kv<'static> { + o(vec![("x", f(x)), ("y", f(y))]) +} + +fn guid(id: u64) -> Kv<'static> { + o(vec![("sessionID", u(1)), ("localID", u(id))]) +} + +fn node(id: u64, parent: Option, ty: &'static str, fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut node = vec![("guid", guid(id)), ("type", s(ty))]; + if let Some(parent) = parent { + node.push(("parentIndex", o(vec![("guid", guid(parent)), ("position", s("!"))]))); + } + node.extend(fields); + o(node) +} + +fn blob() -> Vec { + let mut bytes = Vec::new(); + let u32le = |out: &mut Vec, n: u32| out.extend_from_slice(&n.to_le_bytes()); + let f32le = |out: &mut Vec, n: f32| out.extend_from_slice(&n.to_le_bytes()); + u32le(&mut bytes, 2); // vertices + u32le(&mut bytes, 1); // segments + u32le(&mut bytes, 0); // regions + for (x, y) in [(0.0, 0.0), (100.0, 0.0)] { + u32le(&mut bytes, 0); + f32le(&mut bytes, x); + f32le(&mut bytes, y); + } + u32le(&mut bytes, 0x01ff_ffff); // source start-segment marker + u32le(&mut bytes, 0); + f32le(&mut bytes, 0.0); + f32le(&mut bytes, 0.0); + u32le(&mut bytes, 1); + f32le(&mut bytes, 0.0); + f32le(&mut bytes, 0.0); + bytes +} + +fn canvas() -> Vec { + let schema = super::kiwi::Schema::parse(SCHEMA).unwrap(); + let message = message(true); + let message = super::kiwi::encode::message(&schema, schema.find("Message").unwrap(), &message).unwrap(); + let mut out = b"fig-kiwi\0\0\0\0".to_vec(); + for raw in [super::kiwi::encode::schema(&schema), message] { + let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &raw).unwrap(); + let compressed = encoder.finish().unwrap(); + out.extend_from_slice(&(compressed.len() as u32).to_le_bytes()); + out.extend_from_slice(&compressed); + } + out +} + +fn message(with_path: bool) -> Kv<'static> { + let mut nodes = vec![node(0, None, "DOCUMENT", vec![]), node(1, Some(0), "CANVAS", vec![])]; + let mut blobs = Vec::new(); + if with_path { + blobs.push(o(vec![("bytes", Kv::Bytes(blob()))])); + let text = o(vec![ + ("characters", s("alpha")), + ("characterStyleIDs", a(vec![u(0), u(0), u(0), u(2), u(2)])), + ( + "styleOverrideTable", + a(vec![o(vec![("styleID", u(2)), ("fontName", o(vec![("family", s("Inter")), ("style", s("Medium"))])), ("fontSize", f(30.0))])]), + ), + ]); + nodes.push(node( + 2, + Some(1), + "TEXT_PATH", + vec![ + ("name", s("alpha path")), + ("size", point(100.0, 0.0)), + ("transform", o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(0.0)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(0.0))])), + ("textData", text), + ("fontSize", f(24.0)), + ("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])), + ("textPathStart", o(vec![("tValue", f(0.5)), ("forward", Kv::Bool(true))])), + ("vectorData", o(vec![("vectorNetworkBlob", u(0)), ("normalizedSize", point(100.0, 0.0))])), + ], + )); + } else { + nodes.push(node(2, Some(1), "TEXT_PATH", vec![("name", s("broken path")), ("textData", o(vec![("characters", s("fallback"))]))])); + } + o(vec![("nodeChanges", a(nodes)), ("blobs", a(blobs))]) +} + +fn linked_underline_message() -> Kv<'static> { + let Kv::Object(mut root) = message(true) else { panic!("expected message object") }; + let (_, Kv::Array(nodes)) = root.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap() else { panic!("expected node changes array") }; + let text = nodes.iter_mut().find(|n| n.get("name").and_then(Kv::str) == Some("alpha path")).unwrap(); + let Kv::Object(fields) = text else { panic!("expected text node object") }; + fields.push(("styleIdForText", o(vec![("guid", guid(3))]))); + nodes.push(node( + 3, + Some(1), + "TEXT", + vec![ + ("name", s("Shared style")), + ("styleType", s("TEXT")), + ("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])), + ("fontSize", f(24.0)), + ("textDecoration", s("UNDERLINE")), + ], + )); + Kv::Object(root) +} + +#[test] +fn mapped_text_path_reaches_native_model_with_runs_and_no_standin_warning() { + let message = message(true); + let (json, warnings) = super::map::to_rest(&message, "Text path"); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); + let (doc, warnings) = crate::rest::import_fig(&json, &|_| None).unwrap(); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + let path = text.path.as_deref().unwrap(); + assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false }); + assert_eq!(text.characters, "alpha"); + assert_eq!(text.runs.len(), 1); + assert_eq!(text.runs[0].start, 3); + assert_eq!(text.runs[0].end, 5); +} + +#[test] +fn public_rest_import_ignores_private_fig_text_path_field() { + let message = message(true); + let (json, _) = super::map::to_rest(&message, "Text path"); + // The mapper's private field is accepted only by import_fig, never by public REST import. + let path = json["document"]["children"][0]["children"][0]["_figTextPath"].clone(); + assert!(path.is_object()); + let (doc, _) = crate::rest::import(&json, &|_| None).unwrap(); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + assert!(text.path.is_none()); +} + +#[test] +fn serialized_kiwi_canvas_import_decodes_start_and_builds_native_path() { + let (doc, warnings) = super::import(&canvas(), "Serialized text path").unwrap(); + let node = doc.nodes().find(|n| n.name == "alpha path").unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("expected text") }; + let path = text.path.as_deref().unwrap(); + assert_eq!(path, &TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false }); + assert_eq!(text.characters, "alpha"); + assert!(!warnings.iter().any(|w| w.contains("text off its path")), "{warnings:?}"); +} + +#[test] +fn malformed_text_path_falls_back_with_specific_warning() { + let message = message(false); + let (json, warnings) = super::map::to_rest(&message, "Text path"); + assert!(warnings.iter().any(|w| w.contains("text path has no start data") && w.contains("text imports off its path")), "{warnings:?}"); + let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap(); + let node = doc.nodes().find(|n| n.name == "broken path").unwrap(); + assert!(matches!(&node.kind, NodeKind::Text(text) if text.path.is_none())); +} + +#[test] +fn linked_text_style_decoration_warns_from_effective_mapped_style() { + let message = linked_underline_message(); + let (json, warnings) = super::map::to_rest(&message, "Text path"); + assert_eq!(json["document"]["children"][0]["children"][0]["style"]["textDecoration"], "UNDERLINE"); + assert!(warnings.iter().any(|w| w == "text path underline or strikethrough decoration is not rendered"), "{warnings:?}"); +} diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 3875204f..9b8e9be4 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use figcraft_doc::{ DecorationLength, DecorationStyle, LetterSpacing, LineHeight, ListStyle, ParaList, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase, - TextData, TextDecoration, TextStyle, + TextData, TextDecoration, TextPath, TextStyle, }; use serde_json::Value; @@ -29,6 +29,16 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData { td.runs = runs(cx, v, &td); td.normalize(); lists(v, &mut td); + if cx.fig_sources.is_some() + && let Some(path) = v.get("_figTextPath") + { + match (path.get("d").and_then(Value::as_str), path.get("start").and_then(Value::as_f64), path.get("flip").and_then(Value::as_bool)) { + (Some(d), Some(start), Some(flip)) if !d.is_empty() && start.is_finite() && (0.0..=1.0).contains(&start) => { + td.path = Some(Box::new(TextPath { d: d.to_owned(), start, flip })); + } + _ => cx.warn("invalid internal .fig text path data was ignored"), + } + } td } diff --git a/docs/gaps.md b/docs/gaps.md index e8b111a8..a5cd6a3b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -41,6 +41,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ## Entries ### 1. `.fig` files: the import's tail +- **Text paths (2026-10-15):** local `.fig` import maps supported connected `TEXT_PATH` networks + to existing editable native text paths, preserving the start segment, direction and styled + runs. Other vertical placements, multiline/ambiguous networks, path decorations, public REST + text paths and `.fig` text-path export remain limited. - **Landed (2026-10-11):** `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors, auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). Then (2026-10-11): vectors open as editable vector networks (per-point corner radius, handle mirroring, end caps; outlines when the network blob is missing or damaged), sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning, and set-variable, set-variable-mode and conditional prototype actions come across. `file.open`, `file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved over. From 3fadbf4ba9613f1fcc9f11c770f927c5088524f4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:54:25 +0700 Subject: [PATCH 24/33] #632: read the network with the decoder #631 left --- crates/format/src/fig/map.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 9a2b696d..9e388cc4 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1186,7 +1186,7 @@ impl<'a> Mapper<'a> { fail(self, "has no vector network"); return; }; - let Some((net, _)) = network::decode_styled(blob) else { + let Some(net) = network::decode(blob) else { fail(self, "has damaged vector data"); return; }; From ca94b5fe6f144b31092731d056fb4046c450476c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:54:27 +0700 Subject: [PATCH 25/33] #632: leave the progress docs to the maintainer --- docs/gaps.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index a5cd6a3b..e8b111a8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -41,10 +41,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ## Entries ### 1. `.fig` files: the import's tail -- **Text paths (2026-10-15):** local `.fig` import maps supported connected `TEXT_PATH` networks - to existing editable native text paths, preserving the start segment, direction and styled - runs. Other vertical placements, multiline/ambiguous networks, path decorations, public REST - text paths and `.fig` text-path export remain limited. - **Landed (2026-10-11):** `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors, auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). Then (2026-10-11): vectors open as editable vector networks (per-point corner radius, handle mirroring, end caps; outlines when the network blob is missing or damaged), sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning, and set-variable, set-variable-mode and conditional prototype actions come across. `file.open`, `file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved over. From d84aa7f1e1f14a41fc2d0591064a8a220616d245 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 02:38:33 +0200 Subject: [PATCH 26/33] M1: import image adjustments from .fig files Image paints that carry a current filter container now import their exposure, contrast, vibrance (as saturation), temperature, tint, highlights and shadows as native image filters. Neutral values leave the paint untouched. The older color-adjust container alone, both containers together, filters that have no native equivalent (brightness, detail, vignette and unknown fields) and malformed or non-finite values are skipped and reported as import notes. A positive tint in .fig data moves toward magenta, so it is flipped once at the .fig boundary; public REST image filters keep their sign and are not reported as approximated. Imported filters use the approximate native renderer, which the import says once. --- crates/format/src/fig/image_filter_tests.rs | 83 +++++++++++++++++++ crates/format/src/fig/map.rs | 74 +++++++++++++++-- .../format/src/fig/map_image_filter_tests.rs | 27 ++++++ crates/format/src/fig/tests.rs | 3 + crates/format/src/rest/props.rs | 23 ++++- crates/format/src/rest/tests.rs | 18 ++++ 6 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 crates/format/src/fig/image_filter_tests.rs create mode 100644 crates/format/src/fig/map_image_filter_tests.rs diff --git a/crates/format/src/fig/image_filter_tests.rs b/crates/format/src/fig/image_filter_tests.rs new file mode 100644 index 00000000..b8d64694 --- /dev/null +++ b/crates/format/src/fig/image_filter_tests.rs @@ -0,0 +1,83 @@ +//! Image paint filters read from `.fig` files, on fixtures built in code with the parent module's +//! helpers. + +use super::*; + +/// The shared schema plus the image filter containers only these tests use. +fn image_filter_schema() -> Schema { + let text = SCHEMA + .replace( + "message Paint ", + "message FilterColorAdjust float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance\n\ + message PaintFilterMessage float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance, float contrast, float brightness, float mystery\n\ + message Paint ", + ) + .replace("ImageScaleMode imageScaleMode\n", "ImageScaleMode imageScaleMode, PaintFilterMessage paintFilter, FilterColorAdjust filterColorAdjust\n"); + Schema::parse(&text).unwrap() +} + +#[test] +fn image_filters_import_from_current_container_and_skip_ambiguous_data() { + let filter = |fields: Vec<(&'static str, Kv<'static>)>| o(fields); + let image_paint = |id: u64, name: &'static str, mut extra: Vec<(&'static str, Kv<'static>)>| { + let mut paint = vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))]))]; + paint.append(&mut extra); + node(id, Some((1, "!")), "RECTANGLE", name, vec![("size", xy(2.0, 1.0)), ("fillPaints", arr(vec![o(paint)]))]) + }; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + image_paint( + 2, + "Current", + vec![( + "paintFilter", + filter(vec![ + ("exposure", f(0.25)), + ("contrast", f(0.3)), + ("vibrance", f(-0.4)), + ("temperature", f(0.5)), + ("tint", f(1.0)), + ("highlights", f(-0.6)), + ("shadows", f(0.7)), + ]), + )], + ), + image_paint(3, "Negative tint", vec![("paintFilter", filter(vec![("tint", f(-1.0)), ("contrast", f(-0.3))]))]), + image_paint(4, "Neutral", vec![]), + image_paint(5, "Explicit neutral", vec![("paintFilter", filter(vec![("exposure", f(0.0)), ("vibrance", f(0.0))]))]), + image_paint(6, "Legacy", vec![("filterColorAdjust", filter(vec![("exposure", f(0.8))]))]), + image_paint(7, "Both", vec![("filterColorAdjust", filter(vec![("exposure", f(0.8))])), ("paintFilter", filter(vec![("contrast", f(0.2))]))]), + image_paint( + 8, + "Unsupported", + vec![("paintFilter", filter(vec![("brightness", f(0.4)), ("detail", f(-0.2)), ("vignette", f(0.3)), ("mystery", f(0.1))]))], + ), + ]; + let bytes = zip_file(&canvas(&image_filter_schema(), &o(vec![("nodeChanges", arr(nodes))]))); + let (doc, warnings) = import(&bytes, "ImageFilters.fig").unwrap(); + let get = |name: &str| { + let node = doc.nodes().find(|node| node.name == name).unwrap(); + let figcraft_doc::color::PaintKind::Image(image) = &node.fills[0].kind else { panic!("{name} is not an image fill") }; + image + }; + let current = get("Current"); + assert!((current.filters.exposure - 0.25).abs() < 1e-6); + assert!((current.filters.contrast - 0.3).abs() < 1e-6, "observed raw contrast remains 0.3"); + assert!((current.filters.saturation + 0.4).abs() < 1e-6, "vibrance maps to saturation"); + assert!((current.filters.temperature - 0.5).abs() < 1e-6); + assert!((current.filters.tint + 1.0).abs() < 1e-6, "positive Figma tint converts once at the REST boundary"); + assert!((current.filters.highlights + 0.6).abs() < 1e-6); + assert!((current.filters.shadows - 0.7).abs() < 1e-6); + assert!((get("Negative tint").filters.tint - 1.0).abs() < 1e-6); + assert!((get("Negative tint").filters.contrast + 0.3).abs() < 1e-6); + assert_eq!(get("Neutral").filters, Default::default()); + assert_eq!(get("Explicit neutral").filters, Default::default()); + assert_eq!(doc.images.get(¤t.image).map(|image| image.bytes.as_slice()), Some(png().as_slice())); + assert!(warnings.iter().any(|w| w.contains("legacy image filter container is unsupported")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("both filter containers are present")), "{warnings:?}"); + for field in ["brightness", "detail", "vignette", "mystery"] { + assert!(warnings.iter().any(|w| w.contains(field)), "missing {field} warning: {warnings:?}"); + } + assert_eq!(warnings.iter().filter(|w| w.contains("approximate renderer")).count(), 1, "deduplicated approximation warning: {warnings:?}"); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..62d10d71 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -55,6 +55,10 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { (out, m.warnings) } +#[cfg(test)] +#[path = "map_image_filter_tests.rs"] +mod image_filter_tests; + /// REST importer warnings reworded for a `.fig` import (`None`: drop it). pub(super) fn adapt_warning(w: String) -> Option { if w.contains("geometry=paths") { @@ -73,6 +77,16 @@ fn num(n: &Kv, k: &str) -> Option { n.get(k).and_then(Kv::f64) } +fn filter_number(value: &Kv) -> Option { + let number = match value { + Kv::Float(value) => f64::from(*value), + Kv::Int(value) => *value as f64, + Kv::Uint(value) => *value as f64, + _ => return None, + }; + number.is_finite().then_some(number) +} + fn guid(v: &Kv) -> Option { Some(format!("{}:{}", num(v, "sessionID")? as u64, num(v, "localID")? as u64)) } @@ -964,11 +978,11 @@ impl<'a> Mapper<'a> { let fill_style = self.style(v, "styleIdForFill", "inheritFillStyleID", "fill", &mut styles); let fills = own("fillPaints") .unwrap_or_else(|| fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); - o.insert("fills".into(), Value::Array(fills.iter().filter_map(paint).collect())); + o.insert("fills".into(), Value::Array(fills.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect())); let stroke_style = self.style(v, "styleIdForStrokeFill", "inheritFillStyleIDForStroke", "stroke", &mut styles); let strokes = own("strokePaints") .unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); - o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(paint).collect())); + o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect())); let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles); let effects = effect_style.map_or_else(|| v.list("effects"), |s| s.get("effects").map(Kv::list).unwrap_or_default()); if !effects.is_empty() { @@ -1138,7 +1152,7 @@ impl<'a> Mapper<'a> { } o.insert("style".into(), Value::Object(style)); let Some(td) = td else { return }; - char_styles(o, td, size); + char_styles(o, td, size, &mut |w| self.warn(w)); let lines: Vec<&str> = td .get("lines") .map(Kv::list) @@ -1333,7 +1347,7 @@ impl<'a> Mapper<'a> { 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); + char_styles(o, tv, size, &mut |w| self.warn(w)); } } } @@ -1920,7 +1934,7 @@ fn paint_bound(p: &Kv) -> bool { alias(p.get("colorVar")).is_some() || p.get("stopsVar").map(Kv::list).unwrap_or_default().iter().any(|s| alias(s.get("colorVar")).is_some()) } -fn paint(p: &Kv) -> Option { +fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option { let ty = p.get("type").and_then(Kv::str)?; let mut o = Obj::new(); o.insert("type".into(), json!(ty)); @@ -1989,6 +2003,52 @@ fn paint(p: &Kv) -> Option { if let Some(r) = num(p, "rotation") { o.insert("rotation".into(), json!(r)); } + match (p.get("filterColorAdjust"), p.get("paintFilter")) { + (Some(_), Some(_)) => { + warn("image filters are ambiguous because both filter containers are present; filter conversion was skipped".into()) + } + (Some(_), None) => warn("legacy image filter container is unsupported; filter conversion was skipped".into()), + (None, Some(filters)) => { + let mut mapped = Obj::new(); + let fields = [ + ("exposure", "exposure"), + ("contrast", "contrast"), + ("vibrance", "saturation"), + ("temperature", "temperature"), + ("tint", "tint"), + ("highlights", "highlights"), + ("shadows", "shadows"), + ]; + if let Kv::Object(values) = filters { + for (source, target) in fields { + if let Some(value) = filters.get(source) { + match filter_number(value) { + Some(value) => { + mapped.insert(target.into(), json!(value)); + } + None => warn(format!("malformed image filter `{source}` was skipped")), + } + } + } + for (name, value) in values { + if fields.iter().any(|(known, _)| *known == *name) { + continue; + } + match filter_number(value) { + Some(0.0) => {} + Some(_) => warn(format!("nonzero unsupported image filter `{}` was skipped", crate::rest::props::clip_str(name))), + None => warn(format!("malformed unsupported image filter `{}` was skipped", crate::rest::props::clip_str(name))), + } + } + } else { + warn("malformed image paintFilter container was skipped".into()); + } + if !mapped.is_empty() { + o.insert("filters".into(), Value::Object(mapped)); + } + } + (None, None) => {} + } } _ => {} } @@ -2046,7 +2106,7 @@ fn variable_value(d: &Kv) -> Option { /// 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) { +fn char_styles(o: &mut Obj, td: &Kv, size: Option, warn: &mut dyn FnMut(String)) { let ids: Vec = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect(); if ids.iter().all(|i| *i == 0) { return; @@ -2056,7 +2116,7 @@ fn char_styles(o: &mut Obj, td: &Kv, size: Option) { 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())); + s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(|p| paint(p, warn)).collect())); } table.insert((sid as u64).to_string(), Value::Object(s)); } diff --git a/crates/format/src/fig/map_image_filter_tests.rs b/crates/format/src/fig/map_image_filter_tests.rs new file mode 100644 index 00000000..a8db39a7 --- /dev/null +++ b/crates/format/src/fig/map_image_filter_tests.rs @@ -0,0 +1,27 @@ +//! Malformed image paint filters are skipped with a warning. + +use super::*; + +#[test] +fn malformed_non_finite_filter_is_skipped_with_warning() { + let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![("exposure", Kv::Float(f32::NAN))]))]); + let mut warnings = Vec::new(); + let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap(); + assert!(value.get("filters").is_none(), "{value}"); + assert!(warnings.iter().any(|warning| warning.contains("malformed image filter `exposure`")), "{warnings:?}"); +} + +#[test] +fn boolean_filter_values_are_malformed_even_when_false() { + for (field, value, expected_warning) in [ + ("exposure", Kv::Bool(true), "malformed image filter `exposure`"), + ("exposure", Kv::Bool(false), "malformed image filter `exposure`"), + ("mystery", Kv::Bool(false), "malformed unsupported image filter `mystery`"), + ] { + let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![(field, value)]))]); + let mut warnings = Vec::new(); + let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap(); + assert!(value.get("filters").is_none(), "{value}"); + assert!(warnings.iter().any(|warning| warning.contains(expected_warning)), "{warnings:?}"); + } +} diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..e98a1f34 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -873,6 +873,9 @@ fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { #[path = "tail_tests.rs"] mod tail; +#[path = "image_filter_tests.rs"] +mod image_filters; + /// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset). pub(super) fn samples() -> Vec { let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() }; diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 67ec8063..ac4d3519 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -178,6 +178,24 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { .filter(|c| c[0].abs() > 1e-9 && c[3].abs() > 1e-9); let f = p.get("filters"); let filter = |k: &str| f.and_then(|f| num(f, k)).unwrap_or(0.0).clamp(-1.0, 1.0) as f32; + let filter_fields = ["exposure", "contrast", "saturation", "temperature", "tint", "highlights", "shadows"]; + let mut has_filters = false; + if let Some(filters) = f.and_then(Value::as_object) { + for (key, value) in filters { + match value.as_f64().filter(|value| value.is_finite()) { + Some(value) if filter_fields.contains(&key.as_str()) => has_filters |= value != 0.0, + Some(0.0) => {} + Some(_) => cx.warn(format!("nonzero unsupported image filter `{}` was skipped", clip_str(key))), + None => cx.warn(format!("malformed image filter `{}` was skipped", clip_str(key))), + } + } + } else if f.is_some_and(|filters| !filters.is_null()) { + cx.warn("malformed image filters container was skipped"); + } + let fig = cx.fig_sources.is_some(); + if has_filters && fig { + cx.warn("image filters use FigCraft's approximate renderer"); + } ImagePaint { image: cx.image_key(text(p, "imageRef").unwrap_or("")), scale_mode, @@ -189,7 +207,8 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { contrast: filter("contrast"), saturation: filter("saturation"), temperature: filter("temperature"), - tint: filter("tint"), + // A positive tint in .fig sources moves toward magenta; native positive tint adds green. + tint: if fig { -filter("tint") } else { filter("tint") }, highlights: filter("highlights"), shadows: filter("shadows"), }, @@ -550,6 +569,6 @@ pub(super) fn export_settings(v: &Value) -> Vec { } /// At most 200 chars of `s` (names and labels from the file stay readable in warnings). -pub(super) fn clip_str(s: &str) -> &str { +pub(crate) fn clip_str(s: &str) -> &str { s.char_indices().nth(200).and_then(|(i, _)| s.get(..i)).unwrap_or(s) } diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index d7117fd0..282476ef 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -186,6 +186,24 @@ fn paints_gradients_and_image_placeholders() { assert!(warnings.iter().any(|w| w.starts_with("1 image without image data shows")), "{warnings:?}"); } +#[test] +fn public_rest_image_filter_tint_keeps_its_sign() { + let rect = |name: &str, tint: f64| { + json!({ + "id": name, "name": name, "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, + "fills": [{"type": "IMAGE", "imageRef": "tint", "filters": {"tint": tint}}] + }) + }; + let (doc, warnings) = one(json!([rect("Magenta", 0.75), rect("Green", -0.5)])); + let image = |name: &str| { + let PaintKind::Image(image) = &named(&doc, name).fills[0].kind else { panic!("{name} is not an image fill") }; + image + }; + assert!(close(f64::from(image("Magenta").filters.tint), 0.75)); + assert!(close(f64::from(image("Green").filters.tint), -0.5)); + assert!(!warnings.iter().any(|w| w.contains("approximate renderer")), "{warnings:?}"); +} + #[test] fn image_bytes_come_from_the_resolver() { let png = { From 2ac4ce3a730f02fef392c0f6fcb28ada4dfcb10e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:58:41 +0700 Subject: [PATCH 27/33] #635: rebase onto #628: the filter tests add their paint fields themselves The test schema extended the image scale-mode field that only #634 adds; it now adds the filter containers to the paint message itself, so it works with and without #634. --- crates/format/src/fig/image_filter_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/format/src/fig/image_filter_tests.rs b/crates/format/src/fig/image_filter_tests.rs index b8d64694..3216b2b3 100644 --- a/crates/format/src/fig/image_filter_tests.rs +++ b/crates/format/src/fig/image_filter_tests.rs @@ -12,7 +12,7 @@ fn image_filter_schema() -> Schema { message PaintFilterMessage float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance, float contrast, float brightness, float mystery\n\ message Paint ", ) - .replace("ImageScaleMode imageScaleMode\n", "ImageScaleMode imageScaleMode, PaintFilterMessage paintFilter, FilterColorAdjust filterColorAdjust\n"); + .replace("message Paint PaintType type,", "message Paint PaintFilterMessage paintFilter, FilterColorAdjust filterColorAdjust, PaintType type,"); Schema::parse(&text).unwrap() } From 48d74df226894457390f4968ab76fec276526846 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:03:57 +0700 Subject: [PATCH 28/33] #635: one tint sign for REST file JSON and .fig Both carry the same image filter value, which the public docs describe as green when lower and magenta when higher; a positive native tint adds green. Both readers flip it now (the .fig path did, the REST path didn't). The .fig test message no longer names the product. --- crates/format/src/fig/image_filter_tests.rs | 2 +- crates/format/src/rest/props.rs | 5 +++-- crates/format/src/rest/tests.rs | 7 ++++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/format/src/fig/image_filter_tests.rs b/crates/format/src/fig/image_filter_tests.rs index 3216b2b3..83792123 100644 --- a/crates/format/src/fig/image_filter_tests.rs +++ b/crates/format/src/fig/image_filter_tests.rs @@ -66,7 +66,7 @@ fn image_filters_import_from_current_container_and_skip_ambiguous_data() { assert!((current.filters.contrast - 0.3).abs() < 1e-6, "observed raw contrast remains 0.3"); assert!((current.filters.saturation + 0.4).abs() < 1e-6, "vibrance maps to saturation"); assert!((current.filters.temperature - 0.5).abs() < 1e-6); - assert!((current.filters.tint + 1.0).abs() < 1e-6, "positive Figma tint converts once at the REST boundary"); + assert!((current.filters.tint + 1.0).abs() < 1e-6, "a positive (magenta) tint in the file is a negative native tint"); assert!((current.filters.highlights + 0.6).abs() < 1e-6); assert!((current.filters.shadows - 0.7).abs() < 1e-6); assert!((get("Negative tint").filters.tint - 1.0).abs() < 1e-6); diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index ac4d3519..0ee2d687 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -207,8 +207,9 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { contrast: filter("contrast"), saturation: filter("saturation"), temperature: filter("temperature"), - // A positive tint in .fig sources moves toward magenta; native positive tint adds green. - tint: if fig { -filter("tint") } else { filter("tint") }, + // The files' tint runs from green (negative) to magenta (positive), in REST file JSON and + // .fig alike; a positive native tint adds green. + tint: -filter("tint"), highlights: filter("highlights"), shadows: filter("shadows"), }, diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 282476ef..97e5d828 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -187,7 +187,7 @@ fn paints_gradients_and_image_placeholders() { } #[test] -fn public_rest_image_filter_tint_keeps_its_sign() { +fn image_filter_tint_turns_magenta_positive_into_native_green_positive() { let rect = |name: &str, tint: f64| { json!({ "id": name, "name": name, "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, @@ -199,8 +199,9 @@ fn public_rest_image_filter_tint_keeps_its_sign() { let PaintKind::Image(image) = &named(&doc, name).fills[0].kind else { panic!("{name} is not an image fill") }; image }; - assert!(close(f64::from(image("Magenta").filters.tint), 0.75)); - assert!(close(f64::from(image("Green").filters.tint), -0.5)); + // The same sign as a `.fig` import (`fig::tests::image_filters`). + assert!(close(f64::from(image("Magenta").filters.tint), -0.75)); + assert!(close(f64::from(image("Green").filters.tint), 0.5)); assert!(!warnings.iter().any(|w| w.contains("approximate renderer")), "{warnings:?}"); } From 9e28e119548745adf9dbf09e96588ce79a9d83d9 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 17 Oct 2026 14:03:59 +0700 Subject: [PATCH 29/33] M3: number variables in the text content picker (#629) (#725) The Typography section's variable picker also lists number variables for a text layer's content (shown as the number), as the engine already accepted; other text fields keep their own types (#629). --- ROADMAP.md | 4 +++- crates/ui-egui/src/variable_ui.rs | 28 +++++++++++++++++++++++++++- docs/target-app-parity.md | 3 ++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bbde8bc5..006b81b9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. @@ -314,6 +315,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 04507c0c..e6cc6860 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -361,6 +361,12 @@ pub fn bound_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], field: &str, kin }); } +/// Whether a variable of type `have` belongs in a picker for `want` on `field`: its own type, and +/// numbers for text content too (shown as text). +fn kind_fits(want: Option, field: Option<&str>, have: VariableType) -> bool { + want.is_none_or(|k| have == k || (field == Some("characters") && have == VariableType::Number)) +} + /// The picker popover (call once per frame). pub fn picker(app: &mut FigApp, ctx: &egui::Context) { let Some(p) = ctx.data(|d| d.get_temp::(picker_id())) else { return }; @@ -379,8 +385,12 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { }; // Prototypes and styles use this file's variables. let local_only = matches!(p.target, Target::Proto(_) | Target::Style { .. }); + let field = match &p.target { + Target::Bind { field, .. } => Some(field.as_str()), + _ => None, + }; let keep = |lib: Option<&PublishedLibrary>, v: &Variable| { - p.kind.is_none_or(|k| v.kind == k) + kind_fits(p.kind, field, v.kind) && !(local_only && lib.is_some()) && (q.is_empty() || v.name.to_lowercase().contains(&q)) && scope.is_none_or(|s| v.in_scope(s)) @@ -476,3 +486,19 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { fn groups_any_kind(doc: &Document, libs: &[Arc], kind: Option) -> bool { !groups(doc, libs, |_, v| kind.is_none_or(|k| v.kind == k)).is_empty() } + +#[cfg(test)] +mod kind_tests { + use super::*; + + #[test] + fn text_content_offers_numbers_too() { + use VariableType::*; + assert!(kind_fits(Some(String), Some("characters"), Number), "a number shows as text"); + assert!(kind_fits(Some(String), Some("characters"), String)); + assert!(!kind_fits(Some(String), Some("characters"), Color)); + assert!(!kind_fits(Some(String), Some("fontFamily"), Number), "only text content"); + assert!(!kind_fits(Some(Number), Some("width"), String)); + assert!(kind_fits(None, None, Boolean)); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7c89f239..c3842d4f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -628,6 +628,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | From 1e85d549523a7908d173c61d18524c9a9a15451f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:04:00 +0700 Subject: [PATCH 30/33] #635: cap the .fig import warnings like the REST ones Image filter warnings name fields of the file, so a crafted schema could add any number of distinct warnings (each checked against all earlier ones). The mapper keeps at most as many as the REST importer does. --- crates/format/src/fig/image_filter_tests.rs | 21 +++++++++++++++++++++ crates/format/src/fig/map.rs | 3 ++- crates/format/src/rest/mod.rs | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/format/src/fig/image_filter_tests.rs b/crates/format/src/fig/image_filter_tests.rs index 83792123..3fa50a20 100644 --- a/crates/format/src/fig/image_filter_tests.rs +++ b/crates/format/src/fig/image_filter_tests.rs @@ -81,3 +81,24 @@ fn image_filters_import_from_current_container_and_skip_ambiguous_data() { } assert_eq!(warnings.iter().filter(|w| w.contains("approximate renderer")).count(), 1, "deduplicated approximation warning: {warnings:?}"); } + +#[test] +fn warnings_naming_unknown_filter_fields_are_capped() { + // A schema can name any number of fields; each nonzero unknown one is a warning of its own. + let names: Vec<&'static str> = (0..300).map(|i| &*Box::leak(format!("unknown{i}").into_boxed_str())).collect(); + let fields: Vec = names.iter().map(|n| format!("float {n}")).collect(); + let text = SCHEMA + .replace("message Paint ", &format!("message ManyFilters {}\nmessage Paint ", fields.join(", "))) + .replace("message Paint PaintType type,", "message Paint ManyFilters paintFilter, PaintType type,"); + let filters = o(names.iter().map(|n| (*n, f(0.5))).collect()); + let paint = o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))])), ("paintFilter", filters)]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node(2, Some((1, "!")), "RECTANGLE", "Photo", vec![("size", xy(2.0, 1.0)), ("fillPaints", arr(vec![paint]))]), + ]; + let bytes = zip_file(&canvas(&Schema::parse(&text).unwrap(), &o(vec![("nodeChanges", arr(nodes))]))); + let (_, warnings) = import(&bytes, "ManyFilters.fig").unwrap(); + let named = warnings.iter().filter(|w| w.contains("unsupported image filter")).count(); + assert!((100..=crate::rest::MAX_WARNINGS).contains(&named), "{named} of {} warnings", warnings.len()); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 62d10d71..9dc238dc 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -431,7 +431,8 @@ impl<'a> Mapper<'a> { fn warn(&mut self, w: impl Into) { let w = w.into(); - if !self.warnings.contains(&w) { + // Capped like the REST importer's list: some warnings name fields of the file (image filters). + if self.warnings.len() < crate::rest::MAX_WARNINGS && !self.warnings.contains(&w) { self.warnings.push(w); } } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 282c6a75..d42b04f3 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -66,7 +66,7 @@ const MAX_PATH_BYTES: usize = 4 << 20; /// Most path elements in one vector. const MAX_PATH_ELEMENTS: usize = 200_000; /// Most distinct warnings reported. -const MAX_WARNINGS: usize = 200; +pub(crate) const MAX_WARNINGS: usize = 200; /// Most shared style definitions read. const MAX_STYLES: usize = 10_000; /// Image key used for image fills without an `imageRef`. From d0539b83e9893329995c922926ff94fe5514e741 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:06:43 +0700 Subject: [PATCH 31/33] #631: follow the decoder rename in #639's connector check #639 lands first and reads networks with decode_styled; once #631 drops the count it calls network::decode. Needed whenever #631 is rebased onto a main that has #639. --- crates/format/src/fig/map.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 3ccf3a97..7a3dab5c 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1074,8 +1074,8 @@ impl<'a> Mapper<'a> { .get("vectorData") .and_then(|data| num(data, "vectorNetworkBlob")) .and_then(|index| self.blobs.get(index as usize)) - .and_then(|blob| network::decode_styled(blob)) - .is_some_and(|(network, _)| !network.segments.is_empty()); + .and_then(|blob| network::decode(blob)) + .is_some_and(|network| !network.segments.is_empty()); (geometry || network) && connector_record(v, "nodeGenerationData", 0).is_some() } From f757044d8cf91e63277481018d381716f7a76b80 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:08:05 +0700 Subject: [PATCH 32/33] fix/all: rustfmt the merged module list --- crates/format/src/fig/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index ca01611f..0d341307 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -28,12 +28,12 @@ mod slot_tests; mod source_identity_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod text_paragraph_tests; mod text_path; #[cfg(test)] mod text_path_tests; #[cfg(test)] -mod text_paragraph_tests; -#[cfg(test)] mod text_skip_ink_tests; #[cfg(test)] mod text_style_tests; From 4a572572dbb79bf66cfae89cdc96463cd503cafb Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:25:56 +0700 Subject: [PATCH 33/33] #635: no negative zero from the flipped tint --- crates/format/src/rest/props.rs | 4 ++-- crates/format/src/rest/tests.rs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 0ee2d687..5fe20c71 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -208,8 +208,8 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { saturation: filter("saturation"), temperature: filter("temperature"), // The files' tint runs from green (negative) to magenta (positive), in REST file JSON and - // .fig alike; a positive native tint adds green. - tint: -filter("tint"), + // .fig alike; a positive native tint adds green. (`0 - x`: no negative zero.) + tint: 0.0 - filter("tint"), highlights: filter("highlights"), shadows: filter("shadows"), }, diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 97e5d828..281d0896 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -194,7 +194,7 @@ fn image_filter_tint_turns_magenta_positive_into_native_green_positive() { "fills": [{"type": "IMAGE", "imageRef": "tint", "filters": {"tint": tint}}] }) }; - let (doc, warnings) = one(json!([rect("Magenta", 0.75), rect("Green", -0.5)])); + let (doc, warnings) = one(json!([rect("Magenta", 0.75), rect("Green", -0.5), rect("Neutral", 0.0)])); let image = |name: &str| { let PaintKind::Image(image) = &named(&doc, name).fills[0].kind else { panic!("{name} is not an image fill") }; image @@ -202,6 +202,7 @@ fn image_filter_tint_turns_magenta_positive_into_native_green_positive() { // The same sign as a `.fig` import (`fig::tests::image_filters`). assert!(close(f64::from(image("Magenta").filters.tint), -0.75)); assert!(close(f64::from(image("Green").filters.tint), 0.5)); + assert!(image("Neutral").filters.tint.is_sign_positive(), "no negative zero"); assert!(!warnings.iter().any(|w| w.contains("approximate renderer")), "{warnings:?}"); }