mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 20:29:33 -06:00
M6: preserve scaled instance export overrides (#2097)
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))])));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user