M6: preserve scaled instance export overrides (#2097)

This commit is contained in:
FredApps committed 2026-11-08 15:23:36 +01:00
1 parent 9303b73991
commit 23ed778562
8 files changed
+447 -17

No files matched your search

+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);
}
}
+9 -1
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.fig` import: the layers of an instance scaled with the Scale tool draw at its scale factor, not at their main's lengths (#2091); a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#7Line truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (`.fig` export preserves scaled-instance lengths reset to their unscaled main values, including nested linked-style overrides (#2097); `.fig` import: the layers of an instance scaled with the Scale tool draw at its scale factor, not at their main's lengths (#2091); a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats Line truncated
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -85,6 +85,13 @@ string that orders it among its siblings; vector networks and outlines go in blo
each. `.fig` export warns and drops vector region paint stacks; import and editing preserve their
source paint coordinates.
Scaled instances retain an explicit override when a length is reset to the unscaled main's value,
rather than losing it to the field diff. This covers corners, strokes, layout and grid lengths,
min/max bounds, effects and text, including styled runs. Effect and grid payload overrides above
an inherited style link keep their own values and the link; stale copies on the linking row
still yield to the style. These cases have synthetic export/import regressions, including nested
scales and empty-style clears; they have not been checked in other software.
- **Written:** pages (name, background); frames, groups, sections, rectangles (as rounded
rectangles, with their four radii), ellipses (arcs), polygons, stars, lines, vectors (editable
network blobs with per-point corner radius, handle mirroring, caps and joins, plus the outline of
@@ -189,6 +196,7 @@ source paint coordinates.
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | patch | #2097 / #2100: scaled-instance lengths reset to the unscaled main value survive export/import as explicit overrides. Dimensionless values and zero do not gain scale overrides. Effect/grid payload rows above an inherited style link take precedence at their own scale, including nested instances and empty-list clears, without detaching styles. Synthetic regressions cover corners, dashes/shadows, layout/grids and bounds, typography and styled runs, linked text and nested styles. Numbers unchanged. |
| 2026-11-07 | minor | `.fig` import, #2091 item 1: an instance scaled with the Scale tool stores `symbolData.uniformScaleFactor`, which the import never read, so the layers of a 128 px instance of a 256 px component came in at 256 px with the main's radii, strokes and spacing. The layers of an instance now take the main's lengths times the factor (composed through nested instances, so 0.5 inside 0.5 is 0.25 for the inner layers), the lengths the Scale tool scales: sizes and places, corner radii, stroke weights and dashes, effect radii, spreads and offsets, auto layout gaps and paddings, grid gaps, min and max sizes, layout grids, font sizes, pixel line heights and letter spacings, paragraph spacing and indent. A value an override or derived row gives is taken as written, already at the instance's scale; the instance's own size is the file's; a missing, non-finite, non-positive or out-of-range factor (outside 0.001 to 1000) changes nothing. Not scaled: vector outline paths (a network's points follow the layer's size), per-point corner radii, fixed grid tracks, image tile scales and glass effects. Tests: `fig::tests::a_scaled_instance_draws_its_layers_at_the_factor` and `fig::scale_factor_tests`. Numbers unchanged |
| 2026-11-03 | patch | M6, #1909: `.jam` import: a connector end whose magnet is `AUTO` (FigJam picks the side when it draws) or `CENTER` (the end aims at the layer's middle) left the side its stale `relativePosition` named, the left one in the reporter's file, so the connectors looped behind their shape or crossed it. It now leaves the side facing the other end, as a board connector drawn here does (`Side::Auto`), for straight, elbowed and curved connectors alike; a named magnet (`TOP`, `RIGHT`, `BOTTOM`, `LEFT`) is unchanged, and an end without a magnet still takes the edge its point lies on. The Plugin API lists the magnets (`ConnectorEndpoint.magnet`: "Which side of the node it attaches to"; NONE, AUTO, TOP, LEFT, BOTTOM, RIGHT, CENTER; "Straight connectors may use only CENTER or NONE magnets"). Test: `connectors_with_an_automatic_side_leave_the_side_facing_the_target`. Numbers unchanged. |
| 2026-11-04 | minor | JPG export: `quality` (1 to 100, default 90) on `file.export`, `export.add` / `export.update` and MCP `export`, kept in the export setting; an sRGB file's JPGs embed the sRGB ICC profile, as Display P3 files' JPGs already embedded theirs, since an untagged JPG is only assumed to be sRGB (#2004). |