diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 3d410e07d..98239cd7b 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -48,6 +48,7 @@ const MAX_ASSET_PART: usize = 256; /// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]). pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec, Vec>) { let mut m = Mapper::new(msg, max_nodes); + let _canon = VarCanon::install(&m); let doc = m.document(); // Large values are moved in, never passed through `json!`, which serialises its arguments: // a deep copy of the whole tree, briefly holding it twice. @@ -365,6 +366,30 @@ impl<'a> Mapper<'a> { .cloned() } + /// The collection a `VariableSetID` names, as one collection per library asset: a file that + /// took several updates of a library holds a copy of the collection for each version it met, + /// all with the same asset `key`. Variables and explicit modes may name any of those copies, + /// while the mode ids stay the same across them, so every copy maps to the newest one (the + /// one [`Self::variables`] keeps, with the modes of the others added). Without this, a page set + /// to a mode an older copy didn't have yet lost its mode, and the layers fell back to the + /// default mode of the newest copy. + pub(super) fn collection_id(&self, v: &Kv) -> Option { + let id = self.resource_id(v)?; + Some(self.canonical_collection(&id).unwrap_or(id)) + } + + /// The newest live copy of collection node `id`'s library asset, when it has a key. + pub(super) fn canonical_collection(&self, id: &str) -> Option { + let key = self.nodes.get(id)?.get("key").and_then(Kv::str).filter(|k| !k.is_empty())?; + let live_set = |n: &Kv| n.get("type").and_then(Kv::str) == Some("VARIABLE_SET") && n.get("isSoftDeleted").and_then(Kv::bool) != Some(true); + self.assets + .get(key)? + .iter() + .filter(|(_, nid)| self.nodes.get(nid.as_str()).is_some_and(|n| live_set(n))) + .max_by_key(|(ver, _)| version_rank(ver)) + .map(|(_, nid)| nid.clone()) + } + /// A note about what this file's mapping left out or changed. Counted, as the REST importer /// counts its own: these notes are met once per layer, so the kinds were named once each with /// no sign of how much of the file they covered -- a file whose every text layer lost a @@ -384,3 +409,82 @@ impl<'a> Mapper<'a> { self.max_nodes.saturating_sub(self.reserved) } } + +/// A library version (`"40727:1183"`) as numbers to order by; unparsable parts rank lowest. +fn version_rank(v: &str) -> (u64, u64) { + let mut parts = v.split(':').map(|p| p.trim().parse::().unwrap_or(0)); + (parts.next().unwrap_or(0), parts.next().unwrap_or(0)) +} + +/// Library variables, one per asset. A file that took several updates of a library keeps a copy +/// of a variable for each version it met (same `key`, another `version`), and layers and other +/// variables name them by that asset (`assetRef {key, version}`) rather than by guid. Each +/// reference goes to the newest copy: it has the values of every mode the library has now, where +/// an older one lacks the modes added since (a layer under a page set to such a mode fell back to +/// the default one). Kept per thread while a file is mapped: the paint and variable readers that +/// meet references are free functions. +#[derive(Default)] +pub(super) struct VarCanon { + by_key: HashMap, + by_id: HashMap, +} + +thread_local! { + static VAR_CANON: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// Removes the thread's table when the mapping is done. +pub(super) struct VarCanonGuard; + +impl Drop for VarCanonGuard { + fn drop(&mut self) { + VAR_CANON.with(|c| c.borrow_mut().take()); + } +} + +impl VarCanon { + fn install(m: &Mapper) -> VarCanonGuard { + let mut newest: HashMap = HashMap::new(); + let mut copies: Vec<(String, String)> = Vec::new(); + for (id, n) in &m.nodes { + if n.get("type").and_then(Kv::str) != Some("VARIABLE") || n.get("isSoftDeleted").and_then(Kv::bool) == Some(true) { + continue; + } + let Some(key) = n.get("key").and_then(Kv::str).filter(|k| !k.is_empty()) else { continue }; + let rank = version_rank(n.get("version").and_then(Kv::str).unwrap_or("")); + copies.push((id.clone(), key.to_string())); + let e = newest.entry(key.to_string()).or_insert_with(|| (rank, id.clone())); + if rank > e.0 || (rank == e.0 && *id > e.1) { + *e = (rank, id.clone()); + } + } + let mut t = VarCanon::default(); + for (id, key) in copies { + if let Some((_, canon)) = newest.get(&key) { + t.by_id.insert(id, canon.clone()); + } + } + t.by_key = newest.into_iter().map(|(k, (_, id))| (k, id)).collect(); + VAR_CANON.with(|c| *c.borrow_mut() = Some(t)); + VarCanonGuard + } + + /// The variable a reference (`{guid}` or `{assetRef}`) names, as its newest copy. + pub(super) fn resolve(r: &Kv) -> Option { + let by_guid = r.get("guid").and_then(guid).filter(|g| g != NO_STYLE); + VAR_CANON.with(|c| { + let c = c.borrow(); + let Some(t) = c.as_ref() else { return by_guid.clone() }; + if let Some(g) = &by_guid { + return Some(t.by_id.get(g).cloned().unwrap_or_else(|| g.clone())); + } + let key = r.get("assetRef")?.get("key").and_then(Kv::str)?; + t.by_key.get(key).cloned() + }) + } + + /// Whether variable node `id` is an older copy another one replaces. + pub(super) fn superseded(id: &str) -> bool { + VAR_CANON.with(|c| c.borrow().as_ref().and_then(|t| t.by_id.get(id)).is_some_and(|canon| canon != id)) + } +} diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 657f2f81d..0e2c5016e 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -1487,3 +1487,102 @@ fn dump_samples() { #[path = "board_import_tests.rs"] mod board; + +/// A file that took several updates of a library holds a copy of its collection and of each of +/// its variables per version it met (same asset key, another version). Layers and modes name any +/// of them by asset, never by guid: they all become the newest copy, which has every mode — a page +/// set to a mode only the newer copies have keeps it, and fills bound to an older copy of a +/// variable follow its newest values. +#[test] +fn library_copies_of_several_versions_become_the_newest() { + let mode = |id, name: &'static str, pos: &'static str| o(vec![("id", g(id)), ("name", s(name)), ("sortPosition", s(pos))]); + let by_asset = |key: &'static str, version: &'static str| o(vec![("assetRef", o(vec![("key", s(key)), ("version", s(version))]))]); + let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]); + let values = |pairs: Vec<(u64, Kv<'static>)>| { + o(vec![("entries", arr(pairs.into_iter().map(|(m, c)| o(vec![("modeID", g(m)), ("variableData", color_value(c))])).collect()))]) + }; + let asset_alias = + |key: &'static str, version: &'static str| o(vec![("value", o(vec![("alias", by_asset(key, version))])), ("dataType", e("ALIAS"))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + // The page is set to "Purple", a mode the oldest copy of the collection didn't have yet. + node( + 1, + Some((0, "!")), + "CANVAS", + "Page", + vec![( + "variableModeBySetMap", + o(vec![("entries", arr(vec![o(vec![("variableSetID", by_asset("products", "10:1")), ("variableModeID", g(92))])]))]), + )], + ), + node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + node( + 4, + Some((2, "!")), + "VARIABLE_SET", + "Products", + vec![("variableSetModes", arr(vec![mode(90, "Blue", "!")])), ("key", s("products")), ("version", s("10:1"))], + ), + node( + 6, + Some((2, "#")), + "VARIABLE_SET", + "Products", + vec![("variableSetModes", arr(vec![mode(90, "Blue", "!"), mode(92, "Purple", "\"")])), ("key", s("products")), ("version", s("20:1"))], + ), + node( + 5, + Some((2, "\"")), + "VARIABLE", + "button-bg", + vec![ + ("variableSetID", by_asset("products", "10:1")), + ("variableResolvedType", e("COLOR")), + ("variableDataValues", values(vec![(90, rgb(0.0, 0.0, 1.0))])), + ("key", s("button-bg")), + ("version", s("10:1")), + ], + ), + node( + 7, + Some((2, "$")), + "VARIABLE", + "button-bg", + vec![ + ("variableSetID", by_asset("products", "20:1")), + ("variableResolvedType", e("COLOR")), + ("variableDataValues", values(vec![(90, rgb(0.0, 0.0, 1.0)), (92, rgb(0.5, 0.0, 0.5))])), + ("key", s("button-bg")), + ("version", s("20:1")), + ], + ), + // The button names the older copy of the variable. + node( + 10, + Some((1, "!")), + "FRAME", + "Button", + vec![ + ("size", xy(100.0, 40.0)), + ( + "fillPaints", + arr(vec![o(vec![("type", e("SOLID")), ("color", rgb(0.0, 0.0, 1.0)), ("colorVar", asset_alias("button-bg", "10:1"))])]), + ), + ], + ), + ]; + let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert_eq!(doc.variables.collections.len(), 1, "{:?}", doc.variables.collections); + let products = &doc.variables.collections[0]; + assert_eq!(products.modes.iter().map(|m| m.name.as_str()).collect::>(), ["Blue", "Purple"]); + assert_eq!(doc.variables.variables.len(), 1); + let var = &doc.variables.variables[0]; + assert_eq!(var.collection, products.id); + assert_eq!(var.values.len(), 2, "the newest copy's values: {:?}", var.values); + let page = doc.nodes().find(|n| n.name == "Page").unwrap(); + assert_eq!(page.var_modes.get(&products.id).map(String::as_str), Some("1:92")); + let button = named(&doc, "Button"); + assert_eq!(button.bound.values().next(), Some(&var.id), "{:?}", button.bound); + assert!(!warnings.iter().any(|w| w.contains("variable modes of collections")), "{warnings:?}"); +}