Merge fix/630

This commit is contained in:
bflatastic
2026-10-17 14:04:55 +07:00
22 changed files with 3343 additions and 122 deletions
+85
View File
@@ -529,6 +529,7 @@ fn load_with(
/// innermost first so outer instances settle last.
fn link_instances(doc: &mut Document) {
crate::style_vars::after_load(doc);
super::styles::apply_paint_bindings_on_load(doc);
let mut insts: Vec<(usize, NodeId)> =
doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect();
insts.sort_by_key(|a| std::cmp::Reverse(a.0));
@@ -1272,6 +1273,19 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
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<NodeId> = other
.library_store()
.and_then(|s| other.node(s))
.map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect())
.unwrap_or_default();
for root in &cached {
for id in other.subtree(*root) {
map.insert(id, doc.alloc_id());
}
}
let mut new_pages = Vec::new();
for pg in &other.pages {
let Some(pn) = other.node(*pg) else { continue };
@@ -1289,6 +1303,13 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
}
new_pages.push(np);
}
if !cached.is_empty() {
let store = doc.ensure_library_store();
for root in &cached {
let nodes: Vec<Node> = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect();
doc.paste_nodes(&nodes, &map, *root, store, None)?;
}
}
for new in map.values() {
let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied());
if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) {
@@ -1354,6 +1375,70 @@ mod rest_import_tests {
doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap()
}
/// What a .fig import with a cached library component leaves: the main in the hidden store, a
/// visible instance of it and a layer linked to the main's layer.
fn cached_import() -> Document {
let mut doc = Document::new("Cached");
let page = doc.first_page().unwrap();
let store = doc.ensure_library_store();
let main = doc
.create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0)
.unwrap();
let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap();
let inst =
doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap();
let mut copy = doc.node(body).unwrap().clone();
copy.id = doc.alloc_id();
copy.parent = None;
copy.source = Some(body);
let copy_id = copy.id;
doc.insert(copy, inst, None).unwrap();
assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body));
doc
}
/// Pasting layers that use a cached main into another document leaves the main behind, as it
/// does for other mains that are not published: no link points at a layer of the target.
#[test]
fn pasting_instances_of_cached_components_leaves_no_dangling_links() {
let src = cached_import();
let mut doc = Document::new("Target");
let page = doc.first_page().unwrap();
for i in 0..8 {
doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap();
}
let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id;
let ids = src.subtree(inst);
let map: std::collections::BTreeMap<NodeId, NodeId> = ids.iter().map(|i| (*i, doc.alloc_id())).collect();
let nodes: Vec<Node> = 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();
+21
View File
@@ -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<NodeId>>) {
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<NodeId>>, 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) {
+203 -23
View File
@@ -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<NodeId, Option<Vec<NodeId>>>,
parents: BTreeMap<(NodeId, NodeId), Option<bool>>,
main_changes: BTreeMap<NodeId, bool>,
}
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<Vec<NodeId>> {
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<bool> {
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<NodeId> {
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<Vec<(NodeId, NodeId)>> {
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<NodeId> {
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<String> = 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<String> = 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<NodeId> {
@@ -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));
}
+40
View File
@@ -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"));
}
+2
View File
@@ -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 {
+10 -3
View File
@@ -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(())
+46
View File
@@ -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);
}
+73
View File
@@ -5,6 +5,79 @@ use serde_json::json;
use crate::Session;
use crate::params::id_of;
fn stale_bound_color_document() -> (figcraft_doc::Document, figcraft_doc::NodeId) {
let mut s = Session::new();
let id = id_of(&s.execute("shape.rectangle", &json!({"x": 4000, "y": 4000, "width": 23, "height": 17})).unwrap()["id"]).unwrap();
let page = s.doc().unwrap().first_page().unwrap();
s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap();
s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#FFFFFF"})).unwrap();
s.execute("variable.create", &json!({"collection": "Theme", "name": "size", "type": "number", "value": 23})).unwrap();
s.execute("variable.set", &json!({"variable": "surface", "mode": "Dark", "value": "#202124"})).unwrap();
s.execute("variable.set", &json!({"variable": "size", "mode": "Dark", "value": 99})).unwrap();
s.execute("variable.bind", &json!({"ids": [id], "field": "fill", "variable": "surface"})).unwrap();
s.execute("variable.bind", &json!({"ids": [id], "field": "width", "variable": "size"})).unwrap();
s.execute("variable.setMode", &json!({"collection": "Theme", "mode": "Dark"})).unwrap();
let mut doc = s.doc().unwrap().clone();
let node = doc.get_mut(id).unwrap();
node.name = "load-binding-sentinel".into();
node.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)];
node.width = 5.0;
node.source = Some(page);
node.overrides.insert("sentinel".into());
doc.take_dirty();
(doc, id)
}
fn assert_dark_bound_color(doc: &figcraft_doc::Document, transform: figcraft_doc::geom::Affine) -> figcraft_doc::NodeId {
let node = doc.nodes().find(|n| n.name == "load-binding-sentinel").unwrap();
assert_eq!(node.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()), Some("202124".into()));
assert_eq!((node.transform, node.width, node.height), (transform, 5.0, 17.0));
assert!(node.bound.contains_key("fills/0/color") || node.bound.contains_key("fill"));
node.id
}
#[test]
fn initial_color_bindings_settle_in_constructor_and_loaders() {
let (stale, _id) = stale_bound_color_document();
let name_transform = stale.nodes().find(|n| n.name == "load-binding-sentinel").unwrap().transform;
let mut state = Session::with_document(stale.clone());
let id = assert_dark_bound_color(state.doc().unwrap(), name_transform);
assert_eq!(state.doc().unwrap().node(id).unwrap().source, stale.first_page());
assert!(state.doc().unwrap().node(id).unwrap().overrides.contains("sentinel"));
assert!(!state.ds().unwrap().is_dirty());
assert!(state.doc().unwrap().dirty().is_empty());
assert_eq!(state.undo_depth(), (0, 0));
state.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap();
assert_dark_bound_color(state.doc().unwrap(), name_transform);
let native = figcraft_format::save_bytes(&stale, None).unwrap();
let (loaded, _) = crate::cmd::file::load_bytes(&native, "bindings.figcraft").unwrap();
// A native file settles when it opens as a document, not in the loader.
let opened = Session::with_document(loaded);
let loaded = opened.doc().unwrap();
let loaded_id = assert_dark_bound_color(loaded, name_transform);
assert_eq!(loaded_id, id);
assert_eq!(loaded.node(id).unwrap().source, stale.first_page());
assert!(loaded.node(id).unwrap().overrides.contains("sentinel"));
assert!(loaded.dirty().is_empty());
let (fig, _) = figcraft_format::fig::export(&stale, None).unwrap();
let (loaded, _) = crate::cmd::file::load_bytes(&fig, "bindings.fig").unwrap();
assert_dark_bound_color(&loaded, name_transform);
}
#[test]
fn initial_paint_settlement_preserves_existing_dirty_nodes() {
let (mut doc, _) = stale_bound_color_document();
let page = doc.first_page().unwrap();
doc.mark_dirty([page]);
crate::cmd::styles::apply_paint_bindings_on_load(&mut doc);
assert_eq!(doc.dirty().iter().copied().collect::<Vec<_>>(), [page]);
}
#[test]
fn bound_layers_follow_variable_values_and_unbind() {
let mut s = Session::new();
+515 -62
View File
@@ -6,7 +6,7 @@
//! are expanded here from the main component (overrides and derived layout matched by id path,
//! component property values applied) so the import looks as saved.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::rc::Rc;
use figcraft_doc::geom::{Affine, Point};
@@ -23,6 +23,8 @@ const MAX_NODES: usize = crate::rest::MAX_NODES;
const MAX_DEPTH: usize = 256;
/// Most path commands read from one geometry blob.
const MAX_COMMANDS: usize = 200_000;
const MAX_GRID_TRACKS: usize = 1_000;
const MAX_GRID_TRACK_VALUE: f64 = 1e6;
/// Id of "no style".
const NO_STYLE: &str = "4294967295:4294967295";
/// Longest asset `key` or `version` kept for resolving references given as assets.
@@ -33,6 +35,9 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec<String>) {
let mut m = Mapper::new(msg);
let doc = m.document();
let mut out = json!({"name": name, "document": doc});
if !m.library_contexts.is_empty() {
out[crate::rest::FIG_LIBRARY_CONTEXTS_FIELD] = Value::Array(std::mem::take(&mut m.library_contexts));
}
if !m.styles.is_empty() {
out["styles"] = Value::Object(std::mem::take(&mut m.styles));
}
@@ -74,11 +79,33 @@ fn guid(v: &Kv) -> Option<String> {
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<String> {
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<String> {
match v.get(key).and_then(|r| r.get("guid")).and_then(guid) {
Some(id) => Some(id).filter(|g| g != NO_STYLE),
None => v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE),
}
}
fn color(c: &Kv) -> Value {
let ch = |k: &str| num(c, k).unwrap_or(0.0);
json!({"r": ch("r"), "g": ch("g"), "b": ch("b"), "a": num(c, "a").unwrap_or(1.0)})
@@ -142,6 +169,16 @@ impl<'a> View<'a> {
v
}
/// This view with `style` just above the node itself: the style beats the node's own copy of
/// its fields (stale when the style changed since), and override rows beat both.
fn styled(&self, style: Option<&'a Kv<'a>>) -> Self {
let mut v = self.clone();
if let Some(s) = style {
v.layers.insert(v.layers.len().min(1), s);
}
v
}
fn get(&self, k: &str) -> Option<&'a Kv<'a>> {
self.layers.iter().rev().find_map(|l| l.get(k))
}
@@ -221,7 +258,7 @@ impl Fit {
}
/// Overrides and derived layout by id path.
type Overrides<'a> = BTreeMap<Vec<String>, Vec<(Entry, &'a Kv<'a>)>>;
type Overrides<'a> = BTreeMap<Vec<String>, Vec<OverrideRow<'a>>>;
/// Where an override entry comes from.
#[derive(Clone, Copy, PartialEq)]
@@ -233,6 +270,25 @@ enum Entry {
Stale,
}
/// One override or derived-layout row addressed to a layer.
#[derive(Clone)]
struct OverrideRow<'a> {
row: &'a Kv<'a>,
entry: Entry,
/// How many mains are being expanded around the instance that wrote the row: the rows of
/// the enclosing (outer) instances win over those of the inner ones.
source_depth: usize,
/// Position among the rows of the instance that wrote it; later rows win.
ordinal: usize,
}
/// The layers of a main by current GUID, and the old `overrideKey` of each that has one
/// (`None` when two layers claim the same key).
struct OverrideScope {
current: BTreeSet<String>,
aliases: BTreeMap<String, Option<String>>,
}
/// Where an instance's expanded layers are: the overrides and derived layout of the outermost
/// instance (by id path), the path of the instance being expanded, and the property values.
#[derive(Clone)]
@@ -259,6 +315,16 @@ struct Mapper<'a> {
/// Descriptions of components and component sets (REST `components` / `componentSets`).
components: Obj,
sets: Obj,
/// Layers and keys of each main, built when an instance of it first needs them.
override_scopes: BTreeMap<String, Rc<OverrideScope>>,
/// 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<Value>,
/// 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<String>,
count: usize,
}
@@ -268,9 +334,19 @@ impl<'a> Mapper<'a> {
let mut nodes = BTreeMap::new();
let mut kids: BTreeMap<String, Vec<(&'a str, String)>> = BTreeMap::new();
let mut prop_defs = BTreeMap::new();
for n in msg.get("nodeChanges").map(Kv::list).unwrap_or_default() {
let changes = msg.get("nodeChanges").map(Kv::list).unwrap_or_default();
// The last change of a GUID wins; a REMOVED change deletes what came before it.
for n in changes {
let Some(id) = n.get("guid").and_then(guid) else { continue };
if n.get("phase").and_then(Kv::str) == Some("REMOVED") {
nodes.remove(&id);
} else {
nodes.insert(id, n);
}
}
for n in changes {
let Some(id) = n.get("guid").and_then(guid) else { continue };
if !nodes.get(&id).is_some_and(|latest| std::ptr::eq(*latest, n)) {
continue;
}
if let Some(p) = n.get("parentIndex")
@@ -285,7 +361,6 @@ impl<'a> Mapper<'a> {
prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty));
}
}
nodes.insert(id, n);
}
for list in kids.values_mut() {
list.sort();
@@ -302,7 +377,23 @@ impl<'a> Mapper<'a> {
let version = n.get("version").and_then(Kv::str).filter(|v| !v.is_empty() && v.len() <= MAX_ASSET_PART).unwrap_or("");
assets.entry(key.to_string()).or_default().entry(version.to_string()).or_insert(id);
}
Self { nodes, kids, blobs, prop_defs, assets, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 }
Self {
nodes,
kids,
blobs,
prop_defs,
assets,
styles: Obj::new(),
components: Obj::new(),
sets: Obj::new(),
override_scopes: BTreeMap::new(),
override_scope_entries: 0,
library_contexts: Vec::new(),
library_scan_count: 0,
max_nodes: MAX_NODES,
warnings: Vec::new(),
count: 0,
}
}
/// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`,
@@ -337,6 +428,50 @@ impl<'a> Mapper<'a> {
self.kids.get(id).map(|k| k.iter().map(|(_, c)| c.clone()).collect()).unwrap_or_default()
}
/// The layers of main `main` by current GUID and by `overrideKey`. An instance nested in the
/// main is not entered: its layers belong to another main.
fn override_scope(&mut self, main: &str) -> Rc<OverrideScope> {
if let Some(scope) = self.override_scopes.get(main) {
return Rc::clone(scope);
}
let mut current = BTreeSet::new();
let mut aliases: BTreeMap<String, Option<String>> = BTreeMap::new();
let mut visited = BTreeSet::new();
let mut pending: Vec<(String, usize)> = self.children(main).into_iter().map(|id| (id, 1)).collect();
let mut complete = true;
while let Some((id, depth)) = pending.pop() {
if depth > MAX_DEPTH || visited.len() >= MAX_NODES {
complete = false;
break;
}
if !visited.insert(id.clone()) {
continue;
}
let Some(node) = self.nodes.get(&id) else { continue };
current.insert(id.clone());
if let Some(alias) = node.get("overrideKey").and_then(guid) {
let target = aliases.entry(alias).or_insert_with(|| Some(id.clone()));
if target.as_deref().is_some_and(|target| target != id) {
*target = None;
}
}
if node.get("type").and_then(Kv::str) != Some("INSTANCE") {
pending.extend(self.children(&id).into_iter().map(|child| (child, depth + 1)));
}
}
// A scope cut short could name the wrong layer for a key: use none of the keys.
if !complete {
aliases.clear();
}
let scope = Rc::new(OverrideScope { current, aliases });
let cost = scope.current.len() + scope.aliases.len();
if cost <= MAX_NODES.saturating_sub(self.override_scope_entries) {
self.override_scope_entries += cost;
self.override_scopes.insert(main.to_string(), Rc::clone(&scope));
}
scope
}
/// The DOCUMENT with its pages.
fn document(&mut self) -> Value {
let doc_id = self.nodes.iter().find(|(_, n)| n.get("type").and_then(Kv::str) == Some("DOCUMENT")).map(|(id, _)| id.clone());
@@ -345,6 +480,8 @@ impl<'a> Mapper<'a> {
None => self.nodes.iter().filter(|(_, n)| n.get("type").and_then(Kv::str) == Some("CANVAS")).map(|(id, _)| id.clone()).collect(),
};
let mut out = Vec::new();
let mut internal = Vec::new();
let mut visible_pages = Vec::new();
for p in pages {
let Some(&n) = self.nodes.get(&p) else { continue };
if n.get("type").and_then(Kv::str) != Some("CANVAS") {
@@ -353,16 +490,171 @@ impl<'a> Mapper<'a> {
}
// The hidden page that holds styles, variables and library copies.
if n.get("internalOnly").and_then(Kv::bool) == Some(true) {
internal.push((p, n));
continue;
}
visible_pages.push(p.clone());
out.push(self.page(&p, n));
}
let required_mains = self.referenced_component_mains(&visible_pages);
for (id, canvas) in internal {
if let Some(context) = self.library_context(&id, canvas, &required_mains) {
self.library_contexts.push(context);
}
}
if out.is_empty() {
self.warn("the file has no pages");
}
json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT", "children": out})
}
/// The ids of the components the visible pages use, directly or through the components they
/// use in turn (nested instances, swap targets, overrides naming another main).
fn referenced_component_mains(&mut self, visible_pages: &[String]) -> BTreeSet<String> {
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<String> {
let mut targets: Vec<String> = 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<String>) -> Option<Value> {
let mut included = BTreeSet::new();
let mut parents: BTreeMap<String, String> = BTreeMap::new();
// The canvas itself is never a layer of the walk, whatever the parent links say.
let mut visited = BTreeSet::from([id.to_string()]);
// Each entry: layer, depth, inside a required definition.
let mut pending: Vec<(String, usize, bool)> = self.children(id).into_iter().map(|child| (child, 1, false)).collect();
while let Some((node_id, depth, in_definition)) = pending.pop() {
if depth > MAX_DEPTH {
self.warn(format!("internal library branches deeper than {MAX_DEPTH} levels were skipped"));
continue;
}
if !visited.insert(node_id.clone()) {
continue;
}
self.library_scan_count += 1;
if self.library_scan_count > self.max_nodes {
let max = self.max_nodes;
self.warn(format!("the internal library scan reached the {max}-layer limit; the remaining branches were skipped"));
break;
}
let Some(node) = self.nodes.get(&node_id) else { continue };
let kind = node.get("type").and_then(Kv::str);
let definition = (kind == Some("SYMBOL") || (kind == Some("FRAME") && node.get("isStateGroup").and_then(Kv::bool) == Some(true)))
&& required_mains.contains(&node_id);
let in_definition = in_definition || definition;
if in_definition {
included.insert(node_id.clone());
}
if definition {
// The frames around it keep the path its layers are addressed by.
let mut ancestor = parents.get(&node_id);
for _ in 0..=MAX_DEPTH {
let Some(parent) = ancestor else { break };
if !included.insert(parent.clone()) {
break;
}
ancestor = parents.get(parent);
}
}
for child in self.children(&node_id).into_iter().rev() {
// A layer reached again keeps the parent it was first reached by, so the parents
// lead back to the canvas.
if visited.contains(&child) {
continue;
}
parents.entry(child.clone()).or_insert_with(|| node_id.clone());
pending.push((child, depth + 1, in_definition));
}
}
if included.is_empty() {
return None;
}
let mut context = Obj::new();
context.insert("id".into(), json!(id));
context.insert("type".into(), json!("FRAME"));
copy_str(&mut context, &View::of(canvas), "name", "name");
context.insert("size".into(), json!({"x": 0.0, "y": 0.0}));
context.insert("relativeTransform".into(), rows(Affine::IDENTITY));
context.insert(crate::rest::FIG_SOURCE_FIELD.into(), json!({"origin": id, "expanded": false, "wireFrame": true, "wireInstance": false}));
self.variable_bindings(&mut context, &View::of(canvas));
let mut children = Vec::new();
for child in self.children(id) {
children.extend(self.layer_filtered(&child, None, Affine::IDENTITY, None, None, 0, Some(&included)));
}
if !children.is_empty() {
context.insert("children".into(), Value::Array(children));
}
Some(Value::Object(context))
}
fn page(&mut self, id: &str, n: &'a Kv<'a>) -> Value {
let v = View::of(n);
let mut o = Obj::new();
@@ -405,6 +697,24 @@ impl<'a> Mapper<'a> {
fit: Option<Fit>,
depth: usize,
) -> Option<Value> {
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<Fit>,
depth: usize,
included: Option<&BTreeSet<String>>,
) -> Option<Value> {
if included.is_some_and(|ids| !ids.contains(id)) {
return None;
}
if depth > MAX_DEPTH {
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
return None;
@@ -415,24 +725,24 @@ impl<'a> Mapper<'a> {
}
let base = *self.nodes.get(id)?;
let path = inst.map(|i| [i.path.as_slice(), &[id.to_string()]].concat());
let entries = match (inst, &path) {
let entries: &[OverrideRow<'a>] = match (inst, &path) {
(Some(i), Some(p)) => i.overrides.get(p).map(Vec::as_slice).unwrap_or_default(),
_ => &[],
};
let mut view = View::of(base).with(entries.iter().map(|e| e.1));
let mut view = View::of(base).with(entries.iter().map(|e| e.row));
// A layer of a resized instance the file gives no derived layout for follows its
// constraints from the main's geometry, then takes the size and place overridden on it
// (auto layout places the others, as in the editor).
let derived = entries.iter().any(|(d, l)| *d == Entry::Derived && (l.get("size").is_some() || l.get("transform").is_some()));
let derived = entries.iter().any(|e| e.entry == Entry::Derived && (e.row.get("size").is_some() || e.row.get("transform").is_some()));
let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID"))
&& view.str("stackPositioning") != Some("ABSOLUTE");
let main = View::of(base);
let set = |k: &str| entries.iter().rev().filter(|e| e.0 != Entry::Stale).find_map(|(_, l)| l.get(k));
let set = |k: &str| entries.iter().rev().filter(|e| e.entry != Entry::Stale).find_map(|e| e.row.get(k));
if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) {
let t = main.transform().unwrap_or_default();
let (fw, ft) = f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint"));
view.fitted = Some((set("size").and_then(size).unwrap_or(fw), set("transform").and_then(matrix).unwrap_or(ft)));
} else if entries.iter().any(|(d, l)| *d == Entry::Stale && (l.get("size").is_some() || l.get("transform").is_some()))
} else if entries.iter().any(|e| e.entry == Entry::Stale && (e.row.get("size").is_some() || e.row.get("transform").is_some()))
&& let Some(s) = set("size").and_then(size).or_else(|| main.size())
{
view.fitted = Some((s, set("transform").and_then(matrix).or_else(|| main.transform()).unwrap_or_default()));
@@ -457,6 +767,11 @@ impl<'a> Mapper<'a> {
self.count += 1;
let mut o = Obj::new();
o.insert("id".into(), json!(rest_id));
// Private provenance for the .fig importer; ordinary REST import ignores it.
o.insert(
crate::rest::FIG_SOURCE_FIELD.into(),
json!({"origin": id, "expanded": inst.is_some(), "wireFrame": own == "FRAME", "wireInstance": own == "INSTANCE"}),
);
o.insert("type".into(), json!(ty));
copy_str(&mut o, &view, "name", "name");
self.common(&mut o, &view, ty, space, parent);
@@ -491,7 +806,8 @@ impl<'a> Mapper<'a> {
let main = self.nodes.get(&sym).map(|m| View::of(m));
let fit = Fit::frame(main.and_then(|m| m.size()), view.size());
for c in self.children(&sym) {
kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), fit, depth + 1));
// The main's branch can live outside the cached context being mapped.
kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None));
}
}
} else {
@@ -503,7 +819,7 @@ impl<'a> Mapper<'a> {
(Some(_), _) => Fit::frame(View::of(base).size(), view.size()),
};
for c in self.children(id) {
kids.extend(self.layer(&c, inst, child_space, Some(&view), fit, depth + 1));
kids.extend(self.layer_filtered(&c, inst, child_space, Some(&view), fit, depth + 1, included));
}
}
if stand.is_some_and(|s| s.0 == "FRAME") {
@@ -719,7 +1035,7 @@ impl<'a> Mapper<'a> {
/// The style node a layer links in `key` (or the older `legacy` field), recorded for the REST
/// `styles` map under `slot`.
fn style(&mut self, v: &View<'a>, key: &str, legacy: &str, slot: &str, out: &mut Obj) -> Option<&'a Kv<'a>> {
let id = v.get(key).and_then(ref_guid).or_else(|| v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE))?;
let id = style_link(v, key, legacy)?;
let node = *self.nodes.get(&id)?;
node.get("styleType").and_then(Kv::str)?;
out.insert(slot.into(), json!(id));
@@ -775,35 +1091,100 @@ impl<'a> Mapper<'a> {
copy_num(o, v, "gridRowGap", "gridRowGap");
o.insert("gridColumnCount".into(), json!(tracks(v, "gridColumns").len()));
o.insert("gridRowCount".into(), json!(tracks(v, "gridRows").len()));
let sizes = ["gridColumnsSizing", "gridRowsSizing"]
.iter()
.flat_map(|k| v.get(k).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default());
let flexible = sizes.into_iter().all(|e| {
let t = e.get("trackSize");
["minSizing", "maxSizing"]
.iter()
.all(|s| t.and_then(|t| t.get(s)).and_then(|s| s.get("type")).and_then(Kv::str).is_none_or(|x| x == "FLEX"))
});
if !flexible {
self.warn("grid tracks of fixed or content size import as equal tracks");
let track_data = [("columnTracks", "gridColumns", "gridColumnsSizing"), ("rowTracks", "gridRows", "gridRowsSizing")]
.into_iter()
.filter_map(|(out_key, order_key, sizing_key)| {
self.map_grid_tracks(v, order_key, sizing_key).map(|mapped| (out_key.to_string(), mapped))
})
.collect::<Obj>();
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<Value> {
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<String, Option<&Kv<'a>>> = BTreeMap::new();
for entry in sizes {
let Some(id) = entry.get("id").and_then(guid) else {
self.warn("stale or invalid grid track sizing entries were ignored");
continue;
};
if !ordered_ids.contains(id.as_str()) {
self.warn("stale or invalid grid track sizing entries were ignored");
continue;
}
match size_by_id.entry(id) {
std::collections::btree_map::Entry::Vacant(slot) => {
slot.insert(Some(entry));
}
std::collections::btree_map::Entry::Occupied(mut slot) => {
slot.insert(None);
self.warn("duplicate grid track sizing entries use equal tracks");
}
}
}
let mut mapped = Vec::with_capacity(ordered.len());
for id in ordered {
if let Some(Some(entry)) = size_by_id.get(&id) {
match grid_track_sizing(entry.get("trackSize")) {
Some(track) => mapped.push(track),
None => {
self.warn("grid track sizing is unsupported or malformed; affected tracks use equal sizing");
mapped.push(json!({"sizing": "fill", "value": 1.0}));
}
}
} else {
mapped.push(json!({"sizing": "fill", "value": 1.0}));
}
}
Some(Value::Array(mapped))
}
fn text(&mut self, o: &mut Obj, v: &View<'a>) {
let style_node = v
.get("styleIdForText")
.and_then(ref_guid)
.or_else(|| v.get("inheritTextStyleID").and_then(guid))
.and_then(|s| self.nodes.get(&s).copied());
let sv = v.with(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")));
let style_node = style_link(v, "styleIdForText", "inheritTextStyleID").and_then(|s| self.nodes.get(&s).copied());
// Instance overrides beat the shared style; the style beats the layer's own copy.
let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")));
let td = v.get("textData");
let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or("");
o.insert("characters".into(), json!(chars));
let mut style = text_style(&sv, None);
// The style's typography beats the layer's own copy of it (the copy is stale when the style
// changed). The file cannot tell a stale copy of these four values from a deliberate local
// one, and "original", "none" and 0 could never be set locally if the style always won, so
// a value the layer or an override sets itself is taken as deliberate.
if let Some(decoration @ ("NONE" | "UNDERLINE" | "STRIKETHROUGH")) = v.str("textDecoration") {
style.insert("textDecoration".into(), json!(decoration));
}
if let Some(case @ ("ORIGINAL" | "UPPER" | "LOWER" | "TITLE" | "SMALL_CAPS" | "SMALL_CAPS_FORCED")) = v.str("textCase") {
style.insert("textCase".into(), json!(case));
}
for k in ["textAlignHorizontal", "textAlignVertical", "textAutoResize", "textTruncation"] {
copy_str(&mut style, v, k, k);
}
// Paragraph settings belong to the effective text layer, including explicit zero clears.
for k in ["paragraphSpacing", "paragraphIndent"] {
copy_num(&mut style, v, k, k);
}
if let Some(n) = v.num("maxLines").filter(|n| *n >= 1.0) {
style.insert("maxLines".into(), json!(n));
}
@@ -813,20 +1194,7 @@ impl<'a> Mapper<'a> {
}
o.insert("style".into(), Value::Object(style));
let Some(td) = td else { return };
let ids: Vec<u64> = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect();
if ids.iter().any(|i| *i != 0) {
let mut table = Obj::new();
for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() {
let Some(sid) = num(e, "styleID") else { continue };
let mut s = text_style(&View::of(e), size);
if let Some(fills) = e.get("fillPaints") {
s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(paint).collect()));
}
table.insert((sid as u64).to_string(), Value::Object(s));
}
o.insert("characterStyleOverrides".into(), json!(ids));
o.insert("styleOverrideTable".into(), Value::Object(table));
}
char_styles(o, td, size);
let lines: Vec<&str> = td
.get("lines")
.map(Kv::list)
@@ -880,10 +1248,13 @@ impl<'a> Mapper<'a> {
fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool {
let Some(vd) = v.get("vectorData") else { return false };
let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false };
let Some(net) = network::decode(blob) else {
let Some((net, styled_segments)) = network::decode_styled(blob) else {
self.warn("damaged vector networks import as outlines");
return false;
};
if styled_segments > 0 {
self.warn("stroke styles of single vector network segments were left out");
}
// An open path shows its fill, which regions can't hold: the outline keeps the look.
let fills_shown =
o.get("fills").and_then(Value::as_array).is_some_and(|f| f.iter().any(|p| p.get("visible").and_then(Value::as_bool) != Some(false)));
@@ -1011,6 +1382,16 @@ impl<'a> Mapper<'a> {
"characters" => {
if let Some(t) = prop_text(val) {
o.insert("characters".into(), json!(t));
// Run styles count the chars they were made for: for other chars only
// the value's own apply.
if own_text(v) != Some(t) {
o.remove("characterStyleOverrides");
o.remove("styleOverrideTable");
if let Some(tv) = val.get("textValue") {
let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64);
char_styles(o, tv, size);
}
}
}
}
_ => {}
@@ -1064,7 +1445,18 @@ impl<'a> Mapper<'a> {
}
// Overrides by id path below this instance (the instance itself is addressed by its main's id).
let own_path: Vec<String> = outer.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()).unwrap_or_default();
let mut overrides: Overrides<'a> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default();
let scope = self.override_scope(&sym);
let mut overrides: Overrides<'a> = BTreeMap::new();
if let Some(inherited) = outer {
for (path, rows) in inherited.overrides.iter() {
let mut path = path.clone();
// Rows an outer instance wrote for this instance's layers may use their old keys.
if path.starts_with(&own_path) && path.len() > own_path.len() {
normalize_override_segment(&mut path, own_path.len(), &scope);
}
overrides.entry(path).or_default().extend(rows.iter().cloned());
}
}
// A nested instance's derived layout is for the size it has in its main: resized here, the
// sizes and places in it no longer hold and its layers follow their constraints instead.
let differs = |a: f64, b: f64| (a - b).abs() > 1e-6 * a.abs().max(b.abs()).max(1.0);
@@ -1073,30 +1465,41 @@ impl<'a> Mapper<'a> {
(Some(a), Some(b)) => differs(a.0, b.0) || differs(a.1, b.1),
(a, b) => a.is_some() != b.is_some(),
};
let mut local: Overrides<'a> = BTreeMap::new();
let lists = [sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), v.list("derivedSymbolData")];
let source_depth = outer.map_or(0, |i| i.symbols.len());
let mut ordinal = 0;
for (layer, list) in lists.into_iter().enumerate() {
for ov in list {
let rel: Vec<String> =
let row_ordinal = ordinal;
ordinal += 1;
let mut rel: Vec<String> =
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<String, &'a Kv<'a>> = outer.map(|i| (*i.props).clone()).unwrap_or_default();
for a in assignments {
if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) {
@@ -1671,6 +2074,32 @@ fn tracks(v: &View, key: &str) -> Vec<String> {
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<Value> {
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<String> {
let d = d?;
@@ -1697,6 +2126,26 @@ fn variable_value(d: &Kv) -> Option<Value> {
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<f64>) {
let ids: Vec<u64> = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect();
if ids.iter().all(|i| *i == 0) {
return;
}
let mut table = Obj::new();
for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() {
let Some(sid) = num(e, "styleID") else { continue };
let mut s = text_style(&View::of(e), size);
if let Some(fills) = e.get("fillPaints") {
s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(paint).collect()));
}
table.insert((sid as u64).to_string(), Value::Object(s));
}
o.insert("characterStyleOverrides".into(), json!(ids));
o.insert("styleOverrideTable".into(), Value::Object(table));
}
/// The text of a component property value.
fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> {
v.get("textValue").and_then(|t| t.get("characters")).and_then(Kv::str)
@@ -1734,7 +2183,11 @@ fn text_style(v: &View, size: Option<f64>) -> 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));
+5 -1
View File
@@ -21,7 +21,11 @@ pub mod kiwi;
mod map;
mod network;
#[cfg(test)]
mod source_identity_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod text_style_tests;
use std::io::Read;
@@ -64,7 +68,7 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), 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))
}
+9 -4
View File
@@ -64,8 +64,10 @@ impl Reader<'_> {
}
}
/// The network in blob `b`; `None` when it's truncated, out of range or not finite.
pub(super) fn decode(b: &[u8]) -> Option<Network> {
/// The network in blob `b` and how many of its segments have a style id of their own (per-segment
/// stroke overrides, which the document model can't hold); `None` when it's truncated, out of
/// range or not finite.
pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> {
let mut r = Reader { b, pos: 0 };
let (nv, ns, nr) = (r.u32()? as usize, r.u32()? as usize, r.u32()? as usize);
if nv > MAX_ITEMS || ns > MAX_ITEMS || nr > MAX_ITEMS || nv * 12 + ns * 28 + nr * 8 > b.len().saturating_sub(12) {
@@ -75,8 +77,11 @@ pub(super) fn decode(b: &[u8]) -> Option<Network> {
for _ in 0..nv {
net.vertices.push(Vertex { style: r.u32()?, x: r.f32()?, y: r.f32()? });
}
let mut styled = 0usize;
for _ in 0..ns {
r.u32()?;
if r.u32()? != 0 {
styled += 1;
}
net.segments.push(Segment { a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) });
}
let mut entries = 0usize;
@@ -93,7 +98,7 @@ pub(super) fn decode(b: &[u8]) -> Option<Network> {
}
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.
File diff suppressed because it is too large Load Diff
+25 -6
View File
@@ -262,6 +262,25 @@ fn vectors_import_as_editable_networks() {
assert!(warnings.iter().any(|w| w.contains("damaged vector networks import as outlines")), "{warnings:?}");
}
#[test]
fn per_segment_stroke_styles_are_reported() {
let mut blob = square_blob();
// The first segment's style id follows the counts and the vertices (12 bytes each).
blob[12 + 4 * 12..12 + 4 * 12 + 4].copy_from_slice(&7u32.to_le_bytes());
let (net, styled) = network::decode_styled(&blob).unwrap();
assert_eq!((net.segments.len(), styled), (4, 1));
assert_eq!(network::decode_styled(&square_blob()).unwrap().1, 0);
let plain = import(&tail_canvas(), "Tail").unwrap().1;
assert!(!plain.iter().any(|w| w.contains("stroke styles of single")), "{plain:?}");
let mut message = tail_message();
let Kv::Object(fields) = &mut message else { panic!() };
let Kv::Array(blobs) = &mut fields.iter_mut().find(|(key, _)| *key == "blobs").unwrap().1 else { panic!() };
blobs[1] = o(vec![("bytes", Kv::Bytes(blob))]);
let (doc, warnings) = import(&canvas(&schema(), &message), "Tail").unwrap();
assert!(warnings.iter().any(|w| w.contains("stroke styles of single vector network segments were left out")), "{warnings:?}");
assert_eq!(network_of(&doc, "Wave").segments.len(), 4);
}
#[test]
fn other_layer_kinds_import_as_the_closest_layers() {
let (doc, warnings) = import(&tail_canvas(), "Tail").unwrap();
@@ -336,9 +355,9 @@ fn unreadable_conditions_skip_the_conditional() {
#[test]
fn damaged_network_blobs_never_panic() {
let good = square_blob();
assert!(network::decode(&good).is_some());
assert!(network::decode_styled(&good).is_some());
for cut in 0..good.len() {
assert!(network::decode(&good[..cut]).is_none(), "{cut}");
assert!(network::decode_styled(&good[..cut]).is_none(), "{cut}");
}
let mut r = Lcg(5);
for _ in 0..5000 {
@@ -347,7 +366,7 @@ fn damaged_network_blobs_never_panic() {
let i = (r.next() as usize) % m.len();
m[i] ^= 1 << (r.next() % 8);
}
if let Some(net) = network::decode(&m) {
if let Some((net, _)) = network::decode_styled(&m) {
assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len()));
assert!(net.regions.iter().flat_map(|r| &r.loops).flatten().all(|i| *i < net.segments.len()));
assert!(net.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite()));
@@ -356,13 +375,13 @@ fn damaged_network_blobs_never_panic() {
// Counts larger than the blob, and indices out of range.
let mut huge = vec![0xff; 12];
huge.extend_from_slice(&[0; 64]);
assert!(network::decode(&huge).is_none());
assert!(network::decode_styled(&huge).is_none());
let mut bad = arrow_blob();
bad[12 + 24 + 4..12 + 24 + 8].copy_from_slice(&7u32.to_le_bytes());
assert!(network::decode(&bad).is_none());
assert!(network::decode_styled(&bad).is_none());
for _ in 0..2000 {
let junk: Vec<u8> = (0..(r.next() % 120)).map(|_| r.next() as u8).collect();
let _ = network::decode(&junk);
let _ = network::decode_styled(&junk);
}
}
+169
View File
@@ -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<Kv<'static>> = (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();
+292
View File
@@ -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<u64>, 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<Kv<'static>>) -> 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<u64> = 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));
}
+316
View File
@@ -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<NodeId, Origin<'a>>,
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::<u64>().is_ok())
}
fn expanded_id(instance: &str, origin: &str) -> Option<String> {
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<Origin<'a>> {
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<String, NodeId>) -> Option<NodeId> {
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<NodeId> {
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<String, NodeId>) -> 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<String, NodeId>) -> Option<NodeId> {
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<String, NodeId>) -> 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<String, NodeId>) -> Option<NodeId> {
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<String, NodeId>,
memo: &mut BTreeMap<NodeId, Option<NodeId>>,
active: &mut BTreeSet<NodeId>,
depth: usize,
) -> Option<NodeId> {
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<String, NodeId>) -> 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<NodeId, Option<NodeId>>) -> bool {
let mut current = id;
for _ in 0..=MAX_DEPTH {
let Some(next) = links.get(&current).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());
}
}
@@ -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
);
}
+76 -19
View File
@@ -16,7 +16,8 @@
//!
//! Instances come out with their own layers as the file lists them. Linking those layers to the
//! main component's (`source`) and deriving overrides is left to the caller (the engine does it
//! after loading), since that needs the component model.
//! after loading), since that needs the component model. The private .fig entry point additionally
//! preserves explicit GUID/expansion provenance before the engine derives overrides.
#[cfg(test)]
mod blur_tests;
@@ -24,8 +25,11 @@ mod blur_tests;
mod conditional_tests;
#[cfg(test)]
mod effects_tests;
mod fig_sources;
#[cfg(test)]
mod gamepad_tests;
#[cfg(test)]
mod import_regression_tests;
pub(crate) mod props;
pub(crate) mod proto;
#[cfg(test)]
@@ -53,6 +57,10 @@ pub use variables::export as export_variables;
pub const MAX_NODES: usize = 200_000;
/// Deepest layer nesting followed.
const MAX_DEPTH: usize = 256;
/// Private .fig provenance, consumed only by `import_fig`.
pub(crate) const FIG_SOURCE_FIELD: &str = "_figSource";
/// Hidden .fig library context; accepted only by [`import_fig`].
pub(crate) const FIG_LIBRARY_CONTEXTS_FIELD: &str = "_figLibraryContexts";
/// Longest SVG path string read from one geometry entry.
const MAX_PATH_BYTES: usize = 4 << 20;
/// Most path elements in one vector.
@@ -138,12 +146,22 @@ pub fn import(v: &Value, images: ImageResolver) -> Result<(Document, Vec<String>
/// 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<String>), 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<String>), FormatError> {
import_impl(v, None, images, true)
}
fn import_impl(v: &Value, variables: Option<&Value>, images: ImageResolver, fig_sources: bool) -> Result<(Document, Vec<String>), 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<NodeId> = None;
for e in &entries {
@@ -178,6 +196,12 @@ pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver)
None => cx.warn("entries without a node type were skipped"),
}
}
if fig_sources {
for context in v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).into_iter().flatten() {
let store = cx.doc.ensure_library_store();
cx.build(context, store, Space::default(), None, 0, false);
}
}
// Drop the empty page the new document started with once real pages exist.
if let Some(p) = blank
&& cx.doc.pages.len() > 1
@@ -210,7 +234,7 @@ fn style_binding(slot: &str, key: &str) -> Option<String> {
/// Put back the shape a layer was before the `.fig` export wrote it as the outline of its stroke
/// (`outlinedStroke`: `{kind, fills, strokes, stroke}` as the document holds them). Anything that
/// isn't such a shape is ignored.
fn restore_stroke(n: &mut Node, original: &Value) {
fn restore_stroke(n: &mut Node, original: &Value) -> bool {
#[derive(serde::Deserialize)]
struct Original {
kind: NodeKind,
@@ -218,16 +242,16 @@ fn restore_stroke(n: &mut Node, original: &Value) {
strokes: Vec<figcraft_doc::color::Paint>,
stroke: figcraft_doc::StrokeProps,
}
let Ok(mut o) = serde_json::from_value::<Original>(original.clone()) else { return };
let Ok(mut o) = serde_json::from_value::<Original>(original.clone()) else { return false };
match &mut o.kind {
NodeKind::Vector { network } => {
if network.vertices.len() + network.segments.len() > MAX_PATH_ELEMENTS {
return;
return false;
}
network.sanitize();
}
NodeKind::Line | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Rectangle => {}
_ => return,
_ => return false,
}
let sp = &mut o.stroke;
sp.weight = sp.weight.clamp(0.0, props::MAX_SIZE);
@@ -237,6 +261,28 @@ fn restore_stroke(n: &mut Node, original: &Value) {
o.fills.truncate(64);
o.strokes.truncate(64);
(n.kind, n.fills, n.strokes, n.stroke) = (o.kind, o.fills, o.strokes, o.stroke);
true
}
/// A stroke-only vector imported as the outline of its stroke: the stroke paints fill the
/// outline, and the stroke's shared style and colour variables move with them. The hidden fills
/// it replaces go, with their style and bindings.
fn outline_stroke(n: &mut Node) {
n.fills = std::mem::take(&mut n.strokes);
n.styles.fill = n.styles.stroke.take();
let bound = std::mem::take(&mut n.bound);
for (key, var) in bound {
let moved = match key.as_str() {
"fill" => continue,
k if k.starts_with("fills/") => continue,
"stroke" => "fill".to_string(),
k => match k.strip_prefix("strokes/") {
Some(rest) => format!("fills/{rest}"),
None => key,
},
};
n.bound.insert(moved, var);
}
}
fn children(v: &Value) -> &[Value] {
@@ -291,6 +337,7 @@ pub(crate) struct Ctx<'a> {
count: usize,
/// REST node id → imported node.
ids: BTreeMap<String, NodeId>,
fig_sources: Option<fig_sources::Sources<'a>>,
component_meta: BTreeMap<&'a str, &'a Value>,
set_meta: BTreeMap<&'a str, &'a Value>,
style_meta: BTreeMap<&'a str, &'a Value>,
@@ -321,6 +368,7 @@ impl<'a> Ctx<'a> {
warnings: Warnings::default(),
count: 0,
ids: BTreeMap::new(),
fig_sources: None,
component_meta: BTreeMap::new(),
set_meta: BTreeMap::new(),
style_meta: BTreeMap::new(),
@@ -411,7 +459,10 @@ impl<'a> Ctx<'a> {
}
self.count += 1;
if let Some(rid) = text(j, "id") {
self.ids.entry(rid.to_string()).or_insert(id);
let primary = *self.ids.entry(rid.to_string()).or_insert(id) == id;
if let Some(sources) = &mut self.fig_sources {
sources.record(id, j, primary);
}
}
if ty == "INSTANCE" {
self.instances.push((id, j));
@@ -548,7 +599,7 @@ impl<'a> Ctx<'a> {
if let Some(c) = flag(j, "clipsContent") {
n.clip = c;
}
n.layout = props::auto_layout(j);
n.layout = props::auto_layout(j, self.fig_sources.is_some());
n.grids = props::layout_grids(j);
}
n.child_layout = props::child_layout(j, n.layout.as_ref().map(|l| l.mode), parent_mode);
@@ -558,9 +609,7 @@ impl<'a> Ctx<'a> {
n.prop_refs.insert(field.clone(), p.to_string());
}
}
if matches!(n.kind, NodeKind::Vector { .. }) {
self.vector(n, j);
}
let outlined = matches!(n.kind, NodeKind::Vector { .. }) && self.vector(n, j);
if matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }) {
let defs = self.prop_defs(n.id, j);
match &mut n.kind {
@@ -581,20 +630,24 @@ impl<'a> Ctx<'a> {
self.overlay_meta.entry(rid).or_insert(j);
}
self.bindings(n, j);
if let Some(original) = j.get("outlinedStroke") {
restore_stroke(n, original);
let restored = j.get("outlinedStroke").is_some_and(|original| restore_stroke(n, original));
// Styles and bindings were read against the stroke: they follow its paints to the fill.
if outlined && !restored {
outline_stroke(n);
}
}
/// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, our
/// extension, written by the `.fig` import), else from its geometry. A stroke-only vector
/// uses the stroke outline, painted with the stroke paints (exact look, editable as a shape).
fn vector(&mut self, n: &mut Node, j: &Value) {
/// uses the stroke outline, painted with the stroke paints (exact look, editable as a shape):
/// returns whether it does, and the caller moves the paints with [`outline_stroke`] once the
/// styles and variables of the stroke are read.
fn vector(&mut self, n: &mut Node, j: &Value) -> bool {
if let Some(net) = j.get("vectorNetwork") {
match network(net) {
Some(network) => {
n.kind = NodeKind::Vector { network };
return;
return false;
}
None => self.warn("invalid or oversized vector networks import as outlines"),
}
@@ -609,17 +662,15 @@ impl<'a> Ctx<'a> {
(None, Some(s)) => (s, true),
(None, None) => {
self.warn("vectors without geometry are empty (export the file JSON with geometry=paths)");
return;
return false;
}
};
let mut network = VectorNetwork::from_path(&path);
for r in &mut network.regions {
r.winding = winding;
}
if outline {
n.fills = std::mem::take(&mut n.strokes);
}
n.kind = NodeKind::Vector { network };
outline
}
/// The paths of a `fillGeometry` / `strokeGeometry` list (node-local SVG path data).
@@ -822,6 +873,12 @@ impl<'a> Ctx<'a> {
}
}
self.resolve_instances();
if let Some(sources) = self.fig_sources.take() {
let missing = sources.apply(&mut self.doc, &self.ids);
if missing > 0 {
self.warn(format!("{missing} .fig instance source link(s) could not be resolved and were left unlinked"));
}
}
self.rename_props();
for (id, j) in std::mem::take(&mut self.protos) {
let list = self.interactions(j);
+25 -4
View File
@@ -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<Constraints> {
// ---- auto layout ----
/// The auto layout of a frame-like node (`layoutMode` other than `NONE`).
pub(super) fn auto_layout(v: &Value) -> Option<AutoLayout> {
pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option<AutoLayout> {
let mode = match text(v, "layoutMode")? {
"HORIZONTAL" => LayoutMode::Horizontal,
"VERTICAL" => LayoutMode::Vertical,
@@ -432,10 +432,31 @@ pub(super) fn auto_layout(v: &Value) -> Option<AutoLayout> {
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<GridTrack> {
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.
+2
View File
@@ -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));
}
+6
View File
@@ -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,