M6: preserve scaled instance export overrides (#2097)

This commit is contained in:
FredApps
2026-11-08 15:23:36 +01:00
parent 9303b73991
commit 23ed778562
8 changed files with 447 additions and 17 deletions
+155
View File
@@ -1054,3 +1054,158 @@ fn a_scaled_instance_keeps_its_radius_through_a_round_trip() {
assert_eq!((b.width, b.height), (60.0, 60.0));
assert_eq!(b.corners.radii, [15.0; 4]);
}
/// A scaled child may override its radius back to the unscaled main's value.
#[test]
fn a_scaled_instance_keeps_a_radius_reset_to_its_main_through_a_round_trip() {
let (mut doc, insts) = sized();
let main_box = doc.nodes().find(|n| n.name == "Box" && n.parent.is_some_and(|p| doc.node(p).unwrap().name == "Card")).unwrap().id;
doc.node_mut(main_box).unwrap().corners.radii = [10.0; 4];
let kid = doc.node(insts[2]).unwrap().children[0];
doc.node_mut(kid).unwrap().corners.radii = [10.0; 4];
let (bytes, _) = export(&doc, None).unwrap();
let (back, _) = import(&bytes, "x").unwrap();
let inst = back.nodes().find(|n| n.name == "Scaled").unwrap();
let b = inst.children.iter().map(|c| back.node(*c).unwrap()).find(|n| n.name == "Box").unwrap();
// Its geometry is still scaled; only the authored corner radius was reset.
assert_eq!((b.width, b.height), (60.0, 60.0));
assert_eq!(b.corners.radii, [10.0; 4]);
}
#[test]
fn a_scaled_instance_keeps_main_equal_layout_and_grid_lengths() {
let (mut doc, insts) = sized();
let main = doc.nodes().find(|n| n.name == "Card").unwrap().id;
let boxes: Vec<_> = doc.nodes().filter(|n| n.name == "Box").map(|n| n.id).collect();
for id in [main, insts[0], insts[1], insts[2]].into_iter().chain(boxes) {
let n = doc.node_mut(id).unwrap();
if n.name == "Box" {
n.kind = NodeKind::Frame;
}
n.corners.radii = [4.0, 8.0, 12.0, 16.0];
n.layout = Some(AutoLayout { gap: 8.0, cross_gap: 6.0, padding: [2.0, 4.0, 6.0, 8.0], ..AutoLayout::default() });
n.grids = vec![LayoutGrid { kind: GridKind::Columns, count: 4, gutter: 8.0, ..LayoutGrid::default() }];
n.child_layout.min_w = Some(20.0);
n.child_layout.max_h = Some(200.0);
}
let nodes = written(&doc);
assert_eq!(named(&nodes, "Scaled")["symbolData"]["uniformScaleFactor"], 1.5);
same_after_round_trip(&doc, "scaled main-equal layout and grid lengths");
}
#[test]
fn a_scaled_instance_keeps_main_equal_stroke_and_effect_lengths() {
let (mut doc, insts) = sized();
let child = doc.node(insts[2]).unwrap().children[0];
let source = doc.node(child).unwrap().source.unwrap();
for id in [source, child] {
let n = doc.node_mut(id).unwrap();
n.corners.radii = [2.0, 4.0, 6.0, 8.0];
n.stroke.dashes = vec![4.0, 2.0];
n.effects = vec![Effect {
kind: EffectKind::DropShadow(Shadow {
color: Color::new(0.0, 0.0, 0.0, 0.25),
offset: (2.0, 4.0),
radius: 8.0,
spread: 2.0,
blend: BlendMode::Normal,
show_behind: false,
}),
visible: true,
}];
}
let nodes = written(&doc);
assert_eq!(named(&nodes, "Scaled")["symbolData"]["uniformScaleFactor"], 1.5);
same_after_round_trip(&doc, "scaled main-equal stroke and effect lengths");
}
#[test]
fn a_scaled_instance_keeps_main_equal_text_lengths_and_runs() {
let (mut doc, insts) = sized();
let child = doc.node(insts[2]).unwrap().children[1];
let source = doc.node(child).unwrap().source.unwrap();
for id in [source, child] {
let NodeKind::Text(td) = &mut doc.node_mut(id).unwrap().kind else { panic!("text fixture") };
td.style.font_size = 16.0;
td.style.line_height = LineHeight::Pixels(24.0);
td.style.letter_spacing = LetterSpacing::Pixels(2.0);
td.paragraph_spacing = 6.0;
td.paragraph_indent = 4.0;
td.runs = vec![StyleRun { start: 0, end: 2, style: TextStyle { font_size: 20.0, ..td.style.clone() } }];
}
let nodes = written(&doc);
assert_eq!(named(&nodes, "Scaled")["symbolData"]["uniformScaleFactor"], 1.5);
same_after_round_trip(&doc, "scaled main-equal text lengths and runs");
}
#[test]
fn a_scaled_instance_keeps_a_main_equal_text_length_above_its_shared_style() {
let (mut doc, insts) = sized();
let child = doc.node(insts[2]).unwrap().children[1];
let source = doc.node(child).unwrap().source.unwrap();
let base = TextStyle { font_size: 12.0, line_height: LineHeight::Pixels(24.0), ..TextStyle::default() };
doc.styles.push(Style {
id: "label-style".into(),
name: "Label style".into(),
description: String::new(),
kind: StyleKind::Text { style: base.clone(), paragraph: Default::default() },
bound: Default::default(),
});
for id in [source, child] {
let n = doc.node_mut(id).unwrap();
n.styles.text = Some("label-style".into());
let NodeKind::Text(td) = &mut n.kind else { panic!("text fixture") };
td.style = base.clone();
if id == child {
td.style.line_height = LineHeight::Pixels(36.0);
}
}
let nodes = written(&doc);
assert_eq!(named(&nodes, "Scaled")["symbolData"]["uniformScaleFactor"], 1.5);
same_after_round_trip(&doc, "one text length reset above an inherited shared style");
}
#[test]
fn a_scaled_instance_keeps_reset_lists_above_a_changed_style_link() {
for (scaled, value) in [false, true].into_iter().flat_map(|scaled| [Some(10.0), Some(20.0), Some(15.0), None].map(|value| (scaled, value))) {
let (mut doc, insts) = sized();
let inst = insts[if scaled { 2 } else { 0 }];
let child = doc.node(inst).unwrap().children[0];
let source = doc.node(child).unwrap().source.unwrap();
let boxes: Vec<_> = doc.nodes().filter(|n| n.name == "Box").map(|n| n.id).collect();
for id in boxes {
doc.node_mut(id).unwrap().kind = NodeKind::Frame;
}
let effects = |radius| vec![Effect { kind: EffectKind::DropShadow(Shadow { radius, ..Shadow::default() }), visible: true }];
let grids = |gutter| vec![LayoutGrid { kind: GridKind::Columns, count: 4, gutter, ..LayoutGrid::default() }];
for (id, kind) in [
("effect-a", StyleKind::Effect { effects: effects(10.0) }),
("effect-b", StyleKind::Effect { effects: effects(20.0) }),
("grid-a", StyleKind::Grid { grids: grids(10.0) }),
("grid-b", StyleKind::Grid { grids: grids(20.0) }),
] {
doc.styles.push(Style { id: id.into(), name: id.into(), description: String::new(), kind, bound: Default::default() });
}
for id in [source, child] {
let n = doc.node_mut(id).unwrap();
let value = if id == source { Some(10.0) } else { value };
n.effects = value.map_or_else(Vec::new, effects);
n.grids = value.map_or_else(Vec::new, grids);
n.styles.effect = Some(if id == source { "effect-a" } else { "effect-b" }.into());
n.styles.grid = Some(if id == source { "grid-a" } else { "grid-b" }.into());
}
let nodes = written(&doc);
let name = if scaled { "Scaled" } else { "Same" };
assert_eq!(named(&nodes, name)["symbolData"]["uniformScaleFactor"], if scaled { 1.5 } else { 1.0 });
if value == Some(20.0) {
let inst = named(&nodes, name);
for rows in [&inst["symbolData"]["symbolOverrides"], &inst["derivedSymbolData"]] {
assert!(
rows.as_array().into_iter().flatten().all(|row| row.get("effects").is_none() && row.get("layoutGrids").is_none()),
"values supplied by the new style remain linked, not overridden"
);
}
}
same_after_round_trip(&doc, "explicit list values above a changed style link");
}
}
+1
View File
@@ -43,6 +43,7 @@ mod component;
mod layer;
mod paint;
mod prototype;
mod scale;
mod shape;
/// Most layers written, and deepest nesting followed (the import's limits).
+88 -7
View File
@@ -1,7 +1,33 @@
use super::common::{
bool_data, changed, prop_type, resolved_alike, scaled, sort_position, symbol_data, text_data, text_value, var_data, variant_pairs,
bool_data, changed, layout_grid, prop_type, resolved_alike, scaled, sort_position, symbol_data, text_data, text_value, var_data, variant_pairs,
};
use super::{AUX, BTreeMap, Exporter, Fields, Kv, MAX_NODES, Node, NodeId, NodeKind, PropDef, Value, arr, b, e, fl, guid, o, s};
use super::scale::main_equal_lengths;
use super::{AUX, BTreeMap, Bound, Exporter, Fields, Kv, MAX_NODES, Node, NodeId, NodeKind, PropDef, StyleKind, Value, arr, b, e, fl, guid, o, s};
/// A changed effect/grid style, and a payload only when it differs from that style's values.
struct ListStyleOverride {
link: &'static str,
field: &'static str,
value: Option<Kv<'static>>,
}
/// Keep a changed style link below its deliberate payload. Putting both on one row makes the
/// style beat the payload as a stale copy. Values equal to the new style need no override.
fn separate_style_rows(of: &mut Fields, df: &mut Fields, styles: Vec<ListStyleOverride>) -> Fields {
let Some(path) = of.iter().find(|(k, _)| *k == "guidPath").cloned() else { return Vec::new() };
let mut links = vec![path];
for style in styles {
let Some(link) = of.iter().find(|(k, _)| *k == style.link).cloned() else { continue };
of.retain(|(k, _)| *k != style.field);
df.retain(|(k, _)| *k != style.field);
if let Some(value) = style.value {
of.retain(|(k, _)| *k != style.link);
links.push(link);
of.push((style.field, value));
}
}
links
}
impl<'d> Exporter<'d> {
/// A component property definition of `owner` (a component, or a set), `index`-th in its list.
@@ -142,12 +168,17 @@ impl<'d> Exporter<'d> {
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();
let scale_candidate = Some(n.width / m.width)
.filter(|k| k.is_finite() && *k > 0.0 && (k - 1.0).abs() > 1e-6)
.filter(|k| (n.height / m.height - k).abs() <= 1e-6 * k.max(1.0));
// Each layer below: its id path, the fields that differ from the main's layer, that layer's
// fields, whether its size is its own (a nested instance, a size override) and the
// component a nested instance was swapped for.
struct Row {
path: Kv<'static>,
changed: Fields,
main_equal_lengths: Fields,
styles: Vec<ListStyleOverride>,
theirs: Fields,
sized: bool,
swap: Option<Kv<'static>>,
@@ -178,21 +209,27 @@ impl<'d> Exporter<'d> {
_ => None,
};
let sized = matches!(d.kind, NodeKind::Instance { .. }) || d.overrides.contains("size");
rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, sized, swap });
rows.push(Row {
path: o(vec![("guids", arr(guids))]),
main_equal_lengths: if scale_candidate.is_some() { main_equal_lengths(&mine, &theirs) } else { Vec::new() },
styles: self.changed_list_styles(d, &mine, &theirs),
changed: changed(mine, &theirs),
theirs,
sized,
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 factor = scale_candidate.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))
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
@@ -205,12 +242,20 @@ impl<'d> Exporter<'d> {
let mut overrides = Vec::new();
let mut derived = Vec::new();
let mut of: Fields = vec![("guidPath", o(vec![("guids", arr(vec![symbol.clone()]))]))];
// A main-equal length is an override of the scaled main, not a derived scaled value.
// Keep it out of `follows`, whose permissive equality also accepts dimensionless numbers.
if factor.is_some() {
of.extend(main_equal_lengths(&root_mine, &root_theirs));
}
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)]);
if factor.is_some() {
of.extend(r.main_equal_lengths);
}
for (k, v) in r.changed {
let follows = follows(k, &v, &r.theirs);
// A nested instance's size is its own to set, as is a size the layer overrides.
@@ -219,6 +264,10 @@ impl<'d> Exporter<'d> {
}
if follows { &mut df } else { &mut of }.push((k, v));
}
let links = separate_style_rows(&mut of, &mut df, r.styles);
if links.len() > 1 {
overrides.push(o(links));
}
if df.len() > 1 {
derived.push(o(df));
}
@@ -256,6 +305,38 @@ impl<'d> Exporter<'d> {
}
}
/// A changed list-style link is at the instance row's scale (1), not the inherited main's.
/// Compare with the same payload and bindings the asset writer emits. Preserve differing
/// effective values, including empty clears, but do not freeze values supplied by the style.
fn changed_list_styles(&mut self, n: &Node, mine: &Fields, theirs: &Fields) -> Vec<ListStyleOverride> {
let mut out = Vec::new();
let doc = self.doc;
for (link, field, id) in
[("styleIdForEffect", "effects", n.styles.effect.as_deref()), ("styleIdForGrid", "layoutGrids", n.styles.grid.as_deref())]
{
let current_link = mine.iter().find(|(k, _)| *k == link).map(|(_, v)| v);
if current_link.is_none() || current_link == theirs.iter().find(|(k, _)| *k == link).map(|(_, v)| v) {
continue;
}
let Some(style) = id.and_then(|id| doc.styles.iter().find(|style| style.id == id)) else { continue };
let bound = if style.bound.is_empty() {
Bound::default()
} else {
let mut sample = Node::new(NodeId(0), NodeKind::Rectangle, "", 0.0, 0.0);
sample.bound.clone_from(&style.bound);
self.bound(&sample)
};
let target = match (&style.kind, field) {
(StyleKind::Effect { effects }, "effects") => arr(effects.iter().enumerate().map(|(i, e)| self.effect(e, i, &bound)).collect()),
(StyleKind::Grid { grids }, "layoutGrids") => arr(grids.iter().enumerate().map(|(i, g)| layout_grid(g, i, &bound)).collect()),
_ => continue,
};
let current = mine.iter().find(|(k, _)| *k == field).map_or_else(|| arr(Vec::new()), |(_, v)| v.clone());
out.push(ListStyleOverride { link, field, value: (current != target).then_some(current) });
}
out
}
/// The main component layer an instance or instance layer mirrors (through nested instances),
/// or `id` itself.
pub(super) fn main_layer(&self, id: NodeId) -> NodeId {
+86
View File
@@ -0,0 +1,86 @@
use super::{Fields, Kv};
/// Main-equal fields which are not equal to the scaled main. Keep these separately until the
/// instance's factor is known: the ordinary diff would discard a length reset to its main's value.
pub(super) fn main_equal_lengths(mine: &Fields, theirs: &Fields) -> Fields {
mine.iter().filter(|(k, v)| has_length(k, v) && theirs.iter().any(|(tk, tv)| tk == k && tv == v)).cloned().collect()
}
/// Whether a field contains a finite, nonzero length read at its supplying row's scale by the
/// importer. Ratios, IDs, colours, grid counts and unit-box coordinates are not lengths.
fn has_length(key: &str, v: &Kv) -> bool {
match key {
"strokeWeight"
| "borderTopWeight"
| "borderRightWeight"
| "borderBottomWeight"
| "borderLeftWeight"
| "cornerRadius"
| "rectangleTopLeftCornerRadius"
| "rectangleTopRightCornerRadius"
| "rectangleBottomRightCornerRadius"
| "rectangleBottomLeftCornerRadius"
| "stackSpacing"
| "stackCounterSpacing"
| "stackHorizontalPadding"
| "stackVerticalPadding"
| "stackPaddingRight"
| "stackPaddingBottom"
| "gridColumnGap"
| "gridRowGap"
| "fontSize"
| "paragraphSpacing"
| "paragraphIndent"
| "listSpacing" => nonzero(v),
"size" => numbers(v, &["x", "y"]),
"transform" => numbers(v, &["m02", "m12"]),
"minSize" | "maxSize" => v.get("value").is_some_and(|p| numbers(p, &["x", "y"])),
"dashPattern" => v.list().iter().any(nonzero),
"lineHeight" | "letterSpacing" => v.get("units").and_then(Kv::str) == Some("PIXELS") && numbers(v, &["value"]),
"effects" => v.list().iter().any(|e| {
numbers(e, &["radius", "spread", "startRadius"])
|| ["offset", "noiseSize"].iter().any(|k| e.get(k).is_some_and(|p| numbers(p, &["x", "y"])))
}),
"layoutGrids" => v.list().iter().any(|g| numbers(g, &["sectionSize", "gutterSize", "offset"])),
"textData" => v.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().any(|style| {
numbers(style, &["fontSize", "paragraphSpacing", "paragraphIndent", "listSpacing"])
|| ["lineHeight", "letterSpacing"].iter().any(|k| style.get(k).is_some_and(|v| has_length(k, v)))
}),
_ => false,
}
}
/// Zero is unchanged by every scale; nonfinite sentinels (an absent maximum) are unchanged too.
fn nonzero(v: &Kv) -> bool {
v.f64().is_some_and(|n| n != 0.0)
}
/// Test only the named length members, not other numbers in a composite field.
fn numbers(v: &Kv, keys: &[&str]) -> bool {
keys.iter().any(|key| v.get(key).is_some_and(nonzero))
}
#[cfg(test)]
mod tests {
use super::super::{arr, e, fl, o};
use super::*;
#[test]
fn dimensionless_values_and_zero_do_not_create_scale_overrides() {
for (key, value) in [
("opacity", fl(0.5)),
("cornerRadius", fl(0.0)),
("cornerSmoothing", fl(0.5)),
("letterSpacing", o(vec![("units", e("PERCENT")), ("value", fl(10.0))])),
("lineHeight", o(vec![("units", e("RAW")), ("value", fl(1.5))])),
("transform", o(vec![("m00", fl(1.0)), ("m11", fl(1.0))])),
("layoutGrids", arr(vec![o(vec![("numSections", fl(12.0))])])),
("effects", arr(vec![o(vec![("density", fl(0.5)), ("startOffset", o(vec![("x", fl(0.5))]))])])),
("maxSize", o(vec![("value", o(vec![("x", Kv::Float(f32::INFINITY))]))])),
] {
assert!(!has_length(key, &value), "{key}");
}
assert!(has_length("paragraphIndent", &fl(-4.0)));
assert!(has_length("lineHeight", &o(vec![("units", e("PIXELS")), ("value", fl(24.0))])));
}
}
+15
View File
@@ -263,6 +263,21 @@ impl<'a> View<'a> {
v
}
/// An effect/grid list follows the same precedence as text styles: an override above the
/// style link wins, while the style beats stale copies on and below its linking row. A style
/// without the list is empty; it must not expose a stale list from a lower row.
fn styled_list(&self, style: &'a Kv<'a>, list: &str, key: &str, legacy: &str) -> (&'a [Kv<'a>], f64) {
let linked_in = |k: &str| self.layers.iter().rposition(|l| l.get(k).is_some());
let above = linked_in(key).or_else(|| linked_in(legacy)).map_or(1, |i| i + 1);
self.layers
.iter()
.zip(&self.scales)
.skip(above)
.rev()
.find_map(|(row, scale)| row.get(list).map(|v| (v.list(), *scale)))
.unwrap_or_else(|| (style.get(list).map(Kv::list).unwrap_or_default(), self.link_scale(key, legacy)))
}
fn get(&self, k: &str) -> Option<&'a Kv<'a>> {
self.layers.iter().rev().find_map(|l| l.get(k))
}
+4 -8
View File
@@ -531,18 +531,14 @@ impl<'a> Mapper<'a> {
.unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()));
o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect()));
let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles);
let (effects, effects_scale) = effect_style.map_or_else(
|| v.list_scaled("effects"),
|s| (s.get("effects").map(Kv::list).unwrap_or_default(), v.link_scale("styleIdForEffect", "inheritEffectStyleID")),
);
let (effects, effects_scale) =
effect_style.map_or_else(|| v.list_scaled("effects"), |s| v.styled_list(s, "effects", "styleIdForEffect", "inheritEffectStyleID"));
if !effects.is_empty() {
o.insert("effects".into(), Value::Array(effects.iter().filter_map(|e| effect(e, effects_scale)).collect()));
}
let grid_style = self.style(v, "styleIdForGrid", "inheritGridStyleID", "grid", &mut styles);
let (grids, grids_scale) = grid_style.map_or_else(
|| v.list_scaled("layoutGrids"),
|s| (s.get("layoutGrids").map(Kv::list).unwrap_or_default(), v.link_scale("styleIdForGrid", "inheritGridStyleID")),
);
let (grids, grids_scale) =
grid_style.map_or_else(|| v.list_scaled("layoutGrids"), |s| v.styled_list(s, "layoutGrids", "styleIdForGrid", "inheritGridStyleID"));
if !grids.is_empty() {
o.insert("layoutGrids".into(), Value::Array(grids.iter().map(|g| layout_grid(g, grids_scale)).collect()));
}
+89 -1
View File
@@ -25,9 +25,10 @@ message FontName string family, string style, string postscript
message TextData string characters
message Effect EffectType type, float radius, float spread, Vector offset, float startRadius, Vector startOffset, Vector endOffset, BlurOpType blurOpType
message StyleId GUID guid
message LayoutGrid float sectionSize, float gutterSize, float offset, float numSections
message GUIDPath GUID[] guids
message SymbolData GUID symbolID, NodeChange[] symbolOverrides, float uniformScaleFactor
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float cornerRadius, float strokeWeight, float[] dashPattern, float fontSize, FontName fontName, TextData textData, SymbolData symbolData, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, float stackCounterSpacing, Effect[] effects, Number lineHeight, Number letterSpacing, NodeChange[] derivedSymbolData, GUIDPath guidPath, StyleType styleType, StyleId styleIdForEffect
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float cornerRadius, float strokeWeight, float[] dashPattern, float fontSize, FontName fontName, TextData textData, SymbolData symbolData, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, float stackCounterSpacing, Effect[] effects, Number lineHeight, Number letterSpacing, NodeChange[] derivedSymbolData, GUIDPath guidPath, StyleType styleType, StyleId styleIdForEffect, StyleId styleIdForGrid, LayoutGrid[] layoutGrids
message Blob byte[] bytes
message Message NodeChange[] nodeChanges, Blob[] blobs
";
@@ -368,3 +369,90 @@ fn an_effect_style_takes_the_scale_of_the_layer_that_links_it() {
};
assert_eq!((radius("Kept"), radius("Swapped")), (5.0, 10.0));
}
#[test]
fn nested_scaled_style_overrides_keep_their_values_and_links_through_export() {
let mut nodes = page();
let shadow = |radius| o(vec![("type", e("DROP_SHADOW")), ("radius", f(radius))]);
let grid = |size| o(vec![("sectionSize", f(size)), ("gutterSize", f(size)), ("numSections", f(4.0))]);
let link = |id| o(vec![("guid", g(id))]);
nodes.push(node(30, Some((1, "~")), "ROUNDED_RECTANGLE", "Soft", vec![("styleType", e("EFFECT")), ("effects", arr(vec![shadow(10.0)]))]));
nodes.push(node(31, Some((1, "}")), "FRAME", "Grid", vec![("styleType", e("GRID")), ("layoutGrids", arr(vec![grid(20.0)]))]));
nodes.push(node(5, Some((1, "!")), "SYMBOL", "Inner", vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0))]));
nodes.push(node(
6,
Some((5, "!")),
"FRAME",
"Mark",
vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0)), ("styleIdForEffect", link(30)), ("styleIdForGrid", link(31))],
));
nodes.push(node(7, Some((1, "\"")), "SYMBOL", "Outer", vec![("size", xy(200.0, 200.0)), ("transform", at(300.0, 0.0))]));
nodes.push(set(instance(8, (7, "!"), "Kid", 5, (100.0, 100.0), Some(0.5), vec![]), "transform", at(0.0, 0.0)));
let row =
o(vec![("guidPath", o(vec![("guids", arr(vec![g(8), g(6)]))])), ("effects", arr(vec![shadow(5.0)])), ("layoutGrids", arr(vec![grid(10.0)]))]);
let symbol = o(vec![("symbolID", g(7)), ("uniformScaleFactor", f(0.5)), ("symbolOverrides", arr(vec![row]))]);
nodes.push(node(9, Some((1, "#")), "INSTANCE", "Reset", vec![("size", xy(100.0, 100.0)), ("transform", at(600.0, 0.0)), ("symbolData", symbol)]));
nodes.push(instance(10, (1, "$"), "Scaled", 7, (100.0, 100.0), Some(0.5), vec![]));
let check = |doc: &Document| {
for (name, radius, gap) in [("Reset", 5.0, 10.0), ("Scaled", 2.5, 5.0)] {
let kid = first_child(doc, name);
let mark = doc.node(kid.children[0]).unwrap();
let EffectKind::DropShadow(sh) = &mark.effects[0].kind else { panic!("shadow fixture") };
assert_eq!(sh.radius, radius, "{name}");
assert_eq!(mark.grids[0].gutter, gap, "{name}");
assert_eq!(mark.grids[0].count, 4, "dimensionless grid count");
let effect = doc.styles.iter().find(|s| s.name == "Soft").unwrap();
let grid = doc.styles.iter().find(|s| s.name == "Grid").unwrap();
assert_eq!(mark.styles.effect.as_ref(), Some(&effect.id));
assert_eq!(mark.styles.grid.as_ref(), Some(&grid.id));
}
};
let doc = import_nodes(nodes);
check(&doc);
let (bytes, _) = export(&doc, None).unwrap();
check(&import(&bytes, "round trip").unwrap().0);
}
#[test]
fn an_empty_effect_or_grid_style_and_explicit_clears_stay_empty() {
for empty in [false, true] {
let mut nodes = page();
let effects = arr(vec![o(vec![("type", e("DROP_SHADOW")), ("radius", f(10.0))])]);
let grids = arr(vec![o(vec![("gutterSize", f(20.0)), ("numSections", f(4.0))])]);
let link = |id| o(vec![("guid", g(id))]);
let mut effect_style = vec![("styleType", e("EFFECT"))];
let mut grid_style = vec![("styleType", e("GRID"))];
if !empty {
effect_style.push(("effects", effects.clone()));
grid_style.push(("layoutGrids", grids.clone()));
}
nodes.push(node(30, Some((1, "~")), "ROUNDED_RECTANGLE", "Soft", effect_style));
nodes.push(node(31, Some((1, "}")), "FRAME", "Grid", grid_style));
nodes.push(node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(100.0, 100.0))]));
nodes.push(node(
6,
Some((5, "!")),
"FRAME",
"Mark",
vec![
("size", xy(100.0, 100.0)),
("styleIdForEffect", link(30)),
("styleIdForGrid", link(31)),
("effects", effects),
("layoutGrids", grids),
],
));
nodes.push(instance(9, (1, "\""), "Kept", 5, (50.0, 50.0), Some(0.5), vec![]));
let row = o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("effects", arr(vec![])), ("layoutGrids", arr(vec![]))]);
let symbol = o(vec![("symbolID", g(5)), ("uniformScaleFactor", f(0.5)), ("symbolOverrides", arr(vec![row]))]);
nodes.push(node(10, Some((1, "#")), "INSTANCE", "Cleared", vec![("size", xy(50.0, 50.0)), ("symbolData", symbol)]));
let doc = import_nodes(nodes);
let kept = first_child(&doc, "Kept");
assert_eq!(kept.effects.is_empty(), empty);
assert_eq!(kept.grids.is_empty(), empty);
let cleared = first_child(&doc, "Cleared");
assert!(cleared.effects.is_empty() && cleared.grids.is_empty());
assert_eq!(cleared.styles.effect, kept.styles.effect);
assert_eq!(cleared.styles.grid, kept.styles.grid);
}
}
File diff suppressed because one or more lines are too long