diff --git a/crates/format/src/penpot/export.rs b/crates/format/src/penpot/export.rs index 5f9adc10..4e143212 100644 --- a/crates/format/src/penpot/export.rs +++ b/crates/format/src/penpot/export.rs @@ -8,7 +8,7 @@ use figcraft_doc::proto::{Action, Direction, Easing, Interaction, OverlayPositio use figcraft_doc::{ BoolOp, CellAlign, ContentAlign, CrossAlign, Document, EffectKind, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Sizing, StrokeAlign, StrokeCap, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextDecoration, - TextStyle, VConstraint, VariableType, VariableValue, + TextStyle, VConstraint, Variable, VariableType, VariableValue, Variables, }; use serde_json::{Map, Value, json}; @@ -1701,6 +1701,11 @@ impl<'a> Exporter<'a> { (!out.is_empty()).then_some(Value::Object(out)) } + /// The archive's `tokens.json`: the file's own Penpot tokens when it came from Penpot and its + /// variables are unchanged since, else one token set per collection (per mode, with a theme + /// for each, when it has several). Aliases go out as `{name}` references and colour + /// calculations as the colour they show; a token that resolves to nothing is left out with a + /// warning, so no reference may name it. fn tokens_json(&mut self) -> Option { let first = self.doc.pages.first().and_then(|p| self.node(*p)); let source = first.and_then(|p| p.plugin_data.iter().find(|(k, _)| k.starts_with("penpot.tokens.")).map(|(_, v)| v.clone())); @@ -1715,6 +1720,7 @@ impl<'a> Exporter<'a> { if variables.variables.is_empty() { return None; } + let unreferenceable = unreferenceable(variables); let mut root = Map::new(); let mut themes = Vec::new(); let mut order = Vec::new(); @@ -1726,13 +1732,26 @@ impl<'a> Exporter<'a> { let set_name = if many { format!("{}/{}", collection.name, mode.name) } else { collection.name.clone() }; let mut tokens = Map::new(); for var in variables.variables.iter().filter(|v| v.collection == collection.id) { - let Some(value) = var.values.get(&mode.id).or_else(|| var.values.values().next()) else { continue }; + let Some(value) = mode_value(var, &mode.id) else { continue }; // Penpot tokens have no shape for colour calculations: the colour they resolve // to in this mode goes out as a plain colour token. let Some(value) = variables.flattened(var, &mode.id, value) else { self.warn(crate::CALCULATIONS_LEFT_OUT); continue; }; + // A reference to a token some set leaves out would dangle whenever that set is + // active: such an alias goes out as the colour it shows here instead. + let value = match value { + VariableValue::Alias(target) if unreferenceable.contains(target.as_str()) => { + let here = BTreeMap::from([(collection.id.clone(), mode.id.clone())]); + let Some(shown) = variables.resolve(&var.id, &here) else { + self.warn(crate::CALCULATIONS_LEFT_OUT); + continue; + }; + shown + } + other => other, + }; let (kind, value) = match &value { VariableValue::Alias(target) => { let Some(target_name) = self.token_name(target) else { continue }; @@ -1786,6 +1805,40 @@ impl<'a> Exporter<'a> { } } +/// The value a variable's token takes in `mode` (its first value when it has none for that mode). +fn mode_value<'v>(var: &'v Variable, mode: &str) -> Option<&'v VariableValue> { + var.values.get(mode).or_else(|| var.values.values().next()) +} + +/// Variables a token reference must not name: colour calculations some token set leaves out (they +/// resolve to nothing in a mode of their collection) and every alias that leads to one. +fn unreferenceable(variables: &Variables) -> BTreeSet<&str> { + let mut out = BTreeSet::new(); + for var in &variables.variables { + let Some(coll) = variables.collection_by_id(&var.collection) else { continue }; + if coll.modes.iter().any(|m| mode_value(var, &m.id).is_some_and(|v| variables.flattened(var, &m.id, v).is_none())) { + out.insert(var.id.as_str()); + } + } + let mut readers: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for var in &variables.variables { + for value in var.values.values() { + if let VariableValue::Alias(target) = value { + readers.entry(target.as_str()).or_default().push(var.id.as_str()); + } + } + } + let mut stack: Vec<&str> = out.iter().copied().collect(); + while let Some(id) = stack.pop() { + for reader in readers.get(id).into_iter().flatten() { + if out.insert(reader) { + stack.push(reader); + } + } + } + out +} + /// A layer's visibility, lock, opacity, blend, proportion lock and constraints as Penpot fields. fn appearance_fields(node: &Node, fields: &mut Map) { if !node.visible { diff --git a/crates/format/src/penpot/export_tests.rs b/crates/format/src/penpot/export_tests.rs index 576574e0..4dacd4ea 100644 --- a/crates/format/src/penpot/export_tests.rs +++ b/crates/format/src/penpot/export_tests.rs @@ -588,7 +588,8 @@ fn native_variables_become_token_sets_themes_and_applied_tokens() { } /// Penpot tokens have no shape for a colour calculation: each mode's token is the colour it shows, -/// and one that resolves to nothing is left out with a warning. +/// and one that resolves to nothing is left out with a warning. No reference names a token a set +/// leaves out: an alias to one goes out as the colour it shows, or is left out with it. #[test] fn colour_calculations_export_as_the_colour_they_show() { let mut doc = Document::new("Calculated"); @@ -614,11 +615,34 @@ fn colour_calculations_export_as_the_colour_they_show() { &[("light", VariableValue::ColorAlpha { variable: "v1".into(), opacity: 0.5 }), ("dark", mix)], )); let gone = VariableValue::ColorAlpha { variable: "v-gone".into(), opacity: 0.5 }; - doc.variables.variables.push(variable("v3", "brand/lost", "c1", VariableType::Color, &[("light", gone.clone()), ("dark", gone)])); + doc.variables.variables.push(variable("v3", "brand/lost", "c1", VariableType::Color, &[("light", gone.clone()), ("dark", gone.clone())])); + // Aliases: to the lost one (same collection), to one lost only in Dark (from a collection with + // one mode, which reads Color in its default mode, Light), to an alias that is left out, and to + // one that always resolves. + let alias = |to: &str| VariableValue::Alias(to.into()); + doc.variables.variables.push(variable("v4", "brand/lost-too", "c1", VariableType::Color, &[("light", alias("v3")), ("dark", alias("v3"))])); + let half = VariableValue::ColorAlpha { variable: "v1".into(), opacity: 0.5 }; + doc.variables.variables.push(variable("v5", "brand/half", "c1", VariableType::Color, &[("light", half), ("dark", gone)])); + doc.variables.collections.push(Collection { + id: "c2".into(), + name: "Theme".into(), + modes: vec![Mode { id: "one".into(), name: "One".into() }], + extends: None, + }); + doc.variables.variables.push(variable("v6", "theme/accent", "c2", VariableType::Color, &[("one", alias("v5"))])); + doc.variables.variables.push(variable("v7", "theme/veil", "c2", VariableType::Color, &[("one", alias("v2"))])); + doc.variables.variables.push(variable("v8", "theme/deep", "c2", VariableType::Color, &[("one", alias("v4"))])); let (bytes, warnings) = export(&doc).unwrap(); assert!(warnings.iter().any(|w| w == crate::CALCULATIONS_LEFT_OUT), "{warnings:?}"); let tokens = tokens(&bytes); assert!(["Color/Light", "Color/Dark"].iter().all(|set| tokens[set]["brand"].get("lost").is_none()), "left out, not written"); + assert!(["Color/Light", "Color/Dark"].iter().all(|set| tokens[set]["brand"].get("lost-too").is_none()), "an alias to it too"); + assert!(tokens["Color/Dark"]["brand"].get("half").is_none() && tokens["Color/Light"]["brand"]["half"]["$value"] == "#ff000080"); + assert_eq!(tokens["Theme"]["theme"]["accent"]["$value"], "#ff000080", "the colour it shows, not {{brand.half}}"); + assert_eq!(tokens["Theme"]["theme"]["veil"]["$value"], "{brand.veil}", "a reference that always resolves stays one"); + assert!(tokens["Theme"]["theme"].get("deep").is_none(), "an alias to an alias that is left out is left out too"); + let text = tokens.to_string(); + assert!(!text.contains("{brand.lost") && !text.contains("{brand.half}"), "no reference to a token a set leaves out: {text}"); assert_eq!(tokens["Color/Light"]["brand"]["veil"]["$value"], "#ff000080"); assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$value"], "#008080c0"); assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$type"], "color");