Merge #281: fix(format): import explicit variable modes referenced by asset key (@notdezzi)

This commit is contained in:
bflatastic
2026-10-15 19:32:32 +07:00
2 changed files with 129 additions and 7 deletions
+50 -4
View File
@@ -25,6 +25,8 @@ const MAX_DEPTH: usize = 256;
const MAX_COMMANDS: usize = 200_000;
/// Id of "no style".
const NO_STYLE: &str = "4294967295:4294967295";
/// Longest asset `key` or `version` kept for resolving references given as assets.
const MAX_ASSET_PART: usize = 256;
/// The REST API file JSON for message `msg`, with the warnings about what was skipped.
pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec<String>) {
@@ -249,6 +251,10 @@ struct Mapper<'a> {
blobs: Vec<&'a [u8]>,
/// Component property definitions: id → (REST name "Name#id", type).
prop_defs: BTreeMap<String, (String, &'a str)>,
/// File nodes by asset `key` and `version`: references can name a collection, variable or
/// style as the asset instead of the guid, and the file carries the resources to resolve
/// them (the collection node itself says the same `key`).
assets: BTreeMap<String, BTreeMap<String, String>>,
styles: Obj,
/// Descriptions of components and component sets (REST `components` / `componentSets`).
components: Obj,
@@ -287,7 +293,37 @@ impl<'a> Mapper<'a> {
}
let blobs =
msg.get("blobs").map(Kv::list).unwrap_or_default().iter().map(|b| b.get("bytes").and_then(Kv::bytes).unwrap_or_default()).collect();
Self { nodes, kids, blobs, prop_defs, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 }
let mut assets: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for n in msg.get("nodeChanges").map(Kv::list).unwrap_or_default() {
let (Some(key), Some(id)) = (n.get("key").and_then(Kv::str), n.get("guid").and_then(guid)) else { continue };
if key.is_empty() || key.len() > MAX_ASSET_PART {
continue;
}
let version = n.get("version").and_then(Kv::str).filter(|v| !v.is_empty() && v.len() <= MAX_ASSET_PART).unwrap_or("");
assets.entry(key.to_string()).or_default().entry(version.to_string()).or_insert(id);
}
Self { nodes, kids, blobs, prop_defs, assets, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 }
}
/// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`,
/// else the file node its asset `key` and `version` names (a collection from a library is
/// referenced as the asset, and the file's own node for it says the same `key`). `None` when
/// the reference has neither or the asset names nothing (or ambiguously) in the file.
/// Original code for this importer: the field names are those of the schema each file embeds.
fn resource_id(&self, v: &Kv) -> Option<String> {
if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE) {
return Some(g);
}
let asset = v.get("assetRef")?;
let key = asset.get("key").and_then(Kv::str)?;
let versions = self.assets.get(key)?;
let version = asset.get("version").and_then(Kv::str);
match version {
Some(v) => versions.get(v),
// Without a version: an unversioned record, else the file's only one.
None => versions.get("").or_else(|| (versions.len() == 1).then(|| versions.values().next()).flatten()),
}
.cloned()
}
fn warn(&mut self, w: impl Into<String>) {
@@ -1236,11 +1272,21 @@ impl<'a> Mapper<'a> {
o.insert("style".into(), json!({"boundVariables": text_bound}));
}
let mut modes = Obj::new();
let mut lost = false;
for e in v.get("variableModeBySetMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() {
if let (Some(set), Some(mode)) = (e.get("variableSetID").and_then(ref_guid), e.get("variableModeID").and_then(guid)) {
modes.insert(set, json!(mode));
let (Some(set), Some(mode)) = (e.get("variableSetID"), e.get("variableModeID").and_then(guid)) else { continue };
match self.resource_id(set) {
Some(id) => {
modes.insert(id, json!(mode));
}
// A mode of a collection neither named by guid nor resolvable in the file.
None if set.get("guid").is_some() || set.get("assetRef").is_some() => lost = true,
None => {}
}
}
if lost {
self.warn("variable modes of collections that aren't in the file were skipped");
}
if !modes.is_empty() {
o.insert("explicitVariableModes".into(), Value::Object(modes));
}
@@ -1305,7 +1351,7 @@ impl<'a> Mapper<'a> {
let mut variable_order = Vec::new();
let mut variables = Obj::new();
for (id, n) in of_type("VARIABLE", "sortPosition") {
let Some(set) = n.get("variableSetID").and_then(ref_guid).filter(|s| collections.contains_key(s)) else { continue };
let Some(set) = n.get("variableSetID").and_then(|s| self.resource_id(s)).filter(|s| collections.contains_key(s)) else { continue };
let ty = n.get("variableResolvedType").and_then(Kv::str).unwrap_or("");
if !matches!(ty, "BOOLEAN" | "FLOAT" | "STRING" | "COLOR") {
continue;
+79 -3
View File
@@ -39,12 +39,13 @@ struct Vector float x, float y
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
struct ColorStop Color color, float position
message Image byte[] hash, string name
message VariableID GUID guid
message AssetRef string key, string version
message VariableID GUID guid, AssetRef assetRef
message PropRefValue GUID defId
message AnyValue bool boolValue, float floatValue, VariableID alias, Color colorValue, PropRefValue propRefValue, TextData textDataValue, string textValue, Expression expressionValue
message Expression ExpressionFunction expressionFunction, VariableData[] expressionArguments
message VariableTarget VariableID id
message VariableSetID GUID guid
message VariableSetID GUID guid, AssetRef assetRef
message ConditionalActions Action[] actions, VariableData condition
message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable
message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition
@@ -71,7 +72,7 @@ message Event InteractionType interactionType
message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions
message Interaction Event event, Action[] actions
message StartingPoint string name, string position
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version
message Blob byte[] bytes
message Message NodeChange[] nodeChanges, Blob[] blobs
";
@@ -666,6 +667,81 @@ fn instances_inside_their_own_main_stop() {
assert!(doc.nodes().any(|n| n.name == "Self"));
}
/// A collection from a library is named by its asset key, not a guid — also in the explicit mode
/// map of the frames, components and instances set it on, and in the variables' collection link.
#[test]
fn modes_of_collections_named_by_asset_import() {
let modes = arr(vec![
o(vec![("id", g(90)), ("name", s("Dark")), ("sortPosition", s("!"))]),
o(vec![("id", g(91)), ("name", s("Light")), ("sortPosition", s("\""))]),
]);
let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]);
let values = o(vec![(
"entries",
arr(vec![
o(vec![("modeID", g(90)), ("variableData", color_value(rgb(0.0, 0.0, 0.0)))]),
o(vec![("modeID", g(91)), ("variableData", color_value(rgb(1.0, 1.0, 1.0)))]),
]),
)]);
let by_asset = |key: &'static str, version: Option<&'static str>| {
let mut r = vec![("key", s(key))];
r.extend(version.map(|v| ("version", s(v))));
o(vec![("assetRef", o(r))])
};
let mode_map = |set: Kv<'static>| o(vec![("entries", arr(vec![o(vec![("variableSetID", set), ("variableModeID", g(91))])]))]);
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page A", vec![]),
node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
// The collection itself says the asset key its references name.
node(4, Some((2, "!")), "VARIABLE_SET", "Schema", vec![("variableSetModes", modes), ("key", s("schema-collection")), ("version", s("7"))]),
node(
5,
Some((2, "\"")),
"VARIABLE",
"surface",
vec![("variableSetID", by_asset("schema-collection", Some("7"))), ("variableResolvedType", e("COLOR")), ("variableDataValues", values)],
),
node(
10,
Some((1, "!")),
"FRAME",
"Light section",
vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7"))))],
),
node(
20,
Some((1, "\"")),
"SYMBOL",
"Button",
vec![("size", xy(100.0, 40.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", None)))],
),
node(
21,
Some((1, "#")),
"INSTANCE",
"Button copy",
vec![
("size", xy(100.0, 40.0)),
("symbolData", o(vec![("symbolID", g(20))])),
("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7")))),
],
),
// One names a collection that isn't in the file: its mode is skipped, with a warning.
node(11, Some((1, "$")), "FRAME", "Gone", vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("gone", None)))]),
];
let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
assert_eq!(doc.variables.collections.len(), 1);
assert_eq!(doc.variables.variables.len(), 1);
assert_eq!(doc.variables.variables[0].collection, "1:4");
for name in ["Light section", "Button", "Button copy"] {
let n = named(&doc, name);
assert_eq!(n.var_modes.get("1:4").map(String::as_str), Some("1:91"), "{name}");
}
assert!(named(&doc, "Gone").var_modes.is_empty());
assert!(warnings.iter().any(|w| w.contains("variable modes of collections that aren't in the file")), "{warnings:?}");
}
#[test]
fn resized_instances_lay_their_layers_out_by_their_constraints() {
let pin = |h: &'static str, v: &'static str| vec![("horizontalConstraint", e(h)), ("verticalConstraint", e(v))];