M7: .fig export: instance sizes, variables and modes, shared styles, variable-width strokes
Instances write what they change from their main as an override addressed by the main's id (size, name, paints), what follows from that for their layers as derived data (size, place, outline, colours their variables give), and a scale factor when they were scaled as a whole; layers of a nested instance swapped for another component keep their overrides. Variable collections, modes and variables are nodes on a hidden page; paints, gradient stops, effects, layout grids and layer fields name the variables bound to them, and frames and pages the modes they set. Shared styles are nodes there too, linked from the layers that use them. Variable-width strokes say their reach on each side of the path. The import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, stops, effects and grids, page modes, text bindings, width profiles).
This commit is contained in:
parent
794b83a0db
commit
03b173fd98
|
|
@ -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<String> {
|
||||
let var = |v: &String| doc.variables.get(v).map_or("?", |x| x.name.as_str()).to_string();
|
||||
let style = |r: &Option<String>| 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::<Vec<_>>(), ["Light", "Dark"]);
|
||||
assert_eq!(doc.variables.variables.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["bg", "fg", "gap"]);
|
||||
assert_eq!(doc.styles.iter().map(|x| x.name.as_str()).collect::<Vec<_>>(), ["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<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<String, String> = 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<NodeId> = 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<String> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>();
|
||||
assert_eq!(names(&back), names(&doc));
|
||||
assert_eq!(back.variables.collections.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["Theme", "Spacing"]);
|
||||
assert_eq!(back.variables.collections[0].modes.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>();
|
||||
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::<Vec<_>>(), [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<u64, Vec<String>>, BTreeMap<u64, Vec<String>>) {
|
||||
let list = |v: &Value| -> BTreeMap<u64, Vec<String>> {
|
||||
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<NodeId> = 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::<Vec<_>>(), ["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::<Vec<_>>();
|
||||
assert_eq!(styles(&a), styles(&b), "{}", p.display());
|
||||
}
|
||||
}
|
||||
|
|
@ -6,11 +6,13 @@
|
|||
//! `images/<hash>`. 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<NodeId, Overlay>,
|
||||
/// Flow starting points: layer → (order, name).
|
||||
starts: BTreeMap<NodeId, (usize, String)>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<Kv<'static>>,
|
||||
}
|
||||
|
||||
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<Kv> = 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<Kv<'static>> {
|
||||
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::<usize>().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<Kv<'static>> {
|
||||
/// 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<Kv<'static>> {
|
||||
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<String, Value>, 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<Kv<'static>>,
|
||||
}
|
||||
let mut rows: Vec<Row> = 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<Vec<Kv>> = 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<Vec<NodeId>> {
|
||||
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<Vec<NodeId>> {
|
||||
|
|
@ -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<Kv<'static>> {
|
||||
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<Kv<'static>> {
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec<String>) {
|
|||
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<u64> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> = 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<Value> = 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<f64> = 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<Value> {
|
|||
})
|
||||
.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<Value> = 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<Value> {
|
|||
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<Value> {
|
||||
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<String> {
|
||||
let mut t: Vec<(&str, String)> = v
|
||||
|
|
@ -1520,6 +1606,17 @@ fn effect(e: &Kv) -> Option<Value> {
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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<WidthPoint> = 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);
|
||||
|
|
|
|||
|
|
@ -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<String, String> {
|
||||
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/<i>/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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue