diff --git a/crates/engine/src/fig_export_assets_tests.rs b/crates/engine/src/fig_export_assets_tests.rs new file mode 100644 index 0000000..de3d06f --- /dev/null +++ b/crates/engine/src/fig_export_assets_tests.rs @@ -0,0 +1,153 @@ +//! `.fig` export through `file.export` and back through `file.open`: variables with their modes, +//! the layers bound to them (also inside instances shown in another mode), shared styles, and +//! instances that are resized, fill an auto layout or were scaled come back as they were. The +//! format's own tests (written fields, damaged files) are in `figcraft_format::fig`. + +use figcraft_doc::{Document, NodeKind}; +use serde_json::{Value, json}; + +use crate::Session; + +fn id(v: &Value) -> u64 { + v["id"].as_u64().unwrap() +} + +fn run(s: &mut Session, cmd: &str, params: Value) -> Value { + s.execute(cmd, ¶ms).unwrap_or_else(|e| panic!("{cmd}: {e:?}")) +} + +/// A card component (a box that scales with it, a label), instances of it as it is, resized, +/// filling an auto layout frame and scaled; a theme with two modes bound to two frames, one of +/// them dark and holding another instance; a paint style on a rectangle. +fn build(s: &mut Session) { + let card = id(&run(s, "frame.create", json!({"x": 40, "y": 40, "width": 100, "height": 60, "name": "Card"}))); + let boxed = json!({"fill": "#3B82F6", "name": "Box", "constraints": {"horizontal": "scale", "vertical": "scale"}}); + run(s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40, "parent": card, "props": boxed})); + let label = id(&run(s, "text.create", json!({"text": "Card", "x": 56, "y": 20, "parent": card, "props": {"name": "Label"}}))); + run(s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})); + run(s, "variable.create", json!({"collection": "Theme", "name": "bg", "type": "color", "values": {"Light": "#FFFFFF", "Dark": "#111827"}})); + run(s, "variable.create", json!({"collection": "Theme", "name": "fg", "type": "color", "values": {"Light": "#111827", "Dark": "#F9FAFB"}})); + run(s, "variable.create", json!({"collection": "Theme", "name": "gap", "type": "number", "values": {"Light": 8, "Dark": 16}})); + run(s, "variable.bind", json!({"ids": [card], "field": "fill", "variable": "bg"})); + run(s, "variable.bind", json!({"ids": [label], "field": "fill", "variable": "fg"})); + let comp = id(&run(s, "component.create", json!({"ids": [card]}))); + let same = id(&run(s, "instance.create", json!({"component": comp, "x": 200, "y": 40}))); + run(s, "node.set", json!({"ids": [same], "name": "Same"})); + let resized = id(&run(s, "instance.create", json!({"component": comp, "x": 360, "y": 40}))); + run(s, "node.set", json!({"ids": [resized], "name": "Resized", "width": 200, "height": 120})); + let row = id(&run(s, "frame.create", json!({"x": 40, "y": 220, "width": 400, "height": 100, "name": "Row"}))); + run(s, "layout.add", json!({"ids": [row], "direction": "horizontal"})); + run(s, "node.set", json!({"ids": [row], "width": 400, "height": 100, "sizingH": "fixed", "sizingV": "fixed"})); + run(s, "variable.bind", json!({"ids": [row], "field": "gap", "variable": "gap"})); + let filling = id(&run(s, "instance.create", json!({"component": comp, "parent": row}))); + run(s, "node.set", json!({"ids": [filling], "name": "Filling", "sizingH": "fill", "sizingV": "fill"})); + let scaled = id(&run(s, "instance.create", json!({"component": comp, "x": 620, "y": 40}))); + run(s, "node.set", json!({"ids": [scaled], "name": "Scaled"})); + run(s, "transform.scale", json!({"ids": [scaled], "factor": 1.5})); + let dark = id(&run(s, "frame.create", json!({"x": 40, "y": 360, "width": 300, "height": 120, "name": "Dark"}))); + run(s, "variable.bind", json!({"ids": [dark], "field": "fill", "variable": "bg"})); + run(s, "variable.setMode", json!({"ids": [dark], "collection": "Theme", "mode": "Dark"})); + let inside = id(&run(s, "instance.create", json!({"component": comp, "parent": dark, "x": 20, "y": 20}))); + run(s, "node.set", json!({"ids": [inside], "name": "In the dark"})); + let swatch = + id(&run(s, "shape.rectangle", json!({"x": 520, "y": 360, "width": 100, "height": 100, "props": {"fill": "#F59E0B", "name": "Swatch"}}))); + run(s, "style.create", json!({"type": "paint", "name": "Brand/Accent", "from": swatch})); + run(s, "style.apply", json!({"ids": [swatch], "style": "Brand/Accent"})); +} + +/// Every layer, depth-first: name, kind, place, size, first fill colour, what it is bound to and +/// the styles and modes it names (by name: a round trip gives new ids). +fn outline(doc: &Document) -> Vec { + let var = |v: &String| doc.variables.get(v).map_or("?", |x| x.name.as_str()).to_string(); + let style = |r: &Option| r.as_ref().and_then(|i| doc.style(i)).map(|x| x.name.clone()); + doc.pages + .iter() + .flat_map(|p| doc.subtree(*p)) + .filter_map(|i| doc.node(i)) + .map(|n| { + let t = n.transform.translation(); + let fill = n.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()); + let bound: Vec<(&str, String)> = n.bound.iter().map(|(k, v)| (k.as_str(), var(v))).collect(); + let modes: Vec<(String, String)> = n + .var_modes + .iter() + .filter_map(|(c, m)| doc.variables.collection(c).map(|c| (c.name.clone(), c.find_mode(m).map_or("?".into(), |m| m.name.clone())))) + .collect(); + let size = n.kind.text().map(|t| t.style.font_size); + format!( + "{} {:?} ({:.1}, {:.1}) {:.1}×{:.1} weight {:.2} font {size:?} fill {fill:?} bound {bound:?} modes {modes:?} style {:?} gap {:?}", + n.kind.type_name(), + n.name, + t.x, + t.y, + n.width, + n.height, + n.stroke.weight, + style(&n.styles.fill), + n.layout.as_ref().map(|l| l.gap), + ) + }) + .collect() +} + +#[test] +fn variables_styles_and_instance_sizes_come_back() { + let mut s = Session::new(); + build(&mut s); + let before = outline(s.doc().unwrap()); + // What the outline shows: the instance in the dark frame takes the dark values. + assert!(before.iter().any(|l| l.contains("\"In the dark\"") && l.contains("111827")), "{before:#?}"); + assert!(before.iter().any(|l| l.contains("\"Resized\"") && l.contains("200.0×120.0")), "{before:#?}"); + let path = std::env::temp_dir().join(format!("figcraft-fig-assets-{}.fig", std::process::id())); + let r = run(&mut s, "file.export", json!({"format": "fig", "path": path.to_string_lossy()})); + assert_eq!(r["warnings"], json!([])); + let r = run(&mut s, "file.open", json!({"path": path.to_string_lossy()})); + assert_eq!(r["warnings"], json!([])); + let _ = std::fs::remove_file(&path); + let doc = s.doc().unwrap(); + assert_eq!(outline(doc), before); + // Collections, modes, variables and styles themselves. + let theme = doc.variables.collection("Theme").unwrap(); + assert_eq!(theme.modes.iter().map(|m| m.name.as_str()).collect::>(), ["Light", "Dark"]); + assert_eq!(doc.variables.variables.iter().map(|v| v.name.as_str()).collect::>(), ["bg", "fg", "gap"]); + assert_eq!(doc.styles.iter().map(|x| x.name.as_str()).collect::>(), ["Brand/Accent"]); + // The instances are linked to the main again, layer by layer. + for inst in doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })) { + assert!(inst.children.iter().all(|c| doc.node(*c).is_some_and(|c| c.source.is_some())), "{}", inst.name); + } + // Switching the dark frame back to light recolours what is in it: the bindings are live. + let dark = doc.nodes().find(|n| n.name == "Dark").unwrap().id.0; + run(&mut s, "variable.setMode", json!({"ids": [dark], "collection": "Theme", "mode": "Light"})); + let after = outline(s.doc().unwrap()); + assert!(after.iter().any(|l| l.contains("\"In the dark\"") && l.contains("FFFFFF")), "{after:#?}"); +} + +#[test] +fn every_example_keeps_its_variables_and_styles() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples"); + let Ok(files) = std::fs::read_dir(dir) else { return }; + for p in files.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "figcraft")) { + let mut s = Session::new(); + run(&mut s, "file.open", json!({"path": p.to_string_lossy()})); + let before = outline(s.doc().unwrap()); + let counts = |d: &Document| (d.variables.collections.len(), d.variables.variables.len(), d.styles.len()); + let had = counts(s.doc().unwrap()); + let out = std::env::temp_dir().join(format!("figcraft-fig-assets-{}-{}.fig", std::process::id(), p.file_stem().unwrap().to_string_lossy())); + let r = run(&mut s, "file.export", json!({"format": "fig", "path": out.to_string_lossy()})); + let warnings = r["warnings"].to_string(); + assert!(!warnings.contains("variable") && !warnings.contains("style"), "{}: {warnings}", p.display()); + run(&mut s, "file.open", json!({"path": out.to_string_lossy()})); + let _ = std::fs::remove_file(&out); + assert_eq!(counts(s.doc().unwrap()), had, "{}", p.display()); + // Names, paints, bindings, modes and style links (the format's tests compare the geometry, + // which 32-bit floats round). + let links = |lines: &[String]| -> Vec { + lines.iter().map(|l| format!("{} |{}", l.split(" (").next().unwrap_or(""), l.split(" fill ").nth(1).unwrap_or(""))).collect() + }; + let after = outline(s.doc().unwrap()); + assert_eq!(after.len(), before.len(), "{}", p.display()); + for (a, b) in links(&after).iter().zip(&links(&before)) { + assert_eq!(a, b, "{}", p.display()); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 44bd5d1..b651364 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1384,6 +1384,8 @@ mod export_behaviour_tests; #[cfg(test)] mod extended_chain_tests; #[cfg(test)] +mod fig_export_assets_tests; +#[cfg(test)] mod fig_export_tests; #[cfg(test)] mod fig_import_tests; diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs new file mode 100644 index 0000000..e9b5fc9 --- /dev/null +++ b/crates/format/src/fig/assets_tests.rs @@ -0,0 +1,826 @@ +//! Variables, shared styles and instance sizes in exported `.fig` files: collections with their +//! modes, variables with a value per mode, the bindings on layers, paints, gradient stops, effects +//! and layout grids, the modes frames and pages set, style definitions and the links to them, and +//! instances that keep their own size (resized, filling an auto layout, scaled) with their layers +//! laid out for it. Everything written reads back; what the format can't say is written as its +//! value with a warning; damaged files never panic. + +use figcraft_doc::{Collection, Mode, Style, StyleKind, Variable, VariableType, VariableValue}; +use serde_json::json; + +use super::*; + +fn solid(r: f32, g: f32, b: f32) -> Paint { + Paint::solid(Color::new(r, g, b, 1.0)) +} + +fn var(id: &str, name: &str, collection: &str, kind: VariableType, values: &[(&str, VariableValue)]) -> Variable { + Variable { + id: id.into(), + name: name.into(), + collection: collection.into(), + kind, + values: values.iter().map(|(m, v)| (m.to_string(), v.clone())).collect(), + description: String::new(), + scopes: None, + hidden_from_publishing: false, + extends: None, + overrides: Default::default(), + code_syntax: Default::default(), + } +} + +fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str, at: (f64, f64), size: (f64, f64), edit: &dyn Fn(&mut Node)) -> NodeId { + let id = doc.create(parent, kind, name, at.0, at.1, size.0, size.1).unwrap(); + let n = doc.node_mut(id).unwrap(); + n.fills = vec![solid(1.0, 1.0, 1.0)]; + n.strokes.clear(); + edit(n); + id +} + +/// Two collections (one with two modes), variables of every type (one an alias, some with scopes +/// and a description), and layers bound to them in every way the format has a field for. +fn themed() -> Document { + use VariableValue::{Alias, Boolean, Number}; + let mut doc = Document::new("Themed"); + let page = doc.first_page().unwrap(); + let mode = |id: &str, name: &str| Mode { id: id.into(), name: name.into() }; + doc.variables.collections = vec![ + Collection { id: "c-theme".into(), name: "Theme".into(), modes: vec![mode("m-light", "Light"), mode("m-dark", "Dark")], extends: None }, + Collection { id: "c-space".into(), name: "Spacing".into(), modes: vec![mode("m-only", "Default")], extends: None }, + ]; + let color = |r: f32, g: f32, b: f32| VariableValue::Color(Color::new(r, g, b, 1.0)); + let mut bg = var("v-bg", "color/bg", "c-theme", VariableType::Color, &[("m-light", color(1.0, 1.0, 1.0)), ("m-dark", color(0.0, 0.125, 0.25))]); + bg.description = "Behind everything".into(); + bg.scopes = Some(vec!["FRAME_FILL".into(), "SHAPE_FILL".into()]); + let mut fg = var("v-fg", "color/fg", "c-theme", VariableType::Color, &[("m-light", color(0.0, 0.0, 0.0)), ("m-dark", color(0.75, 0.75, 0.75))]); + fg.scopes = Some(vec!["TEXT_FILL".into(), "STROKE_COLOR".into(), "EFFECT_COLOR".into()]); + let mut shown = var("v-shown", "shown", "c-theme", VariableType::Boolean, &[("m-light", Boolean(true)), ("m-dark", Boolean(false))]); + shown.scopes = Some(Vec::new()); + doc.variables.variables = vec![ + // Out of collection order on purpose: the order of variables is the document's. + var("v-gap", "space/gap", "c-space", VariableType::Number, &[("m-only", Number(8.0))]), + bg, + fg, + var("v-accent", "color/accent", "c-theme", VariableType::Color, &[("m-light", Alias("v-fg".into())), ("m-dark", color(1.0, 0.5, 0.0))]), + var("v-radius", "radius", "c-space", VariableType::Number, &[("m-only", Number(12.5))]), + var( + "v-title", + "title", + "c-theme", + VariableType::String, + &[("m-light", VariableValue::String("Hello".into())), ("m-dark", VariableValue::String("Grüße 😀".into()))], + ), + shown, + ]; + doc.node_mut(page).unwrap().var_modes.insert("c-space".into(), "m-only".into()); + let bind = |n: &mut Node, pairs: &[(&str, &str)]| n.bound.extend(pairs.iter().map(|(k, v)| (k.to_string(), v.to_string()))); + let frame = add(&mut doc, page, NodeKind::Frame, "Card", (0.0, 0.0), (300.0, 200.0), &|n| { + n.layout = Some(AutoLayout::default()); + n.strokes = vec![solid(0.0, 0.0, 0.0)]; + n.stroke.sides = Some([2.0, 1.0, 1.0, 1.0]); + n.grids = vec![LayoutGrid { kind: GridKind::Columns, ..LayoutGrid::default() }]; + n.var_modes.insert("c-theme".into(), "m-dark".into()); + bind( + n, + &[ + ("fill", "v-bg"), + ("stroke", "v-fg"), + ("gap", "v-gap"), + ("crossGap", "v-gap"), + ("paddingHorizontal", "v-gap"), + ("paddingTop", "v-radius"), + ("cornerRadius", "v-radius"), + ("width", "v-gap"), + ("minHeight", "v-gap"), + ("opacity", "v-gap"), + ("strokeTopWeight", "v-gap"), + ("layoutGrids/0/gutter", "v-gap"), + ("layoutGrids/0/count", "v-radius"), + ], + ); + }); + add(&mut doc, frame, NodeKind::Rectangle, "Swatch", (10.0, 10.0), (80.0, 60.0), &|n| { + let mut g = Gradient::default_for(GradientKind::Linear, Color::BLACK); + g.stops = vec![ColorStop { position: 0.0, color: Color::BLACK }, ColorStop { position: 1.0, color: Color::WHITE }]; + n.fills = vec![solid(0.5, 0.5, 0.5), Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal }]; + n.strokes = vec![solid(0.0, 0.0, 0.0), solid(1.0, 0.0, 0.0)]; + n.effects = vec![Effect { + kind: EffectKind::DropShadow(Shadow { + color: Color::new(0.0, 0.0, 0.0, 0.25), + offset: (0.0, 4.0), + radius: 8.0, + spread: 0.0, + blend: BlendMode::Normal, + show_behind: false, + }), + visible: true, + }]; + bind( + n, + &[ + ("fills/0/color", "v-accent"), + ("fills/1/stops/1/color", "v-bg"), + ("strokes/1/color", "v-fg"), + ("effects/0/color", "v-fg"), + ("effects/0/radius", "v-gap"), + ("effects/0/spread", "v-gap"), + ("effects/0/offsetX", "v-gap"), + ("effects/0/offsetY", "v-radius"), + ("visible", "v-shown"), + ("topLeftRadius", "v-radius"), + ], + ); + }); + add(&mut doc, frame, NodeKind::Text(TextData::new("Hello")), "Title", (10.0, 80.0), (60.0, 20.0), &|n| { + n.fills = vec![solid(0.0, 0.0, 0.0)]; + bind( + n, + &[ + ("fill", "v-fg"), + ("characters", "v-title"), + ("fontSize", "v-radius"), + ("lineHeight", "v-radius"), + ("letterSpacing", "v-gap"), + ("paragraphSpacing", "v-gap"), + ("paragraphIndent", "v-gap"), + ("fontFamily", "v-title"), + ("fontStyle", "v-title"), + ], + ); + }); + doc +} + +/// Ids of collections, modes, variables and styles replaced by names (a round trip gives them new +/// ids), wherever they are used. +fn by_name(doc: &mut Document) { + let mut ids: BTreeMap = BTreeMap::new(); + for c in &doc.variables.collections { + ids.insert(c.id.clone(), format!("collection {}", c.name)); + for m in &c.modes { + ids.insert(m.id.clone(), format!("mode {}/{}", c.name, m.name)); + } + } + ids.extend(doc.variables.variables.iter().map(|v| (v.id.clone(), format!("variable {}", v.name)))); + ids.extend(doc.styles.iter().map(|s| (s.id.clone(), format!("style {}", s.name)))); + let name = |id: &String| ids.get(id).cloned().unwrap_or_else(|| format!("unknown {id}")); + for c in &mut doc.variables.collections { + c.id = name(&c.id); + for m in &mut c.modes { + m.id = name(&m.id); + } + } + for v in &mut doc.variables.variables { + (v.id, v.collection) = (name(&v.id), name(&v.collection)); + v.values = std::mem::take(&mut v.values) + .into_iter() + .map(|(m, val)| { + let val = match val { + VariableValue::Alias(a) => VariableValue::Alias(name(&a)), + other => other, + }; + (name(&m), val) + }) + .collect(); + } + for s in &mut doc.styles { + s.id = name(&s.id); + } + let nodes: Vec = doc.ids().collect(); + for id in nodes { + let Some(n) = doc.node_mut(id) else { continue }; + for v in n.bound.values_mut() { + *v = name(v); + } + n.var_modes = std::mem::take(&mut n.var_modes).into_iter().map(|(c, m)| (name(&c), name(&m))).collect(); + for r in [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text] { + *r = r.as_ref().map(name); + } + } +} + +/// Export `doc`, import the file and compare everything: layers with their bindings, modes and +/// style links, variables and styles. Returns the export's warnings. +fn same_after_round_trip(doc: &Document, what: &str) -> Vec { + let (bytes, warnings) = export(doc, None).unwrap(); + let (mut back, notes) = import(&bytes, "x").unwrap_or_else(|e| panic!("{what}: {e}")); + assert!(notes.is_empty(), "{what}: the import warned: {notes:?}"); + let mut doc = doc.clone(); + by_name(&mut doc); + by_name(&mut back); + assert_same(&doc, &back, what); + let assets = |d: &Document| { + let mut styles = d.styles.clone(); + for s in &mut styles { + // Held the way a layer's paints and text are. + let mut n = Node::new(NodeId(0), NodeKind::Text(TextData::new("")), "", 0.0, 0.0); + match &mut s.kind { + StyleKind::Paint { paints } => { + n.fills = std::mem::take(paints); + normalize(&mut n, &|i| i); + *paints = n.fills; + } + StyleKind::Text { style } => { + if let NodeKind::Text(td) = &mut n.kind { + td.style = style.clone(); + } + n.fills.clear(); + normalize(&mut n, &|i| i); + if let NodeKind::Text(td) = n.kind { + *style = td.style; + } + } + _ => {} + } + } + json!({"variables": d.variables, "styles": styles}) + }; + if let Err(e) = close(&assets(&doc), &assets(&back), "assets") { + panic!("{what}: {e}\nwritten: {}\nread back: {}", assets(&doc), assets(&back)); + } + warnings +} + +/// The node changes of the written file as JSON. +fn written(doc: &Document) -> Vec { + let (bytes, _) = export(doc, None).unwrap(); + let (schema, message) = decode_canvas(&Archive::open(&bytes).unwrap().canvas).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + msg.get("nodeChanges").unwrap().list().iter().map(debug_json).collect() +} + +fn named<'v>(nodes: &'v [Value], name: &str) -> &'v Value { + nodes.iter().find(|n| n["name"] == name).unwrap_or_else(|| panic!("no {name} was written")) +} + +#[test] +fn variables_modes_and_bindings_round_trip() { + let doc = themed(); + let warnings = same_after_round_trip(&doc, "themed"); + assert!(warnings.is_empty(), "{warnings:?}"); + // Read back in the document's order, the default mode first. + let (back, _) = import(&export(&doc, None).unwrap().0, "x").unwrap(); + let names = |d: &Document| d.variables.variables.iter().map(|v| v.name.clone()).collect::>(); + assert_eq!(names(&back), names(&doc)); + assert_eq!(back.variables.collections.iter().map(|c| c.name.as_str()).collect::>(), ["Theme", "Spacing"]); + assert_eq!(back.variables.collections[0].modes.iter().map(|m| m.name.as_str()).collect::>(), ["Light", "Dark"]); +} + +#[test] +fn variables_are_written_as_nodes_on_a_hidden_page() { + let nodes = written(&themed()); + let hidden = named(&nodes, "Internal Only Canvas"); + assert_eq!((hidden["type"].as_str(), hidden["internalOnly"].as_bool(), hidden["visible"].as_bool()), (Some("CANVAS"), Some(true), Some(false))); + let set = named(&nodes, "Theme"); + assert_eq!(set["type"], "VARIABLE_SET"); + assert_eq!(set["parentIndex"]["guid"], hidden["guid"]); + let modes = set["variableSetModes"].as_array().unwrap(); + assert_eq!(modes.iter().map(|m| m["name"].as_str().unwrap()).collect::>(), ["Light", "Dark"]); + let bg = named(&nodes, "color/bg"); + assert_eq!((bg["type"].as_str(), bg["variableResolvedType"].as_str()), (Some("VARIABLE"), Some("COLOR"))); + assert_eq!(bg["variableSetID"]["guid"], set["guid"]); + assert_eq!(bg["description"], "Behind everything"); + assert_eq!(bg["variableScopes"], json!(["FRAME_FILL", "SHAPE_FILL"])); + assert_eq!(named(&nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"])); + let values = bg["variableDataValues"]["entries"].as_array().unwrap(); + assert_eq!(values.len(), 2); + assert_eq!(values[1]["modeID"], modes[1]["id"]); + assert_eq!( + (values[1]["variableData"]["dataType"].as_str(), values[1]["variableData"]["value"]["colorValue"]["b"].as_f64()), + (Some("COLOR"), Some(0.25)) + ); + // An alias names the variable's node; every value says the type it resolves to. + let accent = &named(&nodes, "color/accent")["variableDataValues"]["entries"]; + assert_eq!(accent[0]["variableData"]["dataType"], "ALIAS"); + assert_eq!(accent[0]["variableData"]["resolvedDataType"], "COLOR"); + assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(&nodes, "color/fg")["guid"]); + assert_eq!(named(&nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀"); + assert_eq!(named(&nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false); + assert_eq!(named(&nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0); + // Bindings: on the paint, the gradient stop, the effect and the grid, and in the layer's map. + let card = named(&nodes, "Card"); + assert_eq!(card["fillPaints"][0]["colorVar"]["value"]["alias"]["guid"], bg["guid"]); + assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], set["guid"]); + assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableModeID"], modes[1]["id"]); + assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(&nodes, "space/gap")["guid"]); + let fields: Vec<&str> = + card["variableConsumptionMap"]["entries"].as_array().unwrap().iter().map(|e| e["variableField"].as_str().unwrap()).collect(); + for f in + ["STACK_SPACING", "STACK_PADDING_LEFT", "STACK_PADDING_RIGHT", "RECTANGLE_TOP_LEFT_CORNER_RADIUS", "WIDTH", "OPACITY", "BORDER_TOP_WEIGHT"] + { + assert!(fields.contains(&f), "{f} in {fields:?}"); + } + let swatch = named(&nodes, "Swatch"); + assert!(swatch["fillPaints"][1]["stopsVar"][0].get("colorVar").is_none()); + assert_eq!(swatch["fillPaints"][1]["stopsVar"][1]["colorVar"]["value"]["alias"]["guid"], bg["guid"]); + assert_eq!(swatch["fillPaints"][1]["stopsVar"].as_array().unwrap().len(), 2); + assert!(swatch["strokePaints"][0].get("colorVar").is_none() && swatch["strokePaints"][1].get("colorVar").is_some()); + for key in ["colorVar", "radiusVar", "spreadVar", "xVar", "yVar"] { + assert_eq!(swatch["effects"][0][key]["dataType"], "ALIAS", "{key}"); + } + // The page sets a mode too. + assert_eq!(named(&nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(&nodes, "Spacing")["guid"]); +} + +#[test] +fn bindings_the_format_cant_say_keep_their_value_with_a_warning() { + let mut doc = themed(); + let card = doc.nodes().find(|n| n.name == "Card").unwrap().id; + let n = doc.node_mut(card).unwrap(); + n.bound.insert("componentProperties/Label".into(), "v-title".into()); + n.bound.insert("effects/x/radius".into(), "v-gap".into()); + n.bound.insert("fills/0/stops/zero/color".into(), "v-bg".into()); + n.bound.insert("height".into(), "v-gone".into()); + n.var_modes.insert("c-gone".into(), "m-light".into()); + n.styles.fill = Some("s-gone".into()); + doc.variables.variables[0].scopes = Some(vec!["GAP".into(), "FONT_WEIGHT".into()]); + doc.variables.variables.push(var("v-lost", "lost", "c-gone", VariableType::Number, &[("m", VariableValue::Number(1.0))])); + doc.variables.variables.push(var( + "v-dangling", + "dangling", + "c-space", + VariableType::Number, + &[("m-only", VariableValue::Alias("v-gone".into()))], + )); + let (bytes, warnings) = export(&doc, None).unwrap(); + for part in [ + "properties the format has no field for", + "variables that aren't in the document were written as their values", + "modes of collections that aren't in the document", + "styles that aren't in the document", + "scopes the format has no name for", + "whose collection isn't in the document", + "aliases to variables that aren't in the document", + ] { + assert!(warnings.iter().any(|w| w.contains(part)), "{part} in {warnings:?}"); + } + // The rest still comes across. + let (back, _) = import(&bytes, "x").unwrap(); + let card = back.nodes().find(|n| n.name == "Card").unwrap(); + assert!(card.bound.contains_key("fill") && !card.bound.contains_key("height")); + assert!(back.variables.get("lost").is_none()); +} + +/// A style of each kind linked from a layer, and one that nothing uses. +fn styled() -> Document { + let mut doc = Document::new("Styled"); + let page = doc.first_page().unwrap(); + let shadow = Effect { + kind: EffectKind::DropShadow(Shadow { + color: Color::new(0.0, 0.0, 0.0, 0.25), + offset: (0.0, 2.0), + radius: 6.0, + spread: 1.0, + blend: BlendMode::Normal, + show_behind: false, + }), + visible: true, + }; + let grid = LayoutGrid { kind: GridKind::Columns, count: 12, gutter: 16.0, ..LayoutGrid::default() }; + let heading = TextStyle { + font_family: "Inter".into(), + font_style: "Bold".into(), + font_size: 24.0, + line_height: LineHeight::Pixels(32.0), + ..TextStyle::default() + }; + let style = |id: &str, name: &str, kind: StyleKind| Style { id: id.into(), name: name.into(), description: String::new(), kind }; + doc.styles = vec![ + style("s-text", "Heading", StyleKind::Text { style: heading.clone() }), + style("s-brand", "Brand/Primary", StyleKind::Paint { paints: vec![solid(0.25, 0.5, 1.0)] }), + style("s-unused", "Brand/Unused", StyleKind::Paint { paints: vec![solid(1.0, 0.0, 0.0), solid(0.0, 1.0, 0.0)] }), + style("s-shadow", "Elevation/1", StyleKind::Effect { effects: vec![shadow.clone()] }), + style("s-grid", "Desktop/12 columns", StyleKind::Grid { grids: vec![grid.clone()] }), + style("s-line", "Line", StyleKind::Paint { paints: vec![solid(0.0, 0.0, 0.0)] }), + ]; + doc.styles[1].description = "The brand colour".into(); + let frame = add(&mut doc, page, NodeKind::Frame, "Screen", (0.0, 0.0), (400.0, 300.0), &|n| { + n.grids = vec![grid.clone()]; + n.effects = vec![shadow.clone()]; + n.styles.grid = Some("s-grid".into()); + n.styles.effect = Some("s-shadow".into()); + }); + add(&mut doc, frame, NodeKind::Rectangle, "Button", (10.0, 10.0), (120.0, 40.0), &|n| { + n.fills = vec![solid(0.25, 0.5, 1.0)]; + n.strokes = vec![solid(0.0, 0.0, 0.0)]; + n.styles.fill = Some("s-brand".into()); + n.styles.stroke = Some("s-line".into()); + }); + let mut td = TextData::new("Title"); + td.style = heading; + add(&mut doc, frame, NodeKind::Text(td), "Title", (10.0, 80.0), (100.0, 32.0), &|n| { + n.fills = vec![solid(0.0, 0.0, 0.0)]; + n.styles.text = Some("s-text".into()); + }); + doc +} + +#[test] +fn shared_styles_and_their_links_round_trip() { + let doc = styled(); + let warnings = same_after_round_trip(&doc, "styled"); + assert!(warnings.is_empty(), "{warnings:?}"); + let (back, _) = import(&export(&doc, None).unwrap().0, "x").unwrap(); + // In the document's order, the unused one included, each with its values. + let names = |d: &Document| d.styles.iter().map(|s| (s.name.clone(), s.kind.type_name())).collect::>(); + assert_eq!(names(&back), names(&doc)); + assert_eq!(back.styles[1].description, "The brand colour"); + assert!(matches!(&back.styles[2].kind, StyleKind::Paint { paints } if paints.len() == 2)); + let nodes = written(&doc); + let style = named(&nodes, "Brand/Primary"); + assert_eq!((style["styleType"].as_str(), style["type"].as_str()), (Some("FILL"), Some("ROUNDED_RECTANGLE"))); + assert_eq!(style["parentIndex"]["guid"], named(&nodes, "Internal Only Canvas")["guid"]); + assert_eq!(named(&nodes, "Heading")["styleType"], "TEXT"); + assert_eq!(named(&nodes, "Heading")["fontSize"], 24.0); + assert_eq!(named(&nodes, "Elevation/1")["styleType"], "EFFECT"); + assert_eq!(named(&nodes, "Desktop/12 columns")["styleType"], "GRID"); + let button = named(&nodes, "Button"); + assert_eq!(button["styleIdForFill"]["guid"], style["guid"]); + assert_eq!(button["styleIdForStrokeFill"]["guid"], named(&nodes, "Line")["guid"]); + assert_eq!(named(&nodes, "Screen")["styleIdForEffect"]["guid"], named(&nodes, "Elevation/1")["guid"]); + assert_eq!(named(&nodes, "Screen")["styleIdForGrid"]["guid"], named(&nodes, "Desktop/12 columns")["guid"]); + assert_eq!(named(&nodes, "Title")["styleIdForText"]["guid"], named(&nodes, "Heading")["guid"]); +} + +#[test] +fn text_styles_on_part_of_a_text_are_written_as_values() { + let mut doc = styled(); + let title = doc.nodes().find(|n| n.name == "Title").unwrap().id; + let n = doc.node_mut(title).unwrap(); + n.styles.text = None; + if let NodeKind::Text(td) = &mut n.kind { + let mut run = td.style.clone(); + run.style_id = Some("s-text".into()); + run.font_size = 12.0; + td.runs = vec![StyleRun { start: 0, end: 2, style: run }]; + } + let (bytes, warnings) = export(&doc, None).unwrap(); + assert!(warnings.iter().any(|w| w.contains("part of a text")), "{warnings:?}"); + let (back, _) = import(&bytes, "x").unwrap(); + let td = back.nodes().find_map(|n| n.kind.text().filter(|_| n.name == "Title")).unwrap(); + assert_eq!(td.runs.iter().map(|r| r.style.font_size).collect::>(), [12.0]); +} + +/// A component (a box that scales with it, a label that stays put) and instances of it: as it +/// is, resized, and scaled 1.5 times as a whole. Returns the document and the instances. +fn sized() -> (Document, [NodeId; 3]) { + let mut doc = Document::new("Sized"); + let page = doc.first_page().unwrap(); + let main = + add(&mut doc, page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Card", (0.0, 0.0), (100.0, 60.0), &|_| {}); + add(&mut doc, main, NodeKind::Rectangle, "Box", (10.0, 10.0), (40.0, 40.0), &|n| { + n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::Scale }; + }); + add(&mut doc, main, NodeKind::Text(TextData::new("Card")), "Label", (56.0, 20.0), (32.0, 16.0), &|_| {}); + let mut g = Gen { r: Lcg(1), doc, image: String::new(), frames: Vec::new(), layers: Vec::new() }; + let insts = [(); 3].map(|_| g.instance(main, page, BTreeMap::new())); + let mut doc = g.doc; + let kids = |doc: &Document, i: NodeId| doc.node(i).unwrap().children.clone(); + for (i, name) in insts.iter().zip(["Same", "Resized", "Scaled"]) { + doc.node_mut(*i).unwrap().name = name.into(); + } + // Resized to twice the size: the box follows, the label stays. + let n = doc.node_mut(insts[1]).unwrap(); + (n.width, n.height) = (200.0, 120.0); + let b = doc.node_mut(kids(&doc, insts[1])[0]).unwrap(); + (b.width, b.height, b.transform) = (80.0, 80.0, Affine::translate((20.0, 20.0))); + // Scaled: every length grows, stroke weights and the font size too. + for id in std::iter::once(insts[2]).chain(kids(&doc, insts[2])) { + let n = doc.node_mut(id).unwrap(); + (n.width, n.height) = (n.width * 1.5, n.height * 1.5); + n.stroke.weight *= 1.5; + if id != insts[2] { + let t = n.transform.translation(); + n.transform = Affine::translate((t.x * 1.5, t.y * 1.5)); + } + if let NodeKind::Text(td) = &mut n.kind { + td.style.font_size *= 1.5; + } + } + (doc, insts) +} + +/// The overrides (by the last id of their path) of a written instance: (overridden, derived). +fn overrides(inst: &Value) -> (BTreeMap>, BTreeMap>) { + let list = |v: &Value| -> BTreeMap> { + let entry = |e: &Value| { + let id = e["guidPath"]["guids"].as_array().unwrap().last().unwrap()["localID"].as_u64().unwrap(); + (id, e.as_object().unwrap().keys().filter(|k| *k != "guidPath").cloned().collect()) + }; + v.as_array().map(|a| a.iter().map(entry).collect()).unwrap_or_default() + }; + (list(&inst["symbolData"]["symbolOverrides"]), list(&inst["derivedSymbolData"])) +} + +#[test] +fn instances_keep_their_own_size_and_lay_their_layers_out_for_it() { + let (doc, _) = sized(); + let nodes = written(&doc); + let id = |name: &str| named(&nodes, name)["guid"]["localID"].as_u64().unwrap(); + let (main, boxed, label) = (id("Card"), id("Box"), id("Label")); + // As its main: only its name is its own. + let same = named(&nodes, "Same"); + assert_eq!(overrides(same), (BTreeMap::from([(main, vec!["name".to_string()])]), BTreeMap::new())); + assert_eq!(same["symbolData"]["uniformScaleFactor"], 1.0); + // Resized: the size is the instance's override (addressed by the main's id); where the box + // lands follows from it. + let resized = named(&nodes, "Resized"); + assert_eq!(resized["size"], json!({"x": 200.0, "y": 120.0})); + let (over, derived) = overrides(resized); + assert_eq!(over, BTreeMap::from([(main, vec!["name".to_string(), "size".to_string()])])); + assert_eq!(derived, BTreeMap::from([(boxed, vec!["fillGeometry".to_string(), "size".to_string(), "transform".to_string()])])); + assert_eq!(resized["derivedSymbolData"][0]["size"], json!({"x": 80.0, "y": 80.0})); + assert_eq!(resized["symbolData"]["uniformScaleFactor"], 1.0); + // Scaled: the factor says it; what it makes of the main's values is derived, not overridden. + let scaled = named(&nodes, "Scaled"); + assert_eq!(scaled["symbolData"]["uniformScaleFactor"], 1.5); + let (over, derived) = overrides(scaled); + assert_eq!(over, BTreeMap::from([(main, vec!["name".to_string(), "size".to_string()])])); + assert!(derived[&boxed].contains(&"strokeWeight".to_string()) && derived[&label].contains(&"fontSize".to_string()), "{derived:?}"); + // All of it reads back. + let warnings = same_after_round_trip(&doc, "sized"); + assert!(warnings.is_empty(), "{warnings:?}"); +} + +#[test] +fn an_instance_named_after_its_set_has_no_name_of_its_own() { + let (mut doc, insts) = sized(); + let page = doc.first_page().unwrap(); + let main = doc.nodes().find(|n| n.name == "Card").unwrap().id; + let set = doc.create(page, NodeKind::ComponentSet { description: String::new() }, "Chip", 0.0, 200.0, 300.0, 100.0).unwrap(); + doc.reparent(main, set, None).unwrap(); + doc.node_mut(main).unwrap().name = "State=On".into(); + doc.node_mut(insts[0]).unwrap().name = "Chip".into(); + let nodes = written(&doc); + let chip = nodes.iter().find(|n| n["type"] == "INSTANCE" && n["name"] == "Chip").unwrap(); + assert!(overrides(chip).0.values().all(|keys| !keys.contains(&"name".to_string()))); + // Renamed, the name is its own. + let other = nodes.iter().find(|n| n["type"] == "INSTANCE" && n["name"] == "Resized").unwrap(); + assert!(overrides(other).0.values().any(|keys| keys.contains(&"name".to_string()))); +} + +#[test] +fn layers_bound_like_their_mains_are_no_overrides_in_another_mode() { + let mut doc = themed(); + let page = doc.first_page().unwrap(); + let main = + add(&mut doc, page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Chip", (0.0, 300.0), (80.0, 30.0), &|n| { + n.bound.insert("fill".into(), "v-bg".into()); + }); + add(&mut doc, main, NodeKind::Text(TextData::new("Chip")), "Chip label", (8.0, 6.0), (40.0, 16.0), &|n| { + n.fills = vec![solid(0.0, 0.0, 0.0)]; + n.bound.insert("fill".into(), "v-fg".into()); + }); + let dark = add(&mut doc, page, NodeKind::Frame, "Dark", (200.0, 300.0), (200.0, 100.0), &|n| { + n.var_modes.insert("c-theme".into(), "m-dark".into()); + }); + let mut g = Gen { r: Lcg(2), doc, image: String::new(), frames: Vec::new(), layers: Vec::new() }; + let inst = g.instance(main, dark, BTreeMap::new()); + let mut doc = g.doc; + // What the variables give in the dark mode. + doc.node_mut(inst).unwrap().name = "Dark chip".into(); + doc.node_mut(inst).unwrap().fills = vec![solid(0.0, 0.125, 0.25)]; + let label = doc.node(inst).unwrap().children[0]; + doc.node_mut(label).unwrap().fills = vec![solid(0.75, 0.75, 0.75)]; + let nodes = written(&doc); + let chip = named(&nodes, "Dark chip"); + let (over, derived) = overrides(chip); + assert!(over.values().all(|keys| !keys.contains(&"fillPaints".to_string())), "{over:?}"); + assert_eq!(derived.values().next().unwrap(), &["fillPaints".to_string()]); + assert_eq!(chip["fillPaints"][0]["color"]["b"], 0.25); + assert!(chip["fillPaints"][0]["colorVar"].is_object()); + let warnings = same_after_round_trip(&doc, "dark chip"); + assert!(warnings.is_empty(), "{warnings:?}"); +} + +#[test] +fn layers_of_a_swapped_nested_instance_keep_what_they_change() { + let (mut doc, insts) = sized(); + let page = doc.first_page().unwrap(); + let card = doc.nodes().find(|n| n.name == "Card").unwrap().id; + // Another component like the card, and a row holding a card instance. + let other = add( + &mut doc, + page, + NodeKind::Component { description: String::new(), props: BTreeMap::new() }, + "Other", + (0.0, 400.0), + (100.0, 60.0), + &|_| {}, + ); + add(&mut doc, other, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Dot", (10.0, 10.0), (40.0, 40.0), &|_| {}); + let row = + add(&mut doc, page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Row", (0.0, 500.0), (300.0, 80.0), &|_| {}); + let mut g = Gen { r: Lcg(3), doc, image: String::new(), frames: Vec::new(), layers: Vec::new() }; + let thumb = g.instance(card, row, BTreeMap::new()); + g.doc.node_mut(thumb).unwrap().name = "Thumb".into(); + let outer = g.instance(row, page, BTreeMap::new()); + let mut doc = g.doc; + doc.node_mut(outer).unwrap().name = "Row instance".into(); + // In the row's instance the thumb shows the other component, its dot made smaller. + let nested = doc.node(outer).unwrap().children[0]; + let old: Vec = doc.node(nested).unwrap().children.clone(); + for k in old { + doc.remove(k).unwrap(); + } + if let Some(NodeKind::Instance { component, .. }) = doc.node_mut(nested).map(|n| &mut n.kind) { + *component = other; + } + let dot_main = doc.node(other).unwrap().children[0]; + let mut dot = doc.node(dot_main).unwrap().clone(); + (dot.id, dot.source, dot.width, dot.height) = (doc.alloc_id(), Some(dot_main), 20.0, 20.0); + dot.fills = vec![solid(1.0, 0.0, 0.0)]; + doc.insert(dot, nested, None).unwrap(); + let (bytes, warnings) = export(&doc, None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let nodes = written(&doc); + let (over, derived) = overrides(named(&nodes, "Row instance")); + let dot_id = named(&nodes, "Dot")["guid"]["localID"].as_u64().unwrap(); + assert!(over[&dot_id].contains(&"fillPaints".to_string()) && derived[&dot_id].contains(&"size".to_string()), "{over:?} {derived:?}"); + let (back, _) = import(&bytes, "x").unwrap(); + let dots: Vec<(f64, usize)> = back.nodes().filter(|n| n.name == "Dot").map(|n| (n.width, n.fills.len())).collect(); + assert_eq!(dots, [(40.0, 1), (20.0, 1)]); + let _ = insts; +} + +#[test] +fn variable_width_strokes_say_their_reach_on_each_side() { + let mut doc = Document::new("Widths"); + let page = doc.first_page().unwrap(); + add(&mut doc, page, NodeKind::Line, "Tapered", (0.0, 0.0), (100.0, 0.0), &|n| { + n.fills.clear(); + n.strokes = vec![solid(0.0, 0.0, 0.0)]; + n.stroke.weight = 8.0; + n.stroke.align = StrokeAlign::Center; + let mut profile = WidthProfile::new([(0.0, 0.25), (0.5, 1.5), (1.0, 0.0)]); + profile.points[1].bias = 0.5; + n.stroke.width_profile = Some(profile); + }); + let nodes = written(&doc); + let line = named(&nodes, "Tapered"); + let points = line["variableWidthPoints"].as_array().unwrap(); + assert_eq!(points.len(), 3); + assert_eq!(points[0], json!({"position": 0.0, "ascent": 1.0, "descent": 1.0})); + // Wider on the right: half the width is 6, a quarter of it on the left. + assert_eq!(points[1], json!({"position": 0.5, "ascent": 3.0, "descent": 9.0})); + // The outline is left to the reader: it follows from the points. + assert!(line.get("strokeGeometry").is_none()); + let warnings = same_after_round_trip(&doc, "widths"); + assert!(warnings.is_empty(), "{warnings:?}"); + // A stroke without a weight has no reach to say; brushes are still written plain. + let line = doc.nodes().find(|n| n.name == "Tapered").unwrap().id; + doc.node_mut(line).unwrap().stroke.weight = 0.0; + assert!(named(&written(&doc), "Tapered").get("variableWidthPoints").is_none()); +} + +#[test] +fn the_schema_numbers_variables_styles_and_instance_data_as_the_format_does() { + let s = Schema::parse(export::SCHEMA).unwrap(); + let def = |n: &str| s.defs.iter().find(|d| d.name == n).unwrap_or_else(|| panic!("no {n}")); + let id = |d: &str, f: &str| def(d).fields.iter().find(|x| x.name == f).unwrap_or_else(|| panic!("no {d}.{f}")).value; + let node = |f: &str| id("NodeChange", f); + assert_eq!((id("NodeType", "VARIABLE"), id("NodeType", "VARIABLE_SET")), (28, 31)); + assert_eq!((node("variableConsumptionMap"), node("variableModeBySetMap"), node("variableSetModes")), (307, 316, 312)); + assert_eq!((node("variableSetID"), node("variableResolvedType"), node("variableDataValues"), node("variableScopes")), (313, 314, 315, 353)); + assert_eq!((node("styleType"), node("sortPosition"), node("description")), (163, 320, 318)); + assert_eq!( + (node("styleIdForFill"), node("styleIdForStrokeFill"), node("styleIdForText"), node("styleIdForEffect"), node("styleIdForGrid")), + (332, 333, 334, 335, 336) + ); + assert_eq!((node("derivedSymbolData"), id("SymbolData", "uniformScaleFactor"), node("variableWidthPoints")), (125, 3, 447)); + assert_eq!((id("Paint", "colorVar"), id("Paint", "stopsVar"), id("Effect", "colorVar"), id("Effect", "startRadiusVar")), (21, 23, 10, 45)); + assert_eq!((id("LayoutGrid", "numSectionsVar"), id("LayoutGrid", "gutterSizeVar")), (10, 13)); + assert_eq!((id("VariableAnyValue", "alias"), id("VariableAnyValue", "colorValue"), id("VariableField", "BORDER_RIGHT_WEIGHT")), (4, 5, 21)); + assert_eq!((id("VariableScope", "STROKE"), id("StyleType", "GRID"), id("VariableDataType", "ALIAS")), (9, 6, 3)); + for n in [ + "StyleId", + "VariableID", + "VariableSetID", + "VariableSetMode", + "VariableDataValues", + "VariableModeBySetMap", + "ColorStopVar", + "VariableWidthPoint", + ] { + assert_eq!(def(n).kind, Kind::Message, "{n}"); + } +} + +// ---- damaged input ---- + +/// Change values all over a decoded message, keeping each value's kind and its numbers finite (so +/// it still encodes). +fn scramble(v: &mut Kv, r: &mut Lcg) { + match v { + Kv::Float(f) if r.chance(8) => *f = r.pick(&[f32::MAX, -1e30, 1e30, 0.0, -1.0, 1e-30]), + Kv::Uint(u) if r.chance(12) => *u = r.pick(&[0, 1, 2, 7, 4_294_967_295]), + Kv::Int(i) if r.chance(12) => *i = r.pick(&[0, -1, 2_147_483_647, -2_147_483_648]), + Kv::Str(s) if r.chance(8) => *s = r.pick(&["", "!", "~~~~~~", "\u{FFFF}", "ALIAS"]).to_string(), + Kv::Bool(b) if r.chance(8) => *b = !*b, + Kv::Array(a) => { + match r.below(12) { + 0 => a.clear(), + 1 => a.truncate(a.len() / 2), + 2 if a.len() < 64 => a.extend(a.clone()), + _ => {} + } + a.iter_mut().for_each(|x| scramble(x, r)); + } + Kv::Object(f) => { + // Messages may lose fields (structs must keep theirs to encode). + let message = f.iter().any(|(k, _)| matches!(*k, "type" | "guidPath" | "entries" | "variableData" | "symbolID" | "dataType")); + if message && f.len() > 2 && r.chance(6) { + let i = r.below(f.len() as u64) as usize; + f.remove(i); + } + f.iter_mut().for_each(|(_, x)| scramble(x, r)); + } + _ => {} + } +} + +#[test] +fn damaged_variables_styles_and_instances_never_panic() { + let docs = [themed(), styled(), sized().0]; + let mut read = 0; + for (d, doc) in docs.iter().enumerate() { + let (bytes, _) = export(doc, None).unwrap(); + let (schema, message) = decode_canvas(&Archive::open(&bytes).unwrap().canvas).unwrap(); + let root = schema.find("Message").unwrap(); + for seed in 0..120 { + let mut msg = schema.decode_message(root, &message).unwrap(); + scramble(&mut msg, &mut Lcg(seed * 3 + d as u64)); + let Ok(body) = encode::message(&schema, root, &msg) else { continue }; + let mut canvas = b"fig-kiwi".to_vec(); + canvas.extend_from_slice(&101u32.to_le_bytes()); + for chunk in [encode::schema(&schema), body] { + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::fast()); + std::io::Write::write_all(&mut z, &chunk).unwrap(); + let c = z.finish().unwrap(); + canvas.extend_from_slice(&(c.len() as u32).to_le_bytes()); + canvas.extend_from_slice(&c); + } + if let Ok((back, _)) = import(&canvas, "x") { + read += 1; + // What came back can be written again. + export(&back, None).unwrap(); + } + } + } + assert!(read > 100, "only {read} damaged files were read"); +} + +#[test] +fn junk_style_definitions_and_orders_never_panic() { + let page = json!({"id": "0:1", "type": "CANVAS", "name": "P", "explicitVariableModes": {"c": 5, "": "m", "x": null}, + "children": [{"id": "1:2", "type": "FRAME", "name": "R", "styles": {"fill": "1:9", "text": 4}, + "fills": [{"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}, "boundVariables": {"color": {"type": "VARIABLE_ALIAS", "id": "v"}}}, + {"type": "GRADIENT_LINEAR", "gradientStops": [{"position": 0, "color": {"r": 0, "g": 0, "b": 0, "a": 1}, "boundVariables": {"color": 7}}, + {"boundVariables": {"color": {"type": "VARIABLE_ALIAS", "id": "v"}}}]}], + "effects": [{"type": "DROP_SHADOW", "boundVariables": {"radius": {"type": "VARIABLE_ALIAS", "id": "n"}, "nope": 1, "color": []}}], + "layoutGrids": [{"pattern": "GRID", "boundVariables": {"count": {"type": "VARIABLE_ALIAS", "id": "n"}, "offset": "x"}}]}]}); + let meta = json!({"variableCollections": {"c": {"id": "c", "name": "C", "modes": [{"modeId": "m", "name": "M"}]}}, + "variables": {"v": {"id": "v", "name": "V", "variableCollectionId": "c", "resolvedType": "COLOR", "valuesByMode": {"m": {"r": 1, "g": 1, "b": 1, "a": 1}}}, + "n": {"id": "n", "name": "N", "variableCollectionId": "c", "resolvedType": "FLOAT", "valuesByMode": {"m": 4}}}, + "collectionOrder": [5, "c", "c", null, {"x": 1}], "variableOrder": "v"}); + let definitions = [ + json!([]), + json!("styles"), + json!([5, null, {}, {"id": ""}, {"id": "1:9"}, {"id": "1:9", "node": 5, "styleType": "FILL"}]), + json!([{"id": "1:9", "styleType": "FILL", "node": {"fills": "red"}}, {"id": "1:9", "styleType": "TEXT", "node": {"style": []}}]), + json!([{"id": "a", "styleType": "EFFECT", "node": {"effects": [1, {"type": "?"}]}}, {"id": "b", "styleType": "GRID", "node": {"layoutGrids": {}}}, + {"id": "c", "styleType": "TEXT", "node": {"type": "TEXT", "characters": 7, "style": {"fontSize": "big"}}}, {"id": "d", "styleType": "NOPE", "node": {}}]), + Value::Array((0..20_000).map(|i| json!({"id": format!("s{i}"), "styleType": "FILL", "node": {"fills": []}})).collect()), + ]; + for d in definitions { + let file = json!({"name": "x", "document": {"id": "0:0", "type": "DOCUMENT", "children": [page.clone()]}, "meta": meta.clone(), "styleDefinitions": d}); + let (doc, _) = crate::rest::import(&file, &|_| None).unwrap(); + assert!(doc.styles.len() <= 10_001); + // The bound paint, stop, effect and grid fields come across. + let r = doc.nodes().find(|n| n.name == "R").unwrap(); + assert_eq!(r.bound.keys().map(String::as_str).collect::>(), ["effects/0/radius", "fills/0/color", "layoutGrids/0/count"]); + export(&doc, None).unwrap(); + } +} + +/// Local only (`FIGCRAFT_FIG_SAMPLES`): each sample's variables, modes, bindings, styles and style +/// links come back the same after an export and another import. +#[test] +fn local_samples_keep_their_variables_and_styles() { + for p in crate::fig::tests::samples() { + let bytes = std::fs::read(&p).unwrap(); + let (mut a, _) = import(&bytes, "sample").unwrap(); + link(&mut a); + let (out, warnings) = export(&a, None).unwrap_or_else(|e| panic!("{}: {e}", p.display())); + let (mut b, _) = import(&out, "sample").unwrap_or_else(|e| panic!("{}: {e}", p.display())); + assert!(!warnings.iter().any(|w| w.contains("variable") || w.contains("style")), "{}: {warnings:?}", p.display()); + by_name(&mut a); + by_name(&mut b); + assert_same(&a, &b, &p.display().to_string()); + assert_eq!(json!(a.variables), json!(b.variables), "{}", p.display()); + let styles = |d: &Document| d.styles.iter().map(|s| (s.name.clone(), s.kind.type_name())).collect::>(); + assert_eq!(styles(&a), styles(&b), "{}", p.display()); + } +} diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 32c9aeb..70e98b0 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -6,11 +6,13 @@ //! `images/`. The schema travels in the file, so it describes only the fields written here //! ([`SCHEMA`]). The message lists one node change per layer (its id, its parent and its position //! among the siblings, then its fields), parents first, plus blobs (vector networks and outlines). -//! Instances are written as their main and the fields their layers override (by id path), like -//! the files the import reads. What the format can't carry yet (shared styles, variables, Dev Mode -//! data, …) is left out and listed in the warnings. Round trips through our own importer are -//! tested, and written files were opened in other software (`docs/file-format-parity.md` says what -//! was checked); other apps may not open every file. +//! Instances are written as their main, the fields they and their layers override (by id path) and +//! what follows from those (derived data: sizes, places, outlines), like the files the import +//! reads. Shared styles, variable collections and variables are nodes of their own on a hidden +//! page; layers, paints, effects and layout grids name the ones they use. What the format can't +//! carry yet (Dev Mode data, comments, …) is left out and listed in the warnings. Round trips +//! through our own importer are tested, and written files were opened in other software +//! (`docs/file-format-parity.md` says what was checked); other apps may not open every file. use std::collections::BTreeMap; use std::io::Write; @@ -24,8 +26,8 @@ use figcraft_doc::proto::{ }; use figcraft_doc::{ AutoLayout, BoolOp, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, HConstraint, LayoutMode, LetterSpacing, LineHeight, - ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAlignV, - TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VectorNetwork, WindingRule, + ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, StrokeJoin, StyleKind, TextAlignH, + TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, VariableValue, VectorNetwork, WindingRule, }; use serde_json::Value; @@ -59,7 +61,7 @@ const AUX: u32 = 2; pub(super) const SCHEMA: &str = " enum MessageType JOIN_START=0 NODE_CHANGES=1 enum NodePhase CREATED=0 REMOVED=1 -enum NodeType NONE=0 DOCUMENT=1 CANVAS=2 GROUP=3 FRAME=4 BOOLEAN_OPERATION=5 VECTOR=6 STAR=7 LINE=8 ELLIPSE=9 RECTANGLE=10 REGULAR_POLYGON=11 ROUNDED_RECTANGLE=12 TEXT=13 SLICE=14 SYMBOL=15 INSTANCE=16 SECTION=25 +enum NodeType NONE=0 DOCUMENT=1 CANVAS=2 GROUP=3 FRAME=4 BOOLEAN_OPERATION=5 VECTOR=6 STAR=7 LINE=8 ELLIPSE=9 RECTANGLE=10 REGULAR_POLYGON=11 ROUNDED_RECTANGLE=12 TEXT=13 SLICE=14 SYMBOL=15 INSTANCE=16 SECTION=25 VARIABLE=28 VARIABLE_SET=31 enum BlendMode PASS_THROUGH=0 NORMAL=1 DARKEN=2 MULTIPLY=3 LINEAR_BURN=4 COLOR_BURN=5 LIGHTEN=6 SCREEN=7 LINEAR_DODGE=8 COLOR_DODGE=9 OVERLAY=10 SOFT_LIGHT=11 HARD_LIGHT=12 DIFFERENCE=13 EXCLUSION=14 HUE=15 SATURATION=16 COLOR=17 LUMINOSITY=18 enum PaintType SOLID=0 GRADIENT_LINEAR=1 GRADIENT_RADIAL=2 GRADIENT_ANGULAR=3 GRADIENT_DIAMOND=4 IMAGE=5 enum ImageScaleMode STRETCH=0 FIT=1 FILL=2 TILE=3 @@ -99,8 +101,10 @@ enum Axis X=0 Y=1 enum ImageType PNG=0 JPEG=1 SVG=2 PDF=3 enum ExportConstraintType CONTENT_SCALE=0 CONTENT_WIDTH=1 CONTENT_HEIGHT=2 enum ComponentPropType BOOL=0 TEXT=1 INSTANCE_SWAP=3 VARIANT=4 -enum VariableField MISSING=0 VISIBLE=10 TEXT_DATA=11 OVERRIDDEN_SYMBOL_ID=37 +enum VariableField MISSING=0 PARAGRAPH_SPACING=2 PARAGRAPH_INDENT=3 STROKE_WEIGHT=4 STACK_SPACING=5 STACK_PADDING_LEFT=6 STACK_PADDING_TOP=7 STACK_PADDING_RIGHT=8 STACK_PADDING_BOTTOM=9 VISIBLE=10 TEXT_DATA=11 WIDTH=12 HEIGHT=13 RECTANGLE_TOP_LEFT_CORNER_RADIUS=14 RECTANGLE_TOP_RIGHT_CORNER_RADIUS=15 RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS=16 RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS=17 BORDER_TOP_WEIGHT=18 BORDER_BOTTOM_WEIGHT=19 BORDER_LEFT_WEIGHT=20 BORDER_RIGHT_WEIGHT=21 STACK_COUNTER_SPACING=23 MIN_WIDTH=24 MAX_WIDTH=25 MIN_HEIGHT=26 MAX_HEIGHT=27 FONT_FAMILY=28 FONT_STYLE=29 OPACITY=31 FONT_SIZE=32 LETTER_SPACING=34 LINE_HEIGHT=36 OVERRIDDEN_SYMBOL_ID=37 enum VariableDataType BOOLEAN=0 FLOAT=1 STRING=2 ALIAS=3 COLOR=4 PROP_REF=13 +enum VariableScope ALL_SCOPES=0 TEXT_CONTENT=1 CORNER_RADIUS=2 WIDTH_HEIGHT=3 GAP=4 ALL_FILLS=5 FRAME_FILL=6 SHAPE_FILL=7 TEXT_FILL=8 STROKE=9 STROKE_FLOAT=10 EFFECT_FLOAT=11 EFFECT_COLOR=12 OPACITY=13 FONT_STYLE=14 FONT_FAMILY=15 FONT_SIZE=16 LINE_HEIGHT=17 LETTER_SPACING=18 PARAGRAPH_SPACING=19 PARAGRAPH_INDENT=20 +enum StyleType NONE=0 FILL=1 STROKE=2 TEXT=3 EFFECT=4 EXPORT=5 GRID=6 enum InteractionType ON_CLICK=0 DRAG=9 ON_HOVER=4 ON_PRESS=7 MOUSE_IN=2 MOUSE_OUT=3 MOUSE_DOWN=5 MOUSE_UP=6 AFTER_TIMEOUT=1 ON_KEY_DOWN=10 enum TriggerDevice KEYBOARD=0 UNKNOWN_CONTROLLER=1 enum ConnectionType NONE=0 INTERNAL_NODE=1 URL=2 BACK=3 CLOSE=4 @@ -119,8 +123,13 @@ struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 struct ColorStop Color color, float position struct ParentIndex GUID guid, string position message Image byte[] hash=1, string name=2 -message Paint PaintType type=1, Color color=2, float opacity=3, bool visible=4, BlendMode blendMode=5, ColorStop[] stops=6, Matrix transform=7, Image image=8, ImageScaleMode imageScaleMode=10, float rotation=11, float scale=12 -message Effect EffectType type=1, Color color=2, Vector offset=3, float radius=4, bool visible=5, BlendMode blendMode=6, float spread=7, bool showShadowBehindNode=8, BlurOpType blurOpType=19, float startRadius=21, Vector startOffset=20, Vector endOffset=28, NoiseType noiseType=26, Vector noiseSize=22, float density=25, float opacity=27, Color secondaryColor=24, bool clipToShape=23 +message VariableWidthPoint float position=1, float ascent=2, float descent=3 +message StyleId GUID guid=1 +message VariableID GUID guid=1 +message VariableSetID GUID guid=1 +message ColorStopVar Color color=1, VariableData colorVar=2, float position=3 +message Paint PaintType type=1, Color color=2, float opacity=3, bool visible=4, BlendMode blendMode=5, ColorStop[] stops=6, Matrix transform=7, Image image=8, ImageScaleMode imageScaleMode=10, float rotation=11, float scale=12, VariableData colorVar=21, ColorStopVar[] stopsVar=23 +message Effect EffectType type=1, Color color=2, Vector offset=3, float radius=4, bool visible=5, BlendMode blendMode=6, float spread=7, bool showShadowBehindNode=8, BlurOpType blurOpType=19, float startRadius=21, Vector startOffset=20, Vector endOffset=28, NoiseType noiseType=26, Vector noiseSize=22, float density=25, float opacity=27, Color secondaryColor=24, bool clipToShape=23, VariableData radiusVar=9, VariableData colorVar=10, VariableData spreadVar=11, VariableData xVar=12, VariableData yVar=13, VariableData startRadiusVar=45 struct FontName string family, string style, string postscript struct Number float value, NumberUnits units message Hyperlink string url=1 @@ -130,19 +139,24 @@ message Path WindingRule windingRule=1, uint commandsBlob=2, uint styleID=3 message VectorData uint vectorNetworkBlob=1, Vector normalizedSize=2, NodeChange[] styleOverrideTable=3 message ArcData float startingAngle=1, float endingAngle=2, float innerRadius=3 message OptionalVector Vector value=1 -message LayoutGrid LayoutGridType type=1, Axis axis=2, bool visible=3, int numSections=4, float offset=5, float sectionSize=6, float gutterSize=7, Color color=8, LayoutGridPattern pattern=9 +message LayoutGrid LayoutGridType type=1, Axis axis=2, bool visible=3, int numSections=4, float offset=5, float sectionSize=6, float gutterSize=7, Color color=8, LayoutGridPattern pattern=9, VariableData numSectionsVar=10, VariableData offsetVar=11, VariableData sectionSizeVar=12, VariableData gutterSizeVar=13 struct ExportConstraint ExportConstraintType type, float value message ExportSettings string suffix=1, ImageType imageType=2, ExportConstraint constraint=3 message GUIDPath GUID[] guids=1 -message SymbolData GUID symbolID=1, NodeChange[] symbolOverrides=2 +message SymbolData GUID symbolID=1, NodeChange[] symbolOverrides=2, float uniformScaleFactor=3 message ComponentPropValue bool boolValue=1, TextData textValue=2, GUID guidValue=3 message ComponentPropDef GUID id=1, string name=2, ComponentPropValue initialValue=3, ComponentPropType type=6 message ComponentPropAssignment GUID defID=1, ComponentPropValue value=2 message PropRefValue GUID defId=1 -message VariableAnyValue PropRefValue propRefValue=13 +message VariableAnyValue bool boolValue=1, string textValue=2, float floatValue=3, VariableID alias=4, Color colorValue=5, PropRefValue propRefValue=13 message VariableData VariableAnyValue value=1, VariableDataType dataType=2, VariableResolvedDataType resolvedDataType=3 message VariableDataMapEntry VariableData variableData=2, VariableField variableField=3 message VariableDataMap VariableDataMapEntry[] entries=1 +message VariableModeBySetMapEntry VariableSetID variableSetID=1, GUID variableModeID=2 +message VariableModeBySetMap VariableModeBySetMapEntry[] entries=1 +message VariableSetMode GUID id=1, string name=2, string sortPosition=3 +message VariableDataValuesEntry GUID modeID=1, VariableData variableData=2 +message VariableDataValues VariableDataValuesEntry[] entries=1 message KeyTrigger int[] keyCodes=1, TriggerDevice triggerDevice=2 message PrototypeEvent InteractionType interactionType=1, float transitionTimeout=6, KeyTrigger keyTrigger=4 message PrototypeAction GUID transitionNodeID=1, TransitionType transitionType=2, float transitionDuration=3, EasingType easingType=4, float[] easingFunction=12, ConnectionType connectionType=7, NavigationType navigationType=10, string connectionURL=8, bool transitionPreserveScroll=11 @@ -151,7 +165,7 @@ message PrototypeStartingPoint string name=1, string position=3 message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Color backgroundColor=2 message GUIDPositionMapEntry GUID id=1, string position=2 message GUIDPositionMap GUIDPositionMapEntry[] entries=1 -message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, SymbolData symbolData=113, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 +message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 struct Blob byte[] bytes message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6 "; @@ -397,9 +411,28 @@ struct Exporter<'d> { overlays: BTreeMap, /// Flow starting points: layer → (order, name). starts: BTreeMap, + /// Shared styles, variable collections and variables → local id of their node's GUID (session + /// [`LAYERS`], after the layers'), and (collection, mode) → local id of the mode's GUID + /// (session [`AUX`]). + style_ids: BTreeMap<&'d str, u32>, + set_ids: BTreeMap<&'d str, u32>, + var_ids: BTreeMap<&'d str, u32>, + mode_ids: BTreeMap<(&'d str, &'d str), u32>, warnings: Vec, } +/// The variables bound to one layer, as the fields that carry them: paints by (fills?, index), +/// gradient stops by (fills?, paint, stop), effect and layout grid fields by (index, field), and +/// the entries of the layer's own map. +#[derive(Default)] +struct Bound { + paints: BTreeMap<(bool, usize), Kv<'static>>, + stops: BTreeMap<(bool, usize, usize), Kv<'static>>, + effects: BTreeMap<(usize, &'static str), Kv<'static>>, + grids: BTreeMap<(usize, &'static str), Kv<'static>>, + entries: Vec>, +} + impl<'d> Exporter<'d> { fn new(doc: &'d Document) -> Self { Self { @@ -415,6 +448,10 @@ impl<'d> Exporter<'d> { aux: 0, overlays: BTreeMap::new(), starts: BTreeMap::new(), + style_ids: BTreeMap::new(), + set_ids: BTreeMap::new(), + var_ids: BTreeMap::new(), + mode_ids: BTreeMap::new(), warnings: Vec::new(), } } @@ -446,12 +483,7 @@ impl<'d> Exporter<'d> { for (i, p) in doc.pages.iter().enumerate() { self.emit(*p, guid(0, 0), i, None, 0); } - if !doc.styles.is_empty() { - self.warn("shared styles were left out (their layers keep the values)"); - } - if !doc.variables.collections.is_empty() || !doc.variables.variables.is_empty() { - self.warn("variables were left out"); - } + self.assets(doc.pages.len()); if !doc.comments.is_empty() { self.warn("comments were left out"); } @@ -517,6 +549,279 @@ impl<'d> Exporter<'d> { } } } + // The page that holds styles and variables takes the next id, then their nodes. + let mut next = u32::try_from(self.ids.len() + 1).unwrap_or(u32::MAX); + let mut node = || { + next = next.saturating_add(1); + next + }; + for st in &doc.styles { + self.style_ids.entry(st.id.as_str()).or_insert_with(&mut node); + } + for c in &doc.variables.collections { + self.set_ids.entry(c.id.as_str()).or_insert_with(&mut node); + } + for v in &doc.variables.variables { + if self.set_ids.contains_key(v.collection.as_str()) { + self.var_ids.entry(v.id.as_str()).or_insert_with(&mut node); + } + } + for c in &doc.variables.collections { + for m in &c.modes { + if !self.mode_ids.contains_key(&(c.id.as_str(), m.id.as_str())) { + let id = self.next_aux(); + self.mode_ids.insert((c.id.as_str(), m.id.as_str()), id); + } + } + } + } + + /// The hidden page after the `pages` real ones that holds the shared styles, the variable + /// collections and the variables, each a node of its own. + fn assets(&mut self, pages: usize) { + let doc = self.doc; + if self.style_ids.is_empty() && self.set_ids.is_empty() { + return; + } + let page = u32::try_from(self.ids.len() + 1).unwrap_or(u32::MAX); + let mut f: Fields = vec![ + ("guid", guid(LAYERS, page)), + ("phase", e("CREATED")), + ("parentIndex", o(vec![("guid", guid(0, 0)), ("position", s(&position(pages)))])), + ("type", e("CANVAS")), + ("name", s("Internal Only Canvas")), + ]; + f.extend(root_fields().into_iter().map(|(k, v)| if k == "visible" { (k, b(false)) } else { (k, v) })); + f.push(("internalOnly", b(true))); + self.nodes.push(o(f)); + let mut index = 0usize; + let mut head = |local: u32, ty: &'static str, name: &str| -> Fields { + index += 1; + vec![ + ("guid", guid(LAYERS, local)), + ("phase", e("CREATED")), + ("parentIndex", o(vec![("guid", guid(LAYERS, page)), ("position", s(&position(index)))])), + ("type", e(ty)), + ("name", s(name)), + ("sortPosition", s(&position(index))), + ] + }; + let none = Bound::default(); + for st in &doc.styles { + let Some(&local) = self.style_ids.get(st.id.as_str()) else { continue }; + // A sample layer that shows the style: a square for paints and effects, a frame for + // grids, two letters for text. + let (ty, kind) = match &st.kind { + StyleKind::Paint { .. } => ("ROUNDED_RECTANGLE", "FILL"), + StyleKind::Effect { .. } => ("ROUNDED_RECTANGLE", "EFFECT"), + StyleKind::Grid { .. } => ("FRAME", "GRID"), + StyleKind::Text { .. } => ("TEXT", "TEXT"), + }; + let mut f = head(local, ty, &st.name); + f.extend([ + ("styleType", e(kind)), + ("visible", b(true)), + ("opacity", fl(1.0)), + ("size", xy(100.0, 100.0)), + ("transform", matrix(Affine::IDENTITY)), + ("strokeWeight", fl(0.0)), + ("strokeAlign", e("INSIDE")), + ("strokeJoin", e("MITER")), + ]); + if !st.description.is_empty() { + f.push(("description", s(&st.description))); + } + match &st.kind { + StyleKind::Paint { paints } => f.push(("fillPaints", self.paints(paints, true, &none))), + StyleKind::Effect { effects } => { + let list = effects.iter().enumerate().map(|(i, x)| self.effect(x, i, &none)).collect(); + f.extend([("fillPaints", arr(Vec::new())), ("effects", arr(list))]); + } + StyleKind::Grid { grids } => { + let list = grids.iter().enumerate().map(|(i, g)| layout_grid(g, i, &none)).collect(); + f.extend([("fillPaints", arr(Vec::new())), ("layoutGrids", arr(list))]); + } + StyleKind::Text { style } => { + let td = TextData { style: style.as_text_style(), ..TextData::new("Ag") }; + let fills = [Paint::solid(Color::BLACK)]; + f.push(("fillPaints", self.paints(style.fills.as_deref().unwrap_or(&fills), true, &none))); + self.text(&td, &fills, &mut f); + } + } + self.nodes.push(o(f)); + } + for c in &doc.variables.collections { + let Some(&local) = self.set_ids.get(c.id.as_str()) else { continue }; + let mut f = head(local, "VARIABLE_SET", &c.name); + let modes = c.modes.iter().enumerate().filter_map(|(i, m)| { + let id = self.mode_ids.get(&(c.id.as_str(), m.id.as_str()))?; + Some(o(vec![("id", guid(AUX, *id)), ("name", s(&m.name)), ("sortPosition", s(&position(i)))])) + }); + f.push(("variableSetModes", arr(modes.collect()))); + self.nodes.push(o(f)); + if c.extends.is_some() { + self.warn("extended variable collections were written as collections of their own"); + } + } + for v in &doc.variables.variables { + let ids = (self.var_ids.get(v.id.as_str()), self.set_ids.get(v.collection.as_str())); + let (Some(&local), Some(&set), Some(c)) = (ids.0, ids.1, doc.variables.collections.iter().find(|c| c.id == v.collection)) else { + self.warn("variables whose collection isn't in the document were left out"); + continue; + }; + let resolved = resolved_type(v.kind); + let mut values = Vec::new(); + for m in &c.modes { + let (Some(mode), Some(val)) = (self.mode_ids.get(&(c.id.as_str(), m.id.as_str())), v.values.get(&m.id)) else { continue }; + let (ty, value) = match val { + VariableValue::Color(x) => ("COLOR", ("colorValue", color(*x))), + VariableValue::Number(x) => ("FLOAT", ("floatValue", fl(*x))), + // NULs can't be stored in a string. + VariableValue::String(x) => ("STRING", ("textValue", s(&x.replace('\0', "")))), + VariableValue::Boolean(x) => ("BOOLEAN", ("boolValue", b(*x))), + VariableValue::Alias(a) => match self.var_ids.get(a.as_str()) { + Some(t) => ("ALIAS", ("alias", o(vec![("guid", guid(LAYERS, *t))]))), + None => { + self.warn("variable aliases to variables that aren't in the document were left out"); + continue; + } + }, + }; + let data = o(vec![("value", o(vec![value])), ("dataType", e(ty)), ("resolvedDataType", e(resolved))]); + values.push(o(vec![("modeID", guid(AUX, *mode)), ("variableData", data)])); + } + let mut f = head(local, "VARIABLE", &v.name); + f.extend([ + ("variableSetID", o(vec![("guid", guid(LAYERS, set))])), + ("variableResolvedType", e(resolved)), + ("variableDataValues", o(vec![("entries", arr(values))])), + ]); + if !v.description.is_empty() { + f.push(("description", s(&v.description))); + } + if let Some(scopes) = &v.scopes { + let known: Vec = scopes.iter().filter_map(|sc| scope_name(sc)).map(e).collect(); + if known.len() < scopes.len() { + self.warn("variable scopes the format has no name for were left out"); + } + f.push(("variableScopes", arr(known))); + } + if v.hidden_from_publishing || !v.code_syntax.is_empty() || v.extends.is_some() { + self.warn("variables' publishing, code syntax and inheritance settings were left out"); + } + self.nodes.push(o(f)); + } + } + + /// A reference to variable `var` (its id or name) as variable data. + fn alias(&mut self, var: &str) -> Option> { + let found = self.doc.variables.get(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?))); + let Some((kind, local)) = found else { + self.warn("bindings to variables that aren't in the document were written as their values"); + return None; + }; + Some(o(vec![ + ("value", o(vec![("alias", o(vec![("guid", guid(LAYERS, local))]))])), + ("dataType", e("ALIAS")), + ("resolvedDataType", e(resolved_type(kind))), + ])) + } + + /// Layer `n`'s variable bindings, sorted into the fields that carry them. What the format has + /// no field for stays as the value the layer already shows, with a warning. + fn bound(&mut self, n: &Node) -> Bound { + const BAKED: &str = "variables bound to properties the format has no field for were written as their values"; + let mut out = Bound::default(); + for (key, var) in &n.bound { + let parts: Vec<&str> = key.split('/').collect(); + let index = |i: &str| i.parse::().ok().filter(|_| i.bytes().all(|c| c.is_ascii_digit())); + let fields: &[&'static str] = match (parts.as_slice(), key.as_str()) { + (_, "fill" | "fills" | "fills/0" | "stroke") => { + if let Some(a) = self.alias(var) { + out.paints.insert((key != "stroke", 0), a); + } + continue; + } + ([list @ ("fills" | "strokes"), i, "color"], _) => { + if let (Some(i), Some(a)) = (index(i), self.alias(var)) { + out.paints.insert((*list == "fills", i), a); + } + continue; + } + ([list @ ("fills" | "strokes"), i, "stops", j, "color"], _) => { + if let (Some(i), Some(j), Some(a)) = (index(i), index(j), self.alias(var)) { + out.stops.insert((*list == "fills", i, j), a); + } + continue; + } + ([list @ ("effects" | "layoutGrids"), i, field], _) => { + let names: &[&'static str] = if *list == "effects" { + &["color", "radius", "spread", "offsetX", "offsetY", "startRadius"] + } else { + &["size", "count", "gutter", "offset"] + }; + match (index(i), names.iter().find(|f| *f == field)) { + (Some(i), Some(name)) => { + if let Some(a) = self.alias(var) { + if *list == "effects" { &mut out.effects } else { &mut out.grids }.insert((i, *name), a); + } + } + _ => self.warn(BAKED), + } + continue; + } + (_, "width") => &["WIDTH"], + (_, "height") => &["HEIGHT"], + (_, "gap") => &["STACK_SPACING"], + (_, "crossGap") => &["STACK_COUNTER_SPACING"], + (_, "paddingLeft") => &["STACK_PADDING_LEFT"], + (_, "paddingTop") => &["STACK_PADDING_TOP"], + (_, "paddingRight") => &["STACK_PADDING_RIGHT"], + (_, "paddingBottom") => &["STACK_PADDING_BOTTOM"], + (_, "paddingHorizontal") => &["STACK_PADDING_LEFT", "STACK_PADDING_RIGHT"], + (_, "paddingVertical") => &["STACK_PADDING_TOP", "STACK_PADDING_BOTTOM"], + (_, "padding") => &["STACK_PADDING_LEFT", "STACK_PADDING_TOP", "STACK_PADDING_RIGHT", "STACK_PADDING_BOTTOM"], + (_, "cornerRadius") => &[ + "RECTANGLE_TOP_LEFT_CORNER_RADIUS", + "RECTANGLE_TOP_RIGHT_CORNER_RADIUS", + "RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS", + "RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS", + ], + (_, "topLeftRadius") => &["RECTANGLE_TOP_LEFT_CORNER_RADIUS"], + (_, "topRightRadius") => &["RECTANGLE_TOP_RIGHT_CORNER_RADIUS"], + (_, "bottomLeftRadius") => &["RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS"], + (_, "bottomRightRadius") => &["RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS"], + (_, "opacity") => &["OPACITY"], + (_, "visible") => &["VISIBLE"], + (_, "characters") => &["TEXT_DATA"], + (_, "strokeWeight") => &["STROKE_WEIGHT"], + (_, "strokeTopWeight") => &["BORDER_TOP_WEIGHT"], + (_, "strokeRightWeight") => &["BORDER_RIGHT_WEIGHT"], + (_, "strokeBottomWeight") => &["BORDER_BOTTOM_WEIGHT"], + (_, "strokeLeftWeight") => &["BORDER_LEFT_WEIGHT"], + (_, "minWidth") => &["MIN_WIDTH"], + (_, "maxWidth") => &["MAX_WIDTH"], + (_, "minHeight") => &["MIN_HEIGHT"], + (_, "maxHeight") => &["MAX_HEIGHT"], + (_, "fontSize") => &["FONT_SIZE"], + (_, "fontFamily") => &["FONT_FAMILY"], + (_, "fontStyle") => &["FONT_STYLE"], + (_, "letterSpacing") => &["LETTER_SPACING"], + (_, "lineHeight") => &["LINE_HEIGHT"], + (_, "paragraphSpacing") => &["PARAGRAPH_SPACING"], + (_, "paragraphIndent") => &["PARAGRAPH_INDENT"], + _ => { + self.warn(BAKED); + continue; + } + }; + for field in fields { + if let Some(a) = self.alias(var) { + out.entries.push(o(vec![("variableData", a), ("variableField", e(field))])); + } + } + } + out } /// Write layer `id` (child `index` of the layer with GUID `parent_guid`) and its subtree. @@ -542,6 +847,7 @@ impl<'d> Exporter<'d> { if *prototype != Default::default() { self.warn("prototype device settings were left out"); } + self.modes(n, &mut f); true } else { self.layer(n, parent, &mut f, false) @@ -665,17 +971,18 @@ impl<'d> Exporter<'d> { }; f.extend([("mask", b(true)), ("maskType", e(ty))]); } - // Text whose own style sets fills paints with them (the layer's fills only show through runs). + let bound = self.bound(n); + // Text whose own style sets fills paints with them (the layer's fills only show through + // runs); a variable bound to the layer's fill colours the layer's. let fills = match &n.kind { - NodeKind::Text(td) => td.style.fills.as_deref().unwrap_or(&n.fills), - _ => &n.fills, + NodeKind::Text(TextData { style: TextStyle { fills: Some(own), .. }, .. }) => self.paints(own, true, &Bound::default()), + _ => self.paints(&n.fills, true, &bound), }; - let fills = self.paints(fills); - let strokes = self.paints(&n.strokes); + let strokes = self.paints(&n.strokes, false, &bound); f.extend([("fillPaints", fills), ("strokePaints", strokes)]); self.stroke(n, f); if !n.effects.is_empty() { - let effects = n.effects.iter().map(|x| self.effect(x)).collect(); + let effects = n.effects.iter().enumerate().map(|(i, x)| self.effect(x, i, &bound)).collect(); f.push(("effects", arr(effects))); } if n.kind.has_corner_radius() { @@ -718,7 +1025,7 @@ impl<'d> Exporter<'d> { self.auto_layout(n, l, f); } if !n.grids.is_empty() { - f.push(("layoutGrids", arr(n.grids.iter().map(layout_grid).collect()))); + f.push(("layoutGrids", arr(n.grids.iter().enumerate().map(|(i, g)| layout_grid(g, i, &bound)).collect()))); } } self.child_layout(n, parent, f); @@ -733,12 +1040,31 @@ impl<'d> Exporter<'d> { if !refs.is_empty() { f.push(("parameterConsumptionMap", o(vec![("entries", arr(refs))]))); } - if !n.styles.is_empty() { - self.warn("shared styles were left out (their layers keep the values)"); + let text = matches!(n.kind, NodeKind::Text(_)); + let links = [ + ("styleIdForFill", &n.styles.fill, true), + ("styleIdForStrokeFill", &n.styles.stroke, true), + ("styleIdForEffect", &n.styles.effect, true), + ("styleIdForGrid", &n.styles.grid, n.kind.is_frame_like()), + ("styleIdForText", &n.styles.text, text), + ]; + for (key, id, fits) in links { + let Some(id) = id else { continue }; + match self.style_ids.get(id.as_str()).filter(|_| fits) { + Some(local) => f.push((key, o(vec![("guid", guid(LAYERS, *local))]))), + None => self.warn("links to shared styles that aren't in the document were left out (their layers keep the values)"), + } } - if !n.bound.is_empty() || !n.var_modes.is_empty() { - self.warn("variables were left out"); + if let NodeKind::Text(td) = &n.kind + && n.styles.text.is_none() + && std::iter::once(&td.style).chain(td.runs.iter().map(|r| &r.style)).any(|st| st.style_id.is_some()) + { + self.warn("text styles applied to part of a text were written as their values"); } + if !bound.entries.is_empty() { + f.push(("variableConsumptionMap", o(vec![("entries", arr(bound.entries))]))); + } + self.modes(n, f); if !n.guides.is_empty() { self.warn("ruler guides were left out"); } @@ -750,17 +1076,35 @@ impl<'d> Exporter<'d> { } } - fn paints(&mut self, list: &[Paint]) -> Kv<'static> { - arr(list.iter().filter_map(|p| self.paint(p)).collect()) + /// The variable modes layer (or page) `n` sets for what is in it. + fn modes(&mut self, n: &Node, f: &mut Fields) { + let mut entries = Vec::new(); + for (coll, mode) in &n.var_modes { + match (self.set_ids.get(coll.as_str()), self.mode_ids.get(&(coll.as_str(), mode.as_str()))) { + (Some(set), Some(mode)) => { + entries.push(o(vec![("variableSetID", o(vec![("guid", guid(LAYERS, *set))])), ("variableModeID", guid(AUX, *mode))])); + } + _ => self.warn("variable modes of collections that aren't in the document were left out"), + } + } + if !entries.is_empty() { + f.push(("variableModeBySetMap", o(vec![("entries", arr(entries))]))); + } } - fn paint(&mut self, p: &Paint) -> Option> { + /// A paint list: a layer's fills (`fills`) or strokes with the variables `bound` to them. + fn paints(&mut self, list: &[Paint], fills: bool, bound: &Bound) -> Kv<'static> { + arr(list.iter().enumerate().filter_map(|(i, p)| self.paint(p, (fills, i), bound)).collect()) + } + + fn paint(&mut self, p: &Paint, at: (bool, usize), bound: &Bound) -> Option> { let mut f: Fields = vec![("visible", b(p.visible)), ("blendMode", e(blend_name(p.blend)))]; let mut opacity = f64::from(p.opacity); match &p.kind { PaintKind::Solid { color: c } => { opacity *= f64::from(c.a); f.extend([("type", e("SOLID")), ("color", color(c.with_alpha(1.0)))]); + f.extend(bound.paints.get(&at).map(|a| ("colorVar", a.clone()))); } PaintKind::Gradient(g) => { let ty = match g.kind { @@ -790,6 +1134,16 @@ impl<'d> Exporter<'d> { }; let stops = g.stops.iter().map(|st| o(vec![("color", color(st.color)), ("position", fl(st.position.into()))])).collect(); f.extend([("type", e(ty)), ("stops", arr(stops)), ("transform", matrix(t))]); + // Stops bound to variables: every stop again, the bound ones with their variable. + if bound.stops.range((at.0, at.1, 0)..=(at.0, at.1, usize::MAX)).next().is_some() { + let vars = g.stops.iter().enumerate().map(|(j, st)| { + let mut sf: Fields = vec![("color", color(st.color))]; + sf.extend(bound.stops.get(&(at.0, at.1, j)).map(|a| ("colorVar", a.clone()))); + sf.push(("position", fl(st.position.into()))); + o(sf) + }); + f.push(("stopsVar", arr(vars.collect()))); + } } PaintKind::Image(img) => { let hash = self.image(&img.image); @@ -878,8 +1232,17 @@ impl<'d> Exporter<'d> { ("borderLeftWeight", fl(l)), ]); } - if sp.width_profile.is_some() || sp.brush.is_some() { - self.warn("variable-width and brush strokes were written as plain strokes"); + // A variable width: how far the stroke reaches on each side of the path (left first, looking + // along it) at positions along its length. + if let Some(p) = sp.profile().filter(|_| sp.weight.is_finite() && sp.weight > 0.0) { + let point = |q: &figcraft_doc::WidthPoint| { + let half = sp.weight * q.scale / 2.0; + o(vec![("position", fl(q.t)), ("ascent", fl(half * (1.0 - q.bias))), ("descent", fl(half * (1.0 + q.bias)))]) + }; + f.push(("variableWidthPoints", arr(p.sanitized().points.iter().map(point).collect()))); + } + if sp.brush.is_some() { + self.warn("brush strokes were written as plain strokes"); } if [sp.cap_start, sp.cap_end].contains(&StrokeCap::ReversedTriangle) { self.warn("reversed triangle stroke ends were written as triangle arrows"); @@ -890,8 +1253,18 @@ impl<'d> Exporter<'d> { } } - fn effect(&mut self, x: &figcraft_doc::Effect) -> Kv<'static> { + /// Effect `index` of a layer, with the variables `bound` to its fields. + fn effect(&mut self, x: &figcraft_doc::Effect, index: usize, bound: &Bound) -> Kv<'static> { let mut f: Fields = vec![("visible", b(x.visible))]; + let vars = [ + ("color", "colorVar"), + ("radius", "radiusVar"), + ("spread", "spreadVar"), + ("offsetX", "xVar"), + ("offsetY", "yVar"), + ("startRadius", "startRadiusVar"), + ]; + f.extend(vars.into_iter().filter_map(|(ours, key)| Some((key, bound.effects.get(&(index, ours))?.clone())))); let blur = |f: &mut Fields, ty: &'static str, bl: &figcraft_doc::Blur| { f.extend([("type", e(ty)), ("radius", fl(bl.radius))]); if bl.blur_type == figcraft_doc::BlurType::Progressive { @@ -1142,7 +1515,7 @@ impl<'d> Exporter<'d> { let mut ef: Fields = vec![("styleID", u(id))]; style_fields(st, &mut ef); if let Some(p) = st.fills.as_deref().or(td.style.fills.as_ref().map(|_| fills)) { - ef.push(("fillPaints", self.paints(p))); + ef.push(("fillPaints", self.paints(p, true, &Bound::default()))); } o(ef) }) @@ -1177,9 +1550,6 @@ impl<'d> Exporter<'d> { if styles.clone().any(|st| st.decoration_look.is_some() || !st.variations.is_empty()) { self.warn("text decoration looks and variable font settings were left out"); } - if styles.clone().any(|st| st.style_id.is_some()) { - self.warn("shared styles were left out (their layers keep the values)"); - } if td.path.is_some() { self.warn("text on a path was written as plain text"); } @@ -1303,7 +1673,7 @@ impl<'d> Exporter<'d> { fn stroke_geometry(&mut self, n: &Node, boxy: bool, f: &mut Fields) { let sp = &n.stroke; let (w, h, weight) = (n.width, n.height, sp.weight); - let plain = n.strokes.iter().any(|p| p.visible) && sp.dashes.is_empty() && sp.sides.is_none(); + let plain = n.strokes.iter().any(|p| p.visible) && sp.dashes.is_empty() && sp.sides.is_none() && sp.profile().is_none() && sp.brush.is_none(); if !plain || !(weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) { return; } @@ -1409,10 +1779,32 @@ impl<'d> Exporter<'d> { Some(o(vec![("variableData", data), ("variableField", e(var_field))])) } - /// An instance: its main, the fields its layers override, its property values. + /// An instance: its main, the fields it and its layers override, the size, place and outline + /// its layers get from that (derived data), its property values. `f`: its own fields so far. fn instance(&mut self, n: &Node, main: NodeId, props: &BTreeMap, f: &mut Fields) { + const OWN: [&str; 7] = ["guid", "phase", "parentIndex", "type", "transform", "fillGeometry", "strokeGeometry"]; let doc = self.doc; - let mut overrides = Vec::new(); + let (Some(m), Some(symbol)) = (doc.node(main), self.guid_of(main)) else { return }; + // What the instance itself changes from its main (its size, its paints, …) is an override + // addressed by the main's id: a reader takes everything else from the main. Where it sits + // is its own. + let mut root_theirs = Vec::new(); + self.layer(m, m.parent.and_then(|p| doc.node(p)), &mut root_theirs, true); + root_theirs.retain(|(k, _)| !OWN.contains(k)); + // An instance is named after its component (the set, for a variant) until renamed. + let set = m.parent.and_then(|p| doc.node(p)).filter(|p| matches!(p.kind, NodeKind::ComponentSet { .. })); + let named = set.is_none_or(|s| s.name != n.name); + let root_mine: Fields = f.iter().filter(|(k, _)| !OWN.contains(k) && (named || *k != "name")).cloned().collect(); + // Each layer below: its id path, the fields that differ from the main's layer, that layer's + // fields, and the component a nested instance was swapped for. + struct Row { + path: Kv<'static>, + changed: Fields, + theirs: Fields, + nested: bool, + swap: Option>, + } + let mut rows: Vec = Vec::new(); let mut stack: Vec<(NodeId, NodeId)> = n.children.iter().rev().map(|c| (*c, n.id)).collect(); let mut seen = 0usize; while let Some((id, parent)) = stack.pop() { @@ -1422,40 +1814,75 @@ impl<'d> Exporter<'d> { break; } stack.extend(d.children.iter().rev().map(|c| (*c, id))); - let path = d.source.and_then(|src| self.path_in(src, main, 0)); + let path = self.layer_path(d, n.id, main, 0); let guids: Option> = path.and_then(|p| p.iter().map(|x| self.guid_of(*x)).collect()); let (Some(guids), Some(src)) = (guids, d.source.and_then(|src| doc.node(src))) else { self.warn("instance layers that don't come from their main component were left out"); continue; }; - // The fields that differ from the main's layer: overrides, and layout derived from them. let (mut mine, mut theirs) = (Vec::new(), Vec::new()); self.layer(d, doc.node(parent), &mut mine, true); self.layer(src, src.parent.and_then(|p| doc.node(p)), &mut theirs, true); - let mut of: Fields = vec![("guidPath", o(vec![("guids", arr(guids))]))]; - // Fields the main's layer has and this one doesn't are written at their empty values. - let cleared: Fields = - theirs.iter().filter(|(k, _)| !mine.iter().any(|(mk, _)| mk == k)).filter_map(|(k, _)| Some((*k, unset(k)?))).collect(); - of.extend(mine.into_iter().filter(|(k, v)| !theirs.iter().any(|(tk, tv)| tk == k && tv == v))); - of.extend(cleared); // A nested instance swapped for another component. - if let NodeKind::Instance { component, .. } = &d.kind { - let was = d.source.and_then(|src| doc.node(src)).and_then(|m| match &m.kind { - NodeKind::Instance { component, .. } => Some(*component), - _ => None, - }); - if was != Some(*component) - && let Some(g) = self.guid_of(*component) - { - of.push(("overriddenSymbolID", g)); + let swap = match (&d.kind, &src.kind) { + (NodeKind::Instance { component, .. }, NodeKind::Instance { component: was, .. }) if component == was => None, + (NodeKind::Instance { component, .. }, _) => self.guid_of(*component), + _ => None, + }; + let nested = matches!(d.kind, NodeKind::Instance { .. }); + rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, nested, swap }); + } + // An instance scaled as a whole (the Scale tool) is `factor` times its main in every length: + // sizes, places and stroke weights, its own and its layers'. A resized one keeps its weights. + let field = |fields: &Fields, key: &str| fields.iter().find(|(k, _)| *k == key).map(|(_, v)| v.clone()); + let factor = Some(n.width / m.width).filter(|k| k.is_finite() && *k > 0.0 && (k - 1.0).abs() > 1e-6).filter(|k| { + let grown = |mine: &Fields, theirs: &Fields| { + let weight = field(theirs, "strokeWeight").and_then(|w| w.f64()).unwrap_or(0.0); + (weight == 0.0 || field(mine, "strokeWeight").is_some_and(|w| scaled(&w, &fl(weight), *k, 0.0))) + && field(mine, "size").zip(field(theirs, "size")).is_some_and(|(a, b)| scaled(&a, &b, *k, 1.0)) + && field(mine, "transform").zip(field(theirs, "transform")).is_none_or(|(a, b)| scaled(&a, &b, *k, 1.0)) + }; + (n.height / m.height - k).abs() <= 1e-6 * k.max(1.0) + && grown(&changed(root_mine.clone(), &root_theirs), &root_theirs) + && rows.iter().all(|r| grown(&r.changed, &r.theirs)) + }); + // What follows from the overrides goes with the derived data: a layer's size, place and + // outline (a vector's network is the main's, drawn at the layer's size), the colours its + // variables give here, and what the scale factor makes of the main's values. + let follows = |k: &str, v: &Kv<'static>, theirs: &Fields| { + ["size", "transform", "fillGeometry", "strokeGeometry", "vectorData"].contains(&k) + || resolved_alike(k, v, theirs) + || factor.is_some_and(|x| field(theirs, k).is_some_and(|t| scaled(v, &t, x, 0.0))) + }; + let mut overrides = Vec::new(); + let mut derived = Vec::new(); + let mut of: Fields = vec![("guidPath", o(vec![("guids", arr(vec![symbol.clone()]))]))]; + of.extend(changed(root_mine, &root_theirs).into_iter().filter(|(k, v)| *k == "size" || !follows(k, v, &root_theirs))); + if of.len() > 1 { + overrides.push(o(of)); + } + for r in rows { + let (mut of, mut df): (Fields, Fields) = (vec![("guidPath", r.path.clone())], vec![("guidPath", r.path)]); + for (k, v) in r.changed { + let follows = follows(k, &v, &r.theirs); + // A nested instance's size is its own to set. + if follows && r.nested && k == "size" { + of.push((k, v.clone())); } + if follows { &mut df } else { &mut of }.push((k, v)); } + if df.len() > 1 { + derived.push(o(df)); + } + of.extend(r.swap.map(|g| ("overriddenSymbolID", g))); if of.len() > 1 { overrides.push(o(of)); } } - let Some(symbol) = self.guid_of(main) else { return }; - f.push(("symbolData", o(vec![("symbolID", symbol), ("symbolOverrides", arr(overrides))]))); + f.push(("symbolData", o(vec![("symbolID", symbol), ("symbolOverrides", arr(overrides)), ("uniformScaleFactor", fl(factor.unwrap_or(1.0)))]))); + if !derived.is_empty() { + f.push(("derivedSymbolData", arr(derived))); + } let mut assignments = Vec::new(); for (name, v) in props { let (ty, value) = match v { @@ -1495,6 +1922,28 @@ impl<'d> Exporter<'d> { cur } + /// The id path of layer `d` of instance `inst` (of `main`): that of the main's layer it mirrors, + /// or, inside a nested instance swapped for another component, the path of that instance and + /// then the one inside its new main. + fn layer_path(&self, d: &Node, inst: NodeId, main: NodeId, depth: usize) -> Option> { + let src = d.source?; + if let Some(p) = self.path_in(src, main, 0) { + return Some(p); + } + if depth > 32 { + return None; + } + let doc = self.doc; + let nested = doc.ancestors(d.id).into_iter().take_while(|a| *a != inst).filter_map(|a| doc.node(a)).find_map(|a| match &a.kind { + NodeKind::Instance { component, .. } => Some((a, *component)), + _ => None, + }); + let (outer, component) = nested?; + let mut p = self.layer_path(outer, inst, main, depth + 1)?; + p.extend(self.path_in(src, component, 0)?); + Some(p) + } + /// The id path of layer `s` inside component `comp`: `s` itself, or through the nested /// instance it is expanded in, then on inside that instance's main. fn path_in(&self, s: NodeId, comp: NodeId, depth: usize) -> Option> { @@ -1675,11 +2124,93 @@ impl<'d> Exporter<'d> { } } +/// Whether `a` is `b` with its lengths scaled by `k`: every number is `k` times the other's (within +/// `slack`, for sizes that snap to whole pixels) or the same, and the rest is equal. +fn scaled(a: &Kv<'static>, b: &Kv<'static>, k: f64, slack: f64) -> bool { + match (a, b) { + (Kv::Float(x), Kv::Float(y)) => { + let (x, y) = (f64::from(*x), f64::from(*y)); + x == y || (x - y * k).abs() <= slack + 1e-3 * x.abs().max(1.0) + } + (Kv::Array(x), Kv::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| scaled(p, q, k, slack)), + (Kv::Object(x), Kv::Object(y)) => x.len() == y.len() && x.iter().zip(y).all(|((kp, p), (kq, q))| kp == kq && scaled(p, q, k, slack)), + _ => a == b, + } +} + +/// The fields of `mine` that differ from `theirs` (a layer and the main's layer it mirrors), and +/// the ones only `theirs` has at their empty values. +fn changed(mine: Fields, theirs: &Fields) -> Fields { + let cleared: Fields = theirs.iter().filter(|(k, _)| !mine.iter().any(|(mk, _)| mk == k)).filter_map(|(k, _)| Some((*k, unset(k)?))).collect(); + let mut out: Fields = mine.into_iter().filter(|(k, v)| !theirs.iter().any(|(tk, tv)| tk == k && tv == v)).collect(); + out.extend(cleared); + out +} + +/// Whether paint list `v` (field `key`) is `theirs` but for the colours variables give: two layers +/// bound alike are the same, whatever mode each one shows. +fn resolved_alike(key: &str, v: &Kv<'static>, theirs: &Fields) -> bool { + fn unresolved(v: &Kv<'static>) -> Vec> { + let bare = |p: &Kv<'static>| match p { + Kv::Object(f) if f.iter().any(|(k, _)| *k == "colorVar") => o(f.iter().filter(|(k, _)| *k != "color").cloned().collect()), + other => other.clone(), + }; + v.list().iter().map(bare).collect() + } + matches!(key, "fillPaints" | "strokePaints") && theirs.iter().any(|(k, t)| *k == key && unresolved(t) == unresolved(v)) +} + +/// The format's name of a variable type. +fn resolved_type(kind: VariableType) -> &'static str { + match kind { + VariableType::Color => "COLOR", + VariableType::Number => "FLOAT", + VariableType::String => "STRING", + VariableType::Boolean => "BOOLEAN", + } +} + +/// The format's name of a variable scope (`None`: it has none). +fn scope_name(scope: &str) -> Option<&'static str> { + const NAMES: [&str; 20] = [ + "ALL_SCOPES", + "TEXT_CONTENT", + "CORNER_RADIUS", + "WIDTH_HEIGHT", + "GAP", + "ALL_FILLS", + "FRAME_FILL", + "SHAPE_FILL", + "TEXT_FILL", + "STROKE_FLOAT", + "EFFECT_FLOAT", + "EFFECT_COLOR", + "OPACITY", + "FONT_STYLE", + "FONT_FAMILY", + "FONT_SIZE", + "LINE_HEIGHT", + "LETTER_SPACING", + "PARAGRAPH_SPACING", + "PARAGRAPH_INDENT", + ]; + if scope == "STROKE_COLOR" { + return Some("STROKE"); + } + NAMES.into_iter().find(|n| *n == scope) +} + /// The empty value of a field only written when set (an override clears it with this). fn unset(key: &str) -> Option> { Some(match key { + "styleIdForFill" | "styleIdForStrokeFill" | "styleIdForEffect" | "styleIdForGrid" | "styleIdForText" => { + o(vec![("guid", guid(u32::MAX, u32::MAX))]) + } + "variableConsumptionMap" | "variableModeBySetMap" => o(vec![("entries", arr(Vec::new()))]), "mask" | "rectangleCornerRadiiIndependent" | "borderStrokeWeightsIndependent" => b(false), - "effects" | "dashPattern" | "layoutGrids" | "exportSettings" | "prototypeInteractions" | "fillGeometry" => arr(Vec::new()), + "effects" | "dashPattern" | "layoutGrids" | "exportSettings" | "prototypeInteractions" | "fillGeometry" | "variableWidthPoints" => { + arr(Vec::new()) + } "cornerSmoothing" | "stackChildPrimaryGrow" => fl(0.0), "stackMode" => e("NONE"), "stackChildAlignSelf" | "stackPositioning" => e("AUTO"), @@ -1754,7 +2285,8 @@ fn style_fields(st: &TextStyle, f: &mut Fields) { } } -fn layout_grid(g: &figcraft_doc::LayoutGrid) -> Kv<'static> { +/// Layout grid `index` of a layer, with the variables `bound` to its fields. +fn layout_grid(g: &figcraft_doc::LayoutGrid, index: usize, bound: &Bound) -> Kv<'static> { let (pattern, axis) = match g.kind { GridKind::Grid => ("GRID", "X"), GridKind::Columns => ("STRIPES", "X"), @@ -1766,7 +2298,7 @@ fn layout_grid(g: &figcraft_doc::LayoutGrid) -> Kv<'static> { GridAlign::Center => "CENTER", GridAlign::Max => "MAX", }; - o(vec![ + let mut f: Fields = vec![ ("type", e(align)), ("axis", e(axis)), ("visible", b(g.visible)), @@ -1776,7 +2308,10 @@ fn layout_grid(g: &figcraft_doc::LayoutGrid) -> Kv<'static> { ("gutterSize", fl(g.gutter)), ("color", color(g.color)), ("pattern", e(pattern)), - ]) + ]; + let vars = [("count", "numSectionsVar"), ("offset", "offsetVar"), ("size", "sectionSizeVar"), ("gutter", "gutterSizeVar")]; + f.extend(vars.into_iter().filter_map(|(ours, key)| Some((key, bound.grids.get(&(index, ours))?.clone())))); + o(f) } fn export_setting(x: &figcraft_doc::ExportSetting) -> Kv<'static> { diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index fa90003..f9bf281 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -14,6 +14,10 @@ use serde_json::Value; use super::kiwi::{Kind, Kv, Schema, Type, encode}; use super::*; +/// Variables, shared styles and instance sizes (uses this module's generators and comparisons). +#[path = "assets_tests.rs"] +mod assets; + /// A cheap deterministic generator. struct Lcg(u64); impl Lcg { diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index b3a9587..7bf44de 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -43,6 +43,10 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { if let Some(vars) = m.variables() { out["meta"] = vars; } + let styles = m.style_definitions(); + if !styles.is_empty() { + out["styleDefinitions"] = Value::Array(styles); + } (out, m.warnings) } @@ -251,6 +255,7 @@ impl<'a> Mapper<'a> { if let Some(c) = v.get("prototypeBackgroundColor") { o.insert("prototypeBackgrounds".into(), json!([color(c)])); } + self.variable_bindings(&mut o, &v); let mut flows: Vec<(&str, Value)> = Vec::new(); let mut kids = Vec::new(); for c in self.children(id) { @@ -645,6 +650,9 @@ impl<'a> Mapper<'a> { style.insert("maxLines".into(), json!(n)); } let size = sv.num("fontSize"); + if let Some(b) = o.get_mut("style").and_then(|s| s.get_mut("boundVariables")).map(Value::take) { + style.insert("boundVariables".into(), b); + } o.insert("style".into(), Value::Object(style)); let Some(td) = td else { return }; let ids: Vec = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect(); @@ -1066,6 +1074,15 @@ impl<'a> Mapper<'a> { bound.insert(k.into(), a.clone()); } } + _ if field.starts_with("BORDER_") => { + let side = match field { + "BORDER_TOP_WEIGHT" => "strokeTopWeight", + "BORDER_RIGHT_WEIGHT" => "strokeRightWeight", + "BORDER_BOTTOM_WEIGHT" => "strokeBottomWeight", + _ => "strokeLeftWeight", + }; + bound.insert(side.into(), a); + } _ if field == "WIDTH" || field == "HEIGHT" => { let axis = if field == "WIDTH" { "x" } else { "y" }; let size = bound.entry("size").or_insert_with(|| json!({})); @@ -1077,10 +1094,9 @@ impl<'a> Mapper<'a> { if !bound.is_empty() { o.insert("boundVariables".into(), Value::Object(bound)); } - if !text_bound.is_empty() - && let Some(Value::Object(s)) = o.get_mut("style") - { - s.insert("boundVariables".into(), Value::Object(text_bound)); + // (The text style itself is added after this: it keeps these.) + if !text_bound.is_empty() { + o.insert("style".into(), json!({"boundVariables": text_bound})); } let mut modes = Obj::new(); for e in v.get("variableModeBySetMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() { @@ -1093,15 +1109,46 @@ impl<'a> Mapper<'a> { } } - /// Collections and variables, as the local variables response's `meta`. + /// The shared styles the file defines, in their order: each with the sample layer that holds + /// its values (`styleDefinitions`, our extension of the file JSON; the REST importer reads it). + fn style_definitions(&mut self) -> Vec { + let mut found: Vec<(&'a str, String, &'a Kv<'a>)> = Vec::new(); + for (id, n) in &self.nodes { + let live = n.get("isSoftDeleted").and_then(Kv::bool) != Some(true) && n.get("isSoftDeletedStyle").and_then(Kv::bool) != Some(true); + if live && n.get("styleType").and_then(Kv::str).is_some_and(|t| matches!(t, "FILL" | "TEXT" | "EFFECT" | "GRID")) { + found.push((n.get("sortPosition").and_then(Kv::str).unwrap_or(""), id.clone(), n)); + } + } + found.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); + let mut out = Vec::new(); + for (_, id, n) in found { + let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, 0) else { break }; + let name = n.get("name").and_then(Kv::str).unwrap_or("Style"); + let desc = n.get("styleDescription").or_else(|| n.get("description")).and_then(Kv::str).unwrap_or(""); + let ty = n.get("styleType").and_then(Kv::str).unwrap_or(""); + out.push(json!({"id": id, "name": name, "description": desc, "styleType": ty, "node": node})); + } + out + } + + /// Collections and variables, as the local variables response's `meta`, with their ids in the + /// file's order (`collectionOrder` and `variableOrder`, our extensions). fn variables(&self) -> Option { - let of_type = |t: &'static str| { - self.nodes + let of_type = |t: &'static str, key: &'static str| { + let live = move |n: &Kv| n.get("type").and_then(Kv::str) == Some(t) && n.get("isSoftDeleted").and_then(Kv::bool) != Some(true); + let mut list: Vec<(&str, &String, &'a Kv<'a>)> = self + .nodes .iter() - .filter(move |(_, n)| n.get("type").and_then(Kv::str) == Some(t) && n.get("isSoftDeleted").and_then(Kv::bool) != Some(true)) + .filter(|(_, n)| live(n)) + .map(|(id, n)| (n.get(key).and_then(|p| p.get("position").or(Some(p))).and_then(Kv::str).unwrap_or(""), id, *n)) + .collect(); + list.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1))); + list.into_iter().map(|(_, id, n)| (id, n)) }; + let mut order = Vec::new(); let mut collections = Obj::new(); - for (id, n) in of_type("VARIABLE_SET") { + for (id, n) in of_type("VARIABLE_SET", "parentIndex") { + order.push(json!(id)); let mut modes: Vec<(&str, Value)> = n .get("variableSetModes") .map(Kv::list) @@ -1118,8 +1165,9 @@ impl<'a> Mapper<'a> { let modes: Vec = modes.into_iter().map(|m| m.1).collect(); collections.insert(id.clone(), json!({"id": id, "name": name, "modes": modes, "defaultModeId": default})); } + let mut variable_order = Vec::new(); let mut variables = Obj::new(); - for (id, n) in of_type("VARIABLE") { + for (id, n) in of_type("VARIABLE", "sortPosition") { let Some(set) = n.get("variableSetID").and_then(ref_guid).filter(|s| collections.contains_key(s)) else { continue }; let ty = n.get("variableResolvedType").and_then(Kv::str).unwrap_or(""); if !matches!(ty, "BOOLEAN" | "FLOAT" | "STRING" | "COLOR") { @@ -1133,12 +1181,17 @@ impl<'a> Mapper<'a> { } let name = n.get("name").and_then(Kv::str).unwrap_or("Variable"); let desc = n.get("description").and_then(Kv::str).unwrap_or(""); - variables.insert( - id.clone(), - json!({"id": id, "name": name, "variableCollectionId": set, "resolvedType": ty, "valuesByMode": values, "description": desc}), - ); + let mut var = + json!({"id": id, "name": name, "variableCollectionId": set, "resolvedType": ty, "valuesByMode": values, "description": desc}); + // The properties pickers offer the variable for, under the REST names. + if let Some(scopes) = n.get("variableScopes") { + var["scopes"] = scopes.list().iter().filter_map(Kv::str).map(|s| if s == "STROKE" { "STROKE_COLOR" } else { s }).collect(); + } + variable_order.push(json!(id)); + variables.insert(id.clone(), var); } - (!collections.is_empty()).then(|| json!({"variableCollections": collections, "variables": variables})) + (!collections.is_empty()) + .then(|| json!({"variableCollections": collections, "variables": variables, "collectionOrder": order, "variableOrder": variable_order})) } } @@ -1190,6 +1243,21 @@ fn stroke(o: &mut Obj, v: &View) { if let Some(cap) = v.str("strokeCap") { o.insert("strokeCap".into(), json!(cap_name(cap))); } + // A variable width: each point's reach on the two sides of the path, as `[t, scale, bias]` + // (`strokeWidthProfile`, our extension of the file JSON). + let weight = v.num("strokeWeight").unwrap_or(0.0); + let profile: Vec = v + .list("variableWidthPoints") + .iter() + .filter_map(|p| { + let (left, right) = (num(p, "ascent")?.max(0.0), num(p, "descent")?.max(0.0)); + let bias = if left + right > 0.0 { (right - left) / (left + right) } else { 0.0 }; + Some(json!([num(p, "position")?, (left + right) / weight, bias])) + }) + .collect(); + if weight > 0.0 && !profile.is_empty() { + o.insert("strokeWidthProfile".into(), Value::Array(profile)); + } let dashes: Vec = v.list("dashPattern").iter().filter_map(Kv::f64).collect(); if !dashes.is_empty() { o.insert("strokeDashes".into(), json!(dashes)); @@ -1343,12 +1411,21 @@ fn paint(p: &Kv) -> Option { }) .collect(); o.insert("gradientHandlePositions".into(), Value::Array(handles)); + // Stops bound to variables are listed again, each with its variable. + let vars = p.get("stopsVar").map(Kv::list).unwrap_or_default(); let stops: Vec = p .get("stops") .map(Kv::list) .unwrap_or_default() .iter() - .map(|s| json!({"position": num(s, "position").unwrap_or(0.0), "color": s.get("color").map_or(Value::Null, color)})) + .enumerate() + .map(|(i, s)| { + let mut stop = json!({"position": num(s, "position").unwrap_or(0.0), "color": s.get("color").map_or(Value::Null, color)}); + if let Some(b) = bound(vars.get(i), &[("colorVar", "color")]) { + stop["boundVariables"] = b; + } + stop + }) .collect(); o.insert("gradientStops".into(), Value::Array(stops)); } @@ -1373,6 +1450,15 @@ fn paint(p: &Kv) -> Option { Some(Value::Object(o)) } +/// The variables bound to the fields of a paint stop, an effect or a layout grid, as its REST +/// `boundVariables`: `fields` pairs the format's field with the REST name. +fn bound(v: Option<&Kv>, fields: &[(&str, &str)]) -> Option { + let v = v?; + let found: Obj = + fields.iter().filter_map(|(from, to)| Some((to.to_string(), json!({"type": "VARIABLE_ALIAS", "id": alias(v.get(from))?})))).collect(); + (!found.is_empty()).then_some(Value::Object(found)) +} + /// The track ids of a grid's columns or rows, in order. fn tracks(v: &View, key: &str) -> Vec { let mut t: Vec<(&str, String)> = v @@ -1520,6 +1606,17 @@ fn effect(e: &Kv) -> Option { if e.get("blurOpType").and_then(Kv::str) == Some("PROGRESSIVE") { o.insert("blurType".into(), json!("PROGRESSIVE")); } + let vars = [ + ("colorVar", "color"), + ("radiusVar", "radius"), + ("spreadVar", "spread"), + ("xVar", "offsetX"), + ("yVar", "offsetY"), + ("startRadiusVar", "startRadius"), + ]; + if let Some(b) = bound(Some(e), &vars) { + o.insert("boundVariables".into(), b); + } Some(Value::Object(o)) } @@ -1541,6 +1638,10 @@ fn layout_grid(g: &Kv) -> Value { if let Some(b) = g.get("visible").and_then(Kv::bool) { o["visible"] = json!(b); } + let vars = [("numSectionsVar", "count"), ("offsetVar", "offset"), ("sectionSizeVar", "sectionSize"), ("gutterSizeVar", "gutterSize")]; + if let Some(b) = bound(Some(g), &vars) { + o["boundVariables"] = b; + } o } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 57214de..52d8704 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -59,6 +59,8 @@ const MAX_PATH_BYTES: usize = 4 << 20; const MAX_PATH_ELEMENTS: usize = 200_000; /// Most distinct warnings reported. const MAX_WARNINGS: usize = 200; +/// Most shared style definitions read. +const MAX_STYLES: usize = 10_000; /// Image key used for image fills without an `imageRef`. const MISSING_IMAGE: &str = "rest-image-missing"; @@ -154,6 +156,7 @@ pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) if variables.is_some_and(|x| variables::source(x).is_none()) { cx.warn("the variables JSON has no `variableCollections` (expected the local variables response) and was ignored"); } + cx.style_definitions(v); for e in entries { let Some(root) = e.get("document") else { continue }; match text(root, "type") { @@ -298,6 +301,10 @@ impl<'a> Ctx<'a> { /// A CANVAS → a page. fn page(&mut self, c: &'a Value) { let page = self.doc.add_page(text(c, "name").map_or("Page", clip_str)); + let modes = self.modes(c); + if let Some(n) = self.doc.node_mut(page).filter(|_| !modes.is_empty()) { + n.var_modes = modes; + } if let Some(bg) = c.get("backgroundColor").and_then(props::color) && let Some(NodeKind::Page { background, .. }) = self.doc.node_mut(page).map(|n| &mut n.kind) { @@ -609,6 +616,28 @@ impl<'a> Ctx<'a> { out } + /// Shared styles with their values (`styleDefinitions`, written by the `.fig` import: a list of + /// `{id, name, description, styleType, node}`, the node being a layer that shows the style), in + /// the file's order. Layers then link to them; styles that no layer uses come across too. + fn style_definitions(&mut self, v: &'a Value) { + for d in list(v, "styleDefinitions").iter().take(MAX_STYLES) { + let (Some(id), Some(node)) = (text(d, "id").filter(|i| !i.is_empty()).map(clip_str), d.get("node")) else { continue }; + if self.doc.styles.iter().any(|s| s.id == id) { + continue; + } + let kind = match text(d, "styleType") { + Some("FILL") => StyleKind::Paint { paints: props::paints(self, node, "fills").unwrap_or_default() }, + Some("EFFECT") => StyleKind::Effect { effects: props::effects(self, node) }, + Some("GRID") => StyleKind::Grid { grids: props::layout_grids(node) }, + Some("TEXT") => StyleKind::Text { style: text::text_data(self, node).style.as_text_style() }, + _ => continue, + }; + let name = text(d, "name").filter(|s| !s.is_empty()).map_or_else(|| format!("Style {id}"), |s| clip_str(s).to_string()); + let description = text(d, "description").map(|s| clip_str(s).to_string()).unwrap_or_default(); + self.doc.styles.push(Style { id: id.to_string(), name, description, kind }); + } + } + /// Shared styles the layer uses: the metadata names them, the layer supplies the values. fn link_styles(&mut self, n: &mut Node, j: &Value) { for (slot, sid) in j.get("styles").and_then(Value::as_object).into_iter().flatten() { diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index b0b43df..54b6443 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -6,7 +6,7 @@ use figcraft_doc::geom::Affine; use figcraft_doc::{ AutoLayout, Blur, BlurType, CellAlign, ChildLayout, Constraints, CrossAlign, Effect, EffectKind, ExportFormat, ExportSetting, ExportSize, Glass, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, - StrokeProps, Texture, VConstraint, + StrokeProps, Texture, VConstraint, WidthPoint, WidthProfile, }; use serde_json::Value; @@ -343,6 +343,13 @@ pub(super) fn stroke_props(v: &Value, sp: &mut StrokeProps) { sp.dashes.clear(); } } + // A variable width: `[t, scale, bias]` points (our extension, written by the `.fig` import). + let point = + |p: &Value| Some(WidthPoint { t: p.get(0)?.as_f64()?, scale: p.get(1)?.as_f64()?, bias: p.get(2).and_then(Value::as_f64).unwrap_or(0.0) }); + let points: Vec = list(v, "strokeWidthProfile").iter().take(WidthProfile::MAX_POINTS).filter_map(point).collect(); + if !points.is_empty() { + sp.width_profile = Some(WidthProfile { points }.sanitized()); + } // The miter angle (degrees) below which joins bevel → the equivalent miter limit. if let Some(a) = num(v, "strokeMiterAngle").filter(|a| *a > 0.0 && *a < 180.0) { sp.miter_limit = (1.0 / (a.to_radians() / 2.0).sin()).clamp(1.0, 100.0); diff --git a/crates/format/src/rest/variables.rs b/crates/format/src/rest/variables.rs index cd49620..91d1400 100644 --- a/crates/format/src/rest/variables.rs +++ b/crates/format/src/rest/variables.rs @@ -5,6 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; +use figcraft_doc::color::PaintKind; use figcraft_doc::{ ALL_SCOPES, CODE_PLATFORMS, Collection, Mode, Node, NodeKind, Variable, VariableType, VariableValue, Variables, normalize_scopes, }; @@ -135,9 +136,33 @@ impl Ctx<'_> { for v in &found { self.var_kinds.insert(v.id.clone(), v.kind); } + // Objects have no order: `collectionOrder` and `variableOrder` (written by the `.fig` + // import) list the ids in theirs. Ids they leave out keep their place after the listed. + let rank = |key: &str| -> BTreeMap<&str, usize> { list(src, key).iter().filter_map(Value::as_str).zip(0..).collect() }; + let (collections, variables) = (rank("collectionOrder"), rank("variableOrder")); + if !variables.is_empty() { + found.sort_by_key(|v| variables.get(v.id.as_str()).copied().unwrap_or(usize::MAX)); + } + if !collections.is_empty() { + self.doc.variables.collections.sort_by_key(|c| collections.get(c.id.as_str()).copied().unwrap_or(usize::MAX)); + } self.doc.variables.variables.extend(found); } + /// The modes a layer or page sets explicitly (`explicitVariableModes`): collection → mode. + pub(super) fn modes(&mut self, j: &Value) -> BTreeMap { + let mut out = BTreeMap::new(); + for (coll, mode) in j.get("explicitVariableModes").and_then(Value::as_object).into_iter().flatten() { + match mode.as_str() { + Some(m) if self.var_modes.get(coll.as_str()).is_some_and(|ms| ms.iter().any(|x| x == m)) => { + out.insert(coll.clone(), m.to_string()); + } + _ => self.warn("variable modes of collections that aren't in the variables JSON were skipped"), + } + } + out + } + fn collection(&mut self, key: &str, c: &Value) { let id = clip_str(text(c, "id").unwrap_or(key)).to_string(); if id.is_empty() || self.var_modes.contains_key(&id) { @@ -255,8 +280,8 @@ impl Ctx<'_> { /// The layer's variable bindings (`boundVariables`, paint and text style bindings) and /// explicitly set modes, in the field names `variable.bind` uses. - pub(super) fn bindings(&mut self, n: &mut Node, j: &Value) { - let mut found: Vec<(String, &str)> = Vec::new(); + pub(super) fn bindings<'j>(&mut self, n: &mut Node, j: &'j Value) { + let mut found: Vec<(String, &'j str)> = Vec::new(); let mut skipped: BTreeSet<&str> = BTreeSet::new(); if let Some(b) = j.get("boundVariables").and_then(Value::as_object) { for (k, v) in b { @@ -265,18 +290,63 @@ impl Ctx<'_> { } } } - // Paints carry their colour binding themselves. + // Paints carry their colour binding themselves: the first paint's is the layer's `fill` (or + // `stroke`) when it is its only paint, else `fills//color`; gradient stops carry theirs. for (key, field) in [("fills", "fill"), ("strokes", "stroke")] { + let paints = if key == "fills" { &n.fills } else { &n.strokes }; + // Paints that didn't import shift the ones after them: indices only mean something when + // every paint came across. + let aligned = paints.len() == list(j, key).len(); for (i, p) in list(j, key).iter().enumerate() { + let ours = paints.get(i).filter(|_| aligned); + let stops = match ours.map(|o| &o.kind) { + Some(PaintKind::Gradient(g)) => g.stops.len(), + _ => 0, + }; + for (s, stop) in list(p, "gradientStops").iter().enumerate().take(stops) { + if let Some(var) = stop.get("boundVariables").and_then(|b| b.get("color")).and_then(alias) { + found.push((format!("{key}/{i}/stops/{s}/color"), var)); + } + } let Some(var) = p.get("boundVariables").and_then(|b| b.get("color")).and_then(alias) else { continue }; if i == 0 && single_solid(n, field) { found.retain(|(f, _)| f != field); found.push((field.to_string(), var)); + } else if ours.is_some_and(|o| o.solid_color().is_some()) { + found.push((format!("{key}/{i}/color"), var)); } else { skipped.insert(key); } } } + // Effects and layout grids carry theirs per field. + const EFFECT: [(&str, &str); 6] = [ + ("color", "color"), + ("radius", "radius"), + ("spread", "spread"), + ("offsetX", "offsetX"), + ("offsetY", "offsetY"), + ("startRadius", "startRadius"), + ]; + const GRID: [(&str, &str); 4] = [("sectionSize", "size"), ("count", "count"), ("gutterSize", "gutter"), ("offset", "offset")]; + for (key, prefix, fields, len) in + [("effects", "effects", &EFFECT[..], n.effects.len()), ("layoutGrids", "layoutGrids", &GRID[..], n.grids.len())] + { + if list(j, key).len() != len { + continue; + } + for (i, item) in list(j, key).iter().enumerate() { + for (k, v) in item.get("boundVariables").and_then(Value::as_object).into_iter().flatten() { + match (fields.iter().find(|(from, _)| from == k), alias(v)) { + (Some((_, to)), Some(var)) => found.push((format!("{prefix}/{i}/{to}"), var)), + (Some(_), None) => {} + (None, _) => { + skipped.insert(key); + } + } + } + } + } if matches!(n.kind, NodeKind::Text(_)) { for (k, v) in j.get("style").and_then(|s| s.get("boundVariables")).and_then(Value::as_object).into_iter().flatten() { match (text_field(k), alias(v)) { @@ -307,14 +377,7 @@ impl Ctx<'_> { None => self.warn("variable bindings to variables that aren't in the variables JSON (library variables) were skipped"), } } - for (coll, mode) in j.get("explicitVariableModes").and_then(Value::as_object).into_iter().flatten() { - match mode.as_str() { - Some(m) if self.var_modes.get(coll.as_str()).is_some_and(|ms| ms.iter().any(|x| x == m)) => { - n.var_modes.insert(coll.clone(), m.to_string()); - } - _ => self.warn("variable modes of collections that aren't in the variables JSON were skipped"), - } - } + n.var_modes.extend(self.modes(j)); } } @@ -432,6 +495,7 @@ fn applies(n: &Node, field: &str) -> bool { fn field_type(field: &str) -> VariableType { match field { "fill" | "stroke" => VariableType::Color, + f if f.ends_with("/color") => VariableType::Color, "characters" | "fontFamily" | "fontStyle" => VariableType::String, "visible" => VariableType::Boolean, _ => VariableType::Number,