diff --git a/map.rs b/map.rs new file mode 100644 index 000000000..98239cd7b --- /dev/null +++ b/map.rs @@ -0,0 +1,490 @@ +//! Decoded `.fig` message → REST API file JSON, which [`crate::rest`] then imports. +//! +//! The message lists node changes; each names its id (`guid`), its parent and its position among +//! the siblings (`parentIndex`). Nodes are read by field name, so files written with newer +//! schemas still import what is understood. Instances carry only their overrides: their layers +//! are expanded here from the main component (overrides and derived layout matched by id path, +//! component property values applied) so the import looks as saved. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::rc::Rc; + +use figcraft_doc::VectorNetwork; +use figcraft_doc::geom::{Affine, Point}; +use figcraft_doc::proto::{MAX_ACTION_DEPTH, MAX_EXPR_DEPTH}; +use serde_json::{Map, Value, json}; + +use super::kiwi::Kv; +use super::network; + +mod component; +mod connector; +mod document; +mod layer; +mod layout; +mod paint; +mod proto; +mod text; +mod variables; + +use component::OverrideScope; +#[cfg(test)] +use paint::paint; + +type Obj = Map; + +/// Deepest nesting followed. +const MAX_DEPTH: usize = 256; +/// Most path commands read from one geometry blob. +const MAX_COMMANDS: usize = 200_000; +const MAX_GRID_TRACKS: usize = 1_000; +pub(super) const MAX_GRID_TRACK_VALUE: f64 = 1e6; +/// 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 and the +/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]). +pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec, Vec>) { + let mut m = Mapper::new(msg, max_nodes); + let _canon = VarCanon::install(&m); + let doc = m.document(); + // Large values are moved in, never passed through `json!`, which serialises its arguments: + // a deep copy of the whole tree, briefly holding it twice. + let mut out = json!({"name": name}); + out["document"] = doc; + if !m.library_contexts.is_empty() { + out[crate::rest::FIG_LIBRARY_CONTEXTS_FIELD] = Value::Array(std::mem::take(&mut m.library_contexts)); + } + // Mains in the file that the layer cap kept out: their instances are told apart from those of + // components the file doesn't have (#981). + if m.count >= m.max_nodes { + let capped: Vec = m.referenced_mains.difference(&m.mapped_mains).map(|id| json!(id)).collect(); + if !capped.is_empty() { + out[crate::rest::FIG_CAPPED_MAINS_FIELD] = Value::Array(capped); + } + } + if !m.styles.is_empty() { + out["styles"] = Value::Object(std::mem::take(&mut m.styles)); + } + if !m.components.is_empty() { + out["components"] = Value::Object(std::mem::take(&mut m.components)); + } + if !m.sets.is_empty() { + out["componentSets"] = Value::Object(std::mem::take(&mut m.sets)); + } + if let Some(vars) = m.variables() { + out["meta"] = vars; + } + let styles = m.style_definitions(); + if !styles.is_empty() { + out["styleDefinitions"] = Value::Array(styles); + } + (out, m.warnings.finish(), m.networks) +} + +#[cfg(test)] +mod image_filter_tests; + +/// REST importer warnings reworded for a `.fig` import (`None`: drop it). +pub(super) fn adapt_warning(w: String) -> Option { + if w.contains("geometry=paths") { + return None; + } + Some(w.replace("the variables JSON", "the file").replace("the file JSON", "the file")) +} + +// ---- small readers ---- + +/// A generated child is identified by one exact template GUID, not its array position. +fn generated_record<'a>(v: &View<'a>, field: &str, local_id: u32) -> Option<&'a Kv<'a>> { + v.get(field)?.get("overrides")?.list().iter().find(|row| { + row.get("guidPath").and_then(|path| path.get("guids")).map(Kv::list).is_some_and(|guids| { + guids.len() == 1 && num(&guids[0], "sessionID") == Some(40_000_000.0) && num(&guids[0], "localID") == Some(f64::from(local_id)) + }) + }) +} + +fn num(n: &Kv, k: &str) -> Option { + n.get(k).and_then(Kv::f64) +} + +fn guid(v: &Kv) -> Option { + Some(format!("{}:{}", num(v, "sessionID")? as u64, num(v, "localID")? as u64)) +} + +#[cfg(test)] +mod connector_snapshot_budget_tests; + +/// A `GUID`-holding reference (`StyleId`, `VariableID`, …): its `guid`. +fn ref_guid(v: &Kv) -> Option { + v.get("guid").and_then(guid).filter(|g| g != NO_STYLE) +} + +fn vec2(v: &Kv) -> Option { + Some(json!({"x": num(v, "x")?, "y": num(v, "y")?})) +} + +fn size(v: &Kv) -> Option<(f64, f64)> { + Some((num(v, "x")?, num(v, "y")?)) +} + +fn matrix(m: &Kv) -> Option { + let c = |k: &str| num(m, k); + let a = Affine::new([c("m00")?, c("m10")?, c("m01")?, c("m11")?, c("m02")?, c("m12")?]); + a.as_coeffs().iter().all(|x| x.is_finite()).then_some(a) +} + +fn rows(a: Affine) -> Value { + let [a, b, c, d, e, f] = a.as_coeffs(); + json!([[a, c, e], [b, d, f]]) +} + +fn copy_num(o: &mut Obj, n: &View, from: &str, to: &str) { + if let Some(x) = n.num(from) { + o.insert(to.into(), json!(x)); + } +} + +fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) { + if let Some(s) = n.str(from) { + o.insert(to.into(), json!(s)); + } +} + +/// A node seen through override layers: the last layer with a field wins. +#[derive(Clone)] +struct View<'a> { + layers: Vec<&'a Kv<'a>>, + /// Size and transform worked out from the constraints, over the layers' own. + fitted: Option<((f64, f64), Affine)>, +} + +impl<'a> View<'a> { + fn of(n: &'a Kv<'a>) -> Self { + Self { layers: vec![n], fitted: None } + } + + fn size(&self) -> Option<(f64, f64)> { + self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(size)) + } + + fn transform(&self) -> Option { + self.fitted.map(|f| f.1).or_else(|| self.get("transform").and_then(matrix)) + } + + fn with(&self, top: impl IntoIterator>) -> Self { + let mut v = self.clone(); + v.layers.extend(top); + v + } + + /// This view with `style` just above the layer that links it in `key` (or `legacy`): the style + /// beats that layer's and lower layers' copies of its fields (stale when the style changed + /// since, or written for the style linked before), and override rows above the link beat it. + fn styled(&self, style: Option<&'a Kv<'a>>, key: &str, legacy: &str) -> Self { + let mut v = self.clone(); + if let Some(s) = style { + let linked_in = |k: &str| v.layers.iter().rposition(|l| l.get(k).is_some()); + let at = linked_in(key).or_else(|| linked_in(legacy)).map_or(1, |i| i + 1); + v.layers.insert(at.min(v.layers.len()), s); + } + v + } + + fn get(&self, k: &str) -> Option<&'a Kv<'a>> { + self.layers.iter().rev().find_map(|l| l.get(k)) + } + + fn num(&self, k: &str) -> Option { + self.get(k).and_then(Kv::f64) + } + + fn str(&self, k: &str) -> Option<&'a str> { + self.get(k).and_then(Kv::str) + } + + fn flag(&self, k: &str) -> Option { + self.get(k).and_then(Kv::bool) + } + + fn list(&self, k: &str) -> &'a [Kv<'a>] { + self.get(k).map(Kv::list).unwrap_or_default() + } +} + +struct Mapper<'a> { + nodes: BTreeMap>, + kids: BTreeMap>, + blobs: Vec<&'a [u8]>, + /// Component property definitions: id → (REST name "Name#id", type). + prop_defs: BTreeMap, + /// The copies of a set's definitions each variant keeps (no name or type of their own; the + /// variant's layers and its instances' assignments use them): copy id → the definition's id. + prop_parents: BTreeMap, + /// 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>, + styles: Obj, + /// Descriptions of components and component sets (REST `components` / `componentSets`). + components: Obj, + sets: Obj, + /// Layers and keys of each main, built when an instance of it first needs them. + override_scopes: BTreeMap>, + /// Entries held by `override_scopes` (bounded by `max_nodes` together). + override_scope_entries: usize, + /// Component-bearing branches of the file's internal canvases (cached library definitions). + library_contexts: Vec, + /// Layers visited while scanning internal canvases, across all of them. + library_scan_count: usize, + warnings: crate::rest::Warnings, + count: usize, + /// The mains in the file that instances or instance swap defaults name, and the mains mapped + /// as layers: the difference is what the layer cap kept out. + referenced_mains: BTreeSet, + mapped_mains: BTreeSet, + /// Most layers produced or scanned, instance expansion included; the one place the cap comes + /// from (see [`super::layer_budget`]). + max_nodes: usize, + /// Layers kept back for the visible pages' top level while library copies are mapped, so the + /// copies can't take the whole budget and leave the pages empty. + reserved: usize, + /// Vector networks, each built once: the layers that show one (every instance of a main + /// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's + /// networks took gigabytes. + networks: Vec>, + /// Index in `networks` by what a network is made from: its blob, its scale and its style table. + network_ids: HashMap<(usize, u64, u64, usize), usize>, + /// Region paint elements (paints and gradient stops) read so far: one budget for the whole + /// file, since layers with region paints don't share their network. + region_paint_elements: usize, +} + +impl<'a> Mapper<'a> { + fn new(msg: &'a Kv<'a>, max_nodes: usize) -> Self { + let mut nodes = BTreeMap::new(); + let mut kids: BTreeMap> = BTreeMap::new(); + let mut prop_defs = BTreeMap::new(); + let mut prop_parents = BTreeMap::new(); + let changes = msg.get("nodeChanges").map(Kv::list).unwrap_or_default(); + // The last change of a GUID wins; a REMOVED change deletes what came before it. + for n in changes { + let Some(id) = n.get("guid").and_then(guid) else { continue }; + if n.get("phase").and_then(Kv::str) == Some("REMOVED") { + nodes.remove(&id); + } else { + nodes.insert(id, n); + } + } + for n in changes { + let Some(id) = n.get("guid").and_then(guid) else { continue }; + if !nodes.get(&id).is_some_and(|latest| std::ptr::eq(*latest, n)) { + continue; + } + // Variables and their collections are read from the node list (`of_type`), never + // as layers: out of the layer tree, an instance's expansion doesn't walk them again for + // every instance without counting them (#1077). + if let Some(p) = n.get("parentIndex") + && let Some(parent) = p.get("guid").and_then(guid) + && !matches!(n.get("type").and_then(Kv::str), Some("VARIABLE" | "VARIABLE_SET")) + { + kids.entry(parent).or_default().push((p.get("position").and_then(Kv::str).unwrap_or(""), id.clone())); + } + for d in n.get("componentPropDefs").map(Kv::list).unwrap_or_default() { + let Some(did) = d.get("id").and_then(guid) else { continue }; + if let (Some(name), Some(ty)) = (d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str)) { + prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty)); + } else if let Some(parent) = d.get("parentPropDefId").and_then(guid) { + prop_parents.insert(did, parent); + } + } + } + for list in kids.values_mut() { + list.sort(); + list.dedup_by(|a, b| a.1 == b.1); + } + 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(); + let mut assets: BTreeMap> = 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, + prop_parents, + assets, + styles: Obj::new(), + components: Obj::new(), + sets: Obj::new(), + override_scopes: BTreeMap::new(), + override_scope_entries: 0, + library_contexts: Vec::new(), + library_scan_count: 0, + warnings: crate::rest::Warnings::default(), + count: 0, + referenced_mains: BTreeSet::new(), + mapped_mains: BTreeSet::new(), + max_nodes, + reserved: 0, + networks: Vec::new(), + network_ids: HashMap::new(), + region_paint_elements: 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 { + // A guid names the resource when this file holds a node by it. One from another file (the + // library a style or collection came from, as a paste carries) names nothing here: the + // asset it also names finds the file's copy (#1638). + if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE && self.nodes.contains_key(g)) { + 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() + } + + /// The collection a `VariableSetID` names, as one collection per library asset: a file that + /// took several updates of a library holds a copy of the collection for each version it met, + /// all with the same asset `key`. Variables and explicit modes may name any of those copies, + /// while the mode ids stay the same across them, so every copy maps to the newest one (the + /// one [`Self::variables`] keeps, with the modes of the others added). Without this, a page set + /// to a mode an older copy didn't have yet lost its mode, and the layers fell back to the + /// default mode of the newest copy. + pub(super) fn collection_id(&self, v: &Kv) -> Option { + let id = self.resource_id(v)?; + Some(self.canonical_collection(&id).unwrap_or(id)) + } + + /// The newest live copy of collection node `id`'s library asset, when it has a key. + pub(super) fn canonical_collection(&self, id: &str) -> Option { + let key = self.nodes.get(id)?.get("key").and_then(Kv::str).filter(|k| !k.is_empty())?; + let live_set = |n: &Kv| n.get("type").and_then(Kv::str) == Some("VARIABLE_SET") && n.get("isSoftDeleted").and_then(Kv::bool) != Some(true); + self.assets + .get(key)? + .iter() + .filter(|(_, nid)| self.nodes.get(nid.as_str()).is_some_and(|n| live_set(n))) + .max_by_key(|(ver, _)| version_rank(ver)) + .map(|(_, nid)| nid.clone()) + } + + /// A note about what this file's mapping left out or changed. Counted, as the REST importer + /// counts its own: these notes are met once per layer, so the kinds were named once each with + /// no sign of how much of the file they covered -- a file whose every text layer lost a + /// setting read the same as one where a single layer did (#467). The collector keeps the + /// first-seen order and the same cap, since some notes name fields of the file (image + /// filters). + fn warn(&mut self, w: impl Into) { + self.warnings.add(w.into()); + } + + fn children(&self, id: &str) -> Vec { + self.kids.get(id).map(|k| k.iter().map(|(_, c)| c.clone()).collect()).unwrap_or_default() + } + + /// How many layers may be produced now: the cap, less what is kept for the visible pages. + fn budget(&self) -> usize { + self.max_nodes.saturating_sub(self.reserved) + } +} + +/// A library version (`"40727:1183"`) as numbers to order by; unparsable parts rank lowest. +fn version_rank(v: &str) -> (u64, u64) { + let mut parts = v.split(':').map(|p| p.trim().parse::().unwrap_or(0)); + (parts.next().unwrap_or(0), parts.next().unwrap_or(0)) +} + +/// Library variables, one per asset. A file that took several updates of a library keeps a copy +/// of a variable for each version it met (same `key`, another `version`), and layers and other +/// variables name them by that asset (`assetRef {key, version}`) rather than by guid. Each +/// reference goes to the newest copy: it has the values of every mode the library has now, where +/// an older one lacks the modes added since (a layer under a page set to such a mode fell back to +/// the default one). Kept per thread while a file is mapped: the paint and variable readers that +/// meet references are free functions. +#[derive(Default)] +pub(super) struct VarCanon { + by_key: HashMap, + by_id: HashMap, +} + +thread_local! { + static VAR_CANON: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// Removes the thread's table when the mapping is done. +pub(super) struct VarCanonGuard; + +impl Drop for VarCanonGuard { + fn drop(&mut self) { + VAR_CANON.with(|c| c.borrow_mut().take()); + } +} + +impl VarCanon { + fn install(m: &Mapper) -> VarCanonGuard { + let mut newest: HashMap = HashMap::new(); + let mut copies: Vec<(String, String)> = Vec::new(); + for (id, n) in &m.nodes { + if n.get("type").and_then(Kv::str) != Some("VARIABLE") || n.get("isSoftDeleted").and_then(Kv::bool) == Some(true) { + continue; + } + let Some(key) = n.get("key").and_then(Kv::str).filter(|k| !k.is_empty()) else { continue }; + let rank = version_rank(n.get("version").and_then(Kv::str).unwrap_or("")); + copies.push((id.clone(), key.to_string())); + let e = newest.entry(key.to_string()).or_insert_with(|| (rank, id.clone())); + if rank > e.0 || (rank == e.0 && *id > e.1) { + *e = (rank, id.clone()); + } + } + let mut t = VarCanon::default(); + for (id, key) in copies { + if let Some((_, canon)) = newest.get(&key) { + t.by_id.insert(id, canon.clone()); + } + } + t.by_key = newest.into_iter().map(|(k, (_, id))| (k, id)).collect(); + VAR_CANON.with(|c| *c.borrow_mut() = Some(t)); + VarCanonGuard + } + + /// The variable a reference (`{guid}` or `{assetRef}`) names, as its newest copy. + pub(super) fn resolve(r: &Kv) -> Option { + let by_guid = r.get("guid").and_then(guid).filter(|g| g != NO_STYLE); + VAR_CANON.with(|c| { + let c = c.borrow(); + let Some(t) = c.as_ref() else { return by_guid.clone() }; + if let Some(g) = &by_guid { + return Some(t.by_id.get(g).cloned().unwrap_or_else(|| g.clone())); + } + let key = r.get("assetRef")?.get("key").and_then(Kv::str)?; + t.by_key.get(key).cloned() + }) + } + + /// Whether variable node `id` is an older copy another one replaces. + pub(super) fn superseded(id: &str) -> bool { + VAR_CANON.with(|c| c.borrow().as_ref().and_then(|t| t.by_id.get(id)).is_some_and(|canon| canon != id)) + } +} diff --git a/tests.rs b/tests.rs new file mode 100644 index 000000000..0e2c5016e --- /dev/null +++ b/tests.rs @@ -0,0 +1,1588 @@ +//! `.fig` import tests on fixtures built in code: a small schema written in a compact text form, +//! a message encoded with our own kiwi encoder, compressed (deflate and zstd), framed and zipped. + +use std::io::Write; + +use figcraft_doc::{NodeKind, VariableType}; +use serde_json::Value; + +use super::kiwi::{Def, Field, Kind, Kv, Schema, Type, encode}; +use super::*; + +/// The fixture schema: `enum Name A B …`, `struct|message Name type field, type field[] …`. +/// Message field ids count from 1; enum values from 0. +const SCHEMA: &str = " +enum NodeType NONE DOCUMENT CANVAS GROUP FRAME BOOLEAN_OPERATION VECTOR STAR LINE ELLIPSE RECTANGLE REGULAR_POLYGON ROUNDED_RECTANGLE TEXT SLICE SYMBOL INSTANCE STICKY VARIABLE VARIABLE_SET SHAPE_WITH_TEXT CONNECTOR TABLE +enum PaintType SOLID GRADIENT_LINEAR IMAGE EMOJI +enum ImageScaleMode STRETCH FIT FILL TILE +enum WindingRule NONZERO ODD +enum StyleType NONE FILL TEXT +enum StackMode NONE HORIZONTAL VERTICAL +enum PropType BOOL TEXT +enum VariableField MISSING VISIBLE TEXT_DATA +enum DataType BOOLEAN FLOAT STRING ALIAS COLOR PROP_REF EXPRESSION +enum ResolvedType BOOLEAN FLOAT STRING COLOR +enum InteractionType ON_CLICK ON_HOVER +enum ConnectionType NONE INTERNAL_NODE URL BACK CLOSE SET_VARIABLE CONDITIONAL SET_VARIABLE_MODE +enum StrokeCap NONE ROUND SQUARE ARROW_LINES ARROW_EQUILATERAL +enum StrokeJoin MITER BEVEL ROUND +enum VectorMirror NONE ANGLE ANGLE_AND_LENGTH +enum ShapeWithTextType SQUARE ELLIPSE +enum ConnectorLineStyle ELBOWED STRAIGHT CURVED +enum ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQUALS +enum NavigationType NAVIGATE OVERLAY +enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE +enum EasingType IN_CUBIC OUT_CUBIC +enum ConstraintType MIN CENTER MAX STRETCH SCALE +enum LeadingTrim NONE CAP_HEIGHT +struct GUID uint sessionID, uint localID +struct Color float r, float g, float b, float a +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, uint dataBlob +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, AssetRef assetRef +message ConditionalActions Action[] actions, VariableData condition +message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable +message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition +message VariableData AnyValue value, DataType dataType +message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar, ImageScaleMode imageScaleMode +message ParentIndex GUID guid, string position +message FontName string family, string style, string postscript +message TextData string characters, uint[] characterStyleIDs, NodeChange[] styleOverrideTable +message Path WindingRule windingRule, uint commandsBlob +message GUIDPath GUID[] guids +message SymbolData GUID symbolID, NodeChange[] symbolOverrides +message StyleId GUID guid, AssetRef assetRef +message PropValue bool boolValue, TextData textValue +message PropDef GUID id, string name, PropValue initialValue, PropType type +message PropAssignment GUID defID, PropValue value +message MapEntry VariableData variableData, VariableField variableField +message VariableDataMap MapEntry[] entries +message SetMode GUID id, string name, string sortPosition +message ValuesEntry GUID modeID, VariableData variableData +message Values ValuesEntry[] entries +message ModeEntry VariableID variableSetID, GUID variableModeID +message ModeMap ModeEntry[] entries +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, string key, string version, LeadingTrim leadingTrim, float[] dashPattern +message Blob byte[] bytes +message Message NodeChange[] nodeChanges, Blob[] blobs +"; + +pub(super) fn schema() -> Schema { + Schema::parse(SCHEMA).unwrap() +} + +pub(super) fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} +pub(super) fn f(x: f64) -> Kv<'static> { + Kv::Float(x as f32) +} +pub(super) fn u(x: u64) -> Kv<'static> { + Kv::Uint(x) +} +pub(super) fn e(n: &'static str) -> Kv<'static> { + Kv::Enum(n) +} +pub(super) fn s(x: impl Into) -> Kv<'static> { + Kv::Str(x.into()) +} +pub(super) fn g(l: u64) -> Kv<'static> { + o(vec![("sessionID", u(1)), ("localID", u(l))]) +} +pub(super) fn arr(v: Vec>) -> Kv<'static> { + Kv::Array(v) +} +pub(super) fn xy(x: f64, y: f64) -> Kv<'static> { + o(vec![("x", f(x)), ("y", f(y))]) +} +pub(super) fn at(x: f64, y: f64) -> Kv<'static> { + o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(x)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(y))]) +} +fn rgb(r: f64, gr: f64, b: f64) -> Kv<'static> { + o(vec![("r", f(r)), ("g", f(gr)), ("b", f(b)), ("a", f(1.0))]) +} +pub(super) fn solid(r: f64, gr: f64, b: f64) -> Kv<'static> { + o(vec![("type", e("SOLID")), ("color", rgb(r, gr, b)), ("opacity", f(1.0)), ("visible", Kv::Bool(true))]) +} +fn root_guid() -> Kv<'static> { + o(vec![("sessionID", u(0)), ("localID", u(0))]) +} +fn alias_data(l: u64) -> Kv<'static> { + o(vec![("value", o(vec![("alias", o(vec![("guid", g(l))]))])), ("dataType", e("ALIAS"))]) +} +fn prop_ref(def: u64, field: &'static str) -> Kv<'static> { + let data = o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(def))]))])), ("dataType", e("PROP_REF"))]); + o(vec![("entries", arr(vec![o(vec![("variableData", data), ("variableField", e(field))])]))]) +} + +/// A node: id (0: the document), parent (id, position), type, name and further fields. +pub(super) fn node(id: u64, par: Option<(u64, &str)>, ty: &'static str, name: &str, mut more: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut v = vec![("guid", if id == 0 { root_guid() } else { g(id) })]; + if let Some((p, pos)) = par { + v.push(("parentIndex", o(vec![("guid", if p == 0 { root_guid() } else { g(p) }), ("position", s(pos))]))); + } + v.push(("type", e(ty))); + v.push(("name", s(name))); + v.append(&mut more); + o(v) +} + +/// A path blob: a closed triangle. +fn triangle_blob() -> Vec { + let mut b = Vec::new(); + for (cmd, pts) in [(1u8, vec![0.0f32, 0.0]), (2, vec![10.0, 0.0]), (2, vec![5.0, 8.0]), (0, vec![])] { + b.push(cmd); + for p in pts { + b.extend_from_slice(&p.to_le_bytes()); + } + } + b +} + +/// A 2×1 PNG. +pub(super) fn png() -> Vec { + let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +const HASH: [u8; 20] = [0xab; 20]; + +/// The fixture message: a page with a frame holding a rectangle (image fill, click interaction), +/// a two-style text, a group with a triangle vector and a star, a layer with a linked fill style, +/// one with a variable-bound fill, and an instance of a component with a boolean property +/// (property off, child renamed); a flow and an explicit variable mode; an unsupported sticky +/// note; styles and variables on the internal page. +fn message() -> Kv<'static> { + let modes = arr(vec![ + o(vec![("id", g(90)), ("name", s("Light")), ("sortPosition", s("!"))]), + o(vec![("id", g(91)), ("name", s("Dark")), ("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(1.0, 1.0, 1.0)))]), + o(vec![("modeID", g(91)), ("variableData", color_value(rgb(0.0, 0.0, 0.0)))]), + ]), + )]); + let click = o(vec![ + ("event", o(vec![("interactionType", e("ON_CLICK"))])), + ( + "actions", + arr(vec![o(vec![ + ("transitionNodeID", g(20)), + ("transitionType", e("SMART_ANIMATE")), + ("transitionDuration", f(0.25)), + ("easingType", e("OUT_CUBIC")), + ("connectionType", e("INTERNAL_NODE")), + ("navigationType", e("NAVIGATE")), + ])]), + ), + ]); + let font = |style: &str| o(vec![("family", s("Inter")), ("style", s(style)), ("postscript", s(""))]); + let text = o(vec![ + ("characters", s("Hi there")), + ("characterStyleIDs", arr(vec![u(0), u(0), u(0), u(5), u(5), u(5)])), + ("styleOverrideTable", arr(vec![o(vec![("fontName", font("Bold")), ("styleID", u(5))])])), + ]); + 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))]), + node(3, Some((2, "!")), "RECTANGLE", "Brand", vec![("styleType", e("FILL")), ("fillPaints", arr(vec![solid(0.0, 0.0, 1.0)]))]), + node(4, Some((2, "\"")), "VARIABLE_SET", "Theme", vec![("variableSetModes", modes)]), + node( + 5, + Some((2, "#")), + "VARIABLE", + "surface", + vec![("variableSetID", o(vec![("guid", g(4))])), ("variableResolvedType", e("COLOR")), ("variableDataValues", values)], + ), + // Listed before the frame, but its position puts it second. + node( + 20, + Some((1, "\"")), + "SYMBOL", + "Button", + vec![ + ("size", xy(100.0, 40.0)), + ("transform", at(500.0, 0.0)), + ("fillPaints", arr(vec![solid(0.0, 1.0, 0.0)])), + ( + "componentPropDefs", + arr(vec![o(vec![ + ("id", g(80)), + ("name", s("Show icon")), + ("initialValue", o(vec![("boolValue", Kv::Bool(true))])), + ("type", e("BOOL")), + ])]), + ), + ("stackMode", e("HORIZONTAL")), + ("stackSpacing", f(8.0)), + ("stackHorizontalPadding", f(12.0)), + ("stackVerticalPadding", f(4.0)), + ], + ), + node( + 21, + Some((20, "!")), + "ELLIPSE", + "Icon", + vec![("size", xy(16.0, 16.0)), ("transform", at(12.0, 12.0)), ("parameterConsumptionMap", prop_ref(80, "VISIBLE"))], + ), + node( + 10, + Some((1, "!")), + "FRAME", + "Screen", + vec![ + ("size", xy(400.0, 300.0)), + ("fillPaints", arr(vec![solid(1.0, 1.0, 1.0)])), + ("prototypeStartingPoint", o(vec![("name", s("Main flow")), ("position", s("!"))])), + ( + "variableModeBySetMap", + o(vec![("entries", arr(vec![o(vec![("variableSetID", o(vec![("guid", g(4))])), ("variableModeID", g(91))])]))]), + ), + ], + ), + node( + 11, + Some((10, "!")), + "ROUNDED_RECTANGLE", + "Photo", + vec![ + ("size", xy(50.0, 40.0)), + ("transform", at(10.0, 20.0)), + ("cornerRadius", f(6.0)), + ("fillPaints", arr(vec![o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))]))])])), + ("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])), + ("strokeWeight", f(2.0)), + ("prototypeInteractions", arr(vec![click])), + ], + ), + node( + 12, + Some((10, "\"")), + "TEXT", + "Title", + vec![ + ("size", xy(120.0, 20.0)), + ("transform", at(10.0, 80.0)), + ("fillPaints", arr(vec![solid(0.0, 0.0, 0.0)])), + ("fontSize", f(16.0)), + ("fontName", font("Regular")), + ("leadingTrim", e("CAP_HEIGHT")), + ("textData", text), + ], + ), + node(13, Some((10, "#")), "FRAME", "Group", vec![("size", xy(30.0, 30.0)), ("transform", at(100.0, 100.0)), ("resizeToFit", Kv::Bool(true))]), + node( + 14, + Some((13, "!")), + "VECTOR", + "Tri", + vec![ + ("size", xy(10.0, 8.0)), + ("transform", at(5.0, 6.0)), + ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])), + ("fillGeometry", arr(vec![o(vec![("windingRule", e("NONZERO")), ("commandsBlob", u(0))])])), + ], + ), + node( + 15, + Some((13, "\"")), + "STAR", + "Star", + vec![("size", xy(20.0, 20.0)), ("transform", at(10.0, 10.0)), ("count", u(6)), ("starInnerScale", f(0.5))], + ), + node( + 16, + Some((10, "$")), + "ROUNDED_RECTANGLE", + "Styled", + vec![ + ("size", xy(10.0, 10.0)), + ("transform", at(0.0, 200.0)), + // A stale copy of the style's paint: the style wins. + ("fillPaints", arr(vec![solid(0.5, 0.5, 0.5)])), + ("styleIdForFill", o(vec![("guid", g(3))])), + ], + ), + node( + 17, + Some((10, "%")), + "ROUNDED_RECTANGLE", + "Card", + vec![ + ("size", xy(10.0, 10.0)), + ("transform", at(20.0, 200.0)), + ("fillPaints", arr(vec![o(vec![("type", e("SOLID")), ("color", rgb(0.0, 0.0, 0.0)), ("colorVar", alias_data(5))])])), + ], + ), + node( + 18, + Some((10, "&")), + "INSTANCE", + "Button copy", + vec![ + ("size", xy(100.0, 40.0)), + ("transform", at(200.0, 200.0)), + ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])), + ("stackMode", e("HORIZONTAL")), + ( + "symbolData", + o(vec![ + ("symbolID", g(20)), + ("symbolOverrides", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(21)]))])), ("name", s("Renamed icon"))])])), + ]), + ), + ("componentPropAssignments", arr(vec![o(vec![("defID", g(80)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ], + ), + node(19, Some((10, "'")), "STICKY", "Note", vec![]), + ]; + o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![o(vec![("bytes", Kv::Bytes(triangle_blob()))])]))]) +} + +/// `canvas.fig`: magic, version, deflated schema, zstd message. +pub(super) fn canvas(schema: &Schema, msg: &Kv) -> Vec { + let root = schema.find("Message").unwrap(); + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(&encode::schema(schema)).unwrap(); + let schema_chunk = z.finish().unwrap(); + let msg_chunk = ruzstd::encoding::compress_to_vec(&encode::message(schema, root, msg).unwrap()[..], ruzstd::encoding::CompressionLevel::Fastest); + let mut out = b"fig-kiwi".to_vec(); + out.extend_from_slice(&101u32.to_le_bytes()); + for c in [schema_chunk, msg_chunk] { + out.extend_from_slice(&(c.len() as u32).to_le_bytes()); + out.extend_from_slice(&c); + } + out +} + +fn zip_file(canvas: &[u8]) -> Vec { + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let image_name = format!("images/{}", HASH.iter().map(|b| format!("{b:02x}")).collect::()); + for (name, bytes) in [("canvas.fig", canvas.to_vec()), ("meta.json", br#"{"file_name":"Fixture"}"#.to_vec()), (image_name.as_str(), png())] { + z.start_file(name, stored).unwrap(); + z.write_all(&bytes).unwrap(); + } + z.finish().unwrap().into_inner() +} + +fn fixture() -> Vec { + zip_file(&canvas(&schema(), &message())) +} + +fn named<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}")) +} + +/// The shared schema plus the grid sizing definitions only these tests use. +fn grid_schema() -> Schema { + const NODE_FIELDS: &str = ", GridTrackMap gridColumns, GridTrackMap gridRows, GridTrackSizeMap gridColumnsSizing, GridTrackSizeMap gridRowsSizing, GUID gridColumnAnchor, GUID gridRowAnchor, uint gridColumnSpan, uint gridRowSpan, float gridColumnGap, float gridRowGap"; + const TYPES: &str = " +enum GridTrackSizingType FLEX FIXED HUG UNKNOWN +message GridTrackSizeFunction GridTrackSizingType type, float value +message GridTrackSize GridTrackSizeFunction minSizing, GridTrackSizeFunction maxSizing +message GridTrackSizeEntry GUID id, GridTrackSize trackSize +message GridTrackSizeMap GridTrackSizeEntry[] entries +message GridTrackEntry GUID id, string position +message GridTrackMap GridTrackEntry[] entries +message Blob "; + let text = SCHEMA.replace("enum StackMode NONE HORIZONTAL VERTICAL\n", "enum StackMode NONE HORIZONTAL VERTICAL GRID\n").replacen( + "\nmessage Blob ", + &format!("{NODE_FIELDS}{TYPES}"), + 1, + ); + Schema::parse(&text).unwrap() +} + +fn track_size(kind: &'static str, value: f64) -> Kv<'static> { + let sizing = || o(vec![("type", e(kind)), ("value", f(value))]); + o(vec![("minSizing", sizing()), ("maxSizing", sizing())]) +} + +fn ranged_track_size(min: Kv<'static>, max: Kv<'static>) -> Kv<'static> { + o(vec![("minSizing", min), ("maxSizing", max)]) +} + +fn track_map_entry(id: u64, size: Kv<'static>) -> Kv<'static> { + o(vec![("id", g(id)), ("trackSize", size)]) +} + +fn positioned_track(id: u64, position: &'static str) -> Kv<'static> { + o(vec![("id", g(id)), ("position", s(position))]) +} + +fn unpositioned_track(id: u64) -> Kv<'static> { + o(vec![("id", g(id))]) +} + +#[test] +fn grid_tracks_import_by_guid_and_keep_track_order() { + let cols = arr(vec![positioned_track(13, "$"), unpositioned_track(10), positioned_track(12, "#"), unpositioned_track(11)]); + let rows = arr(vec![positioned_track(21, "\""), positioned_track(20, "!")]); + // Sizing entries intentionally differ from track order. Their GUIDs define the join. + let col_sizes = arr(vec![ + track_map_entry(12, track_size("FLEX", 1.0)), + track_map_entry(10, track_size("FIXED", 120.0)), + track_map_entry(13, track_size("FLEX", 2.0)), + track_map_entry(11, track_size("HUG", 80.0)), + ]); + let row_sizes = arr(vec![track_map_entry(21, track_size("HUG", 30.0)), track_map_entry(20, track_size("FIXED", 50.0))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node( + 2, + Some((1, "!")), + "FRAME", + "Grid", + vec![ + ("size", xy(600.0, 300.0)), + ("transform", at(0.0, 0.0)), + ("stackMode", e("GRID")), + ("gridColumns", o(vec![("entries", cols)])), + ("gridRows", o(vec![("entries", rows)])), + ("gridColumnsSizing", o(vec![("entries", col_sizes)])), + ("gridRowsSizing", o(vec![("entries", row_sizes)])), + ("gridColumnGap", f(10.0)), + ("gridRowGap", f(12.0)), + ], + ), + node( + 3, + Some((2, "!")), + "RECTANGLE", + "Hug child", + vec![ + ("size", xy(80.0, 30.0)), + ("transform", at(0.0, 0.0)), + ("gridColumnAnchor", g(11)), + ("gridRowAnchor", g(21)), + ("gridColumnSpan", u(2)), + ("gridRowSpan", u(1)), + ], + ), + ]; + let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); + let (doc, warnings) = import(&bytes, "Grid tracks").unwrap(); + let grid = named(&doc, "Grid").layout.as_ref().unwrap(); + assert_eq!((grid.mode, grid.grid_columns, grid.grid_rows, grid.gap, grid.cross_gap), (figcraft_doc::LayoutMode::Grid, 4, 2, 10.0, 12.0)); + assert_eq!( + grid.column_tracks, + vec![ + figcraft_doc::GridTrack::fixed(120.0), + figcraft_doc::GridTrack::hug(), + figcraft_doc::GridTrack::fill(1.0), + figcraft_doc::GridTrack::fill(2.0), + ] + ); + assert_eq!(grid.row_tracks, vec![figcraft_doc::GridTrack::fixed(50.0), figcraft_doc::GridTrack::hug()]); + let child = &named(&doc, "Hug child").child_layout; + assert_eq!((child.grid_column, child.grid_row, child.grid_column_span, child.grid_row_span), (Some(1), Some(1), 2, 1)); + assert!(!warnings.iter().any(|w| w.contains("grid track")), "{warnings:?}"); +} + +#[test] +fn malformed_grid_tracks_warn_and_keep_equal_track_fallbacks() { + let tracks = arr(vec![positioned_track(30, "!"), positioned_track(31, "\""), positioned_track(32, "#"), positioned_track(33, "$")]); + let sizing = arr(vec![ + track_map_entry(30, track_size("FIXED", -1.0)), + track_map_entry(31, ranged_track_size(o(vec![("type", e("FLEX")), ("value", f(1.0))]), o(vec![("type", e("FLEX")), ("value", f(2.0))]))), + track_map_entry(33, track_size("UNKNOWN", 5.0)), + track_map_entry(34, track_size("FLEX", 3.0)), + ]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node( + 2, + Some((1, "!")), + "FRAME", + "Grid", + vec![ + ("size", xy(100.0, 100.0)), + ("transform", at(0.0, 0.0)), + ("stackMode", e("GRID")), + ("gridColumns", o(vec![("entries", tracks)])), + ("gridColumnsSizing", o(vec![("entries", sizing)])), + ], + ), + ]; + let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); + let (doc, warnings) = import(&bytes, "Malformed grid tracks").unwrap(); + let layout = named(&doc, "Grid").layout.as_ref().unwrap(); + assert_eq!(layout.column_tracks, vec![figcraft_doc::GridTrack::default(); 4]); + assert!(warnings.iter().any(|w| w.contains("grid track sizing is unsupported or malformed")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("stale or invalid grid track sizing entries")), "{warnings:?}"); +} + +#[test] +fn oversized_grid_track_lists_warn_and_use_default_tracks() { + let oversized: Vec> = (0..1_001).map(|i| unpositioned_track(100 + i)).collect(); + let sizing = vec![track_map_entry(100, track_size("FIXED", 40.0))]; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node( + 2, + Some((1, "!")), + "FRAME", + "Grid", + vec![ + ("size", xy(100.0, 100.0)), + ("transform", at(0.0, 0.0)), + ("stackMode", e("GRID")), + ("gridColumns", o(vec![("entries", arr(oversized))])), + ("gridColumnsSizing", o(vec![("entries", arr(sizing))])), + ], + ), + ]; + let bytes = canvas(&grid_schema(), &o(vec![("nodeChanges", arr(nodes))])); + let (doc, warnings) = import(&bytes, "Oversized grid tracks").unwrap(); + let layout = named(&doc, "Grid").layout.as_ref().unwrap(); + assert!(layout.column_tracks.is_empty(), "{:?}", layout.column_tracks.len()); + assert!(warnings.iter().any(|w| w.contains("grid track list exceeds 1000 entries")), "{warnings:?}"); +} + +#[test] +fn kiwi_round_trip() { + let sc = schema(); + let back = Schema::decode(&encode::schema(&sc)).unwrap(); + assert_eq!(back.defs.len(), sc.defs.len()); + for (a, b) in sc.defs.iter().zip(&back.defs) { + assert_eq!((a.name.as_str(), a.kind), (b.name.as_str(), b.kind)); + let fields = |d: &Def| d.fields.iter().map(|f| (f.name.clone(), f.ty, f.array, f.value)).collect::>(); + assert_eq!(fields(a), fields(b)); + } + let msg = message(); + let root = sc.find("Message").unwrap(); + assert_eq!(back.decode_message(root, &encode::message(&sc, root, &msg).unwrap()).unwrap(), msg); +} + +#[test] +fn kiwi_scalars() { + let names = ["f", "i", "u", "i64", "u64", "s", "b"]; + let types = [Type::Float, Type::Int, Type::Uint, Type::Int64, Type::Uint64, Type::Str, Type::Bool]; + let fields = names + .iter() + .zip(types) + .enumerate() + .map(|(i, (n, t))| Field { name: n.to_string(), ty: Some(t), array: false, value: i as u32 + 1 }) + .collect(); + let sc = Schema { defs: vec![Def { name: "M".into(), kind: Kind::Message, fields }] }; + let cases = [ + (0.0f32, 0i32, 0u32, 0i64, 0u64), + (-1.5, -7, 300, i64::MIN, u64::MAX), + (3.25e7, i32::MAX, u32::MAX, i64::MAX, 1 << 60), + (1e-40, i32::MIN, 1, -1, 127), + ]; + for (fv, iv, uv, i64v, u64v) in cases { + // Written by hand: the generic encoder goes through f64, which can't hold every 64-bit value. + let mut w = encode::Writer::default(); + w.var_uint(1); + w.var_float(fv); + w.var_uint(2); + w.var_int(iv); + w.var_uint(3); + w.var_uint(uv); + w.var_uint(4); + w.var_int64(i64v); + w.var_uint(5); + w.var_uint64(u64v); + w.var_uint(6); + w.string("héllo"); + w.var_uint(7); + w.byte(1); + w.var_uint(0); + let got = sc.decode_message(0, &w.0).unwrap(); + // Denormals are stored as zero. + let want_f = if fv.abs() < f32::MIN_POSITIVE { 0.0 } else { fv }; + assert_eq!(got.get("f"), Some(&Kv::Float(want_f))); + assert_eq!(got.get("i"), Some(&Kv::Int(i64::from(iv)))); + assert_eq!(got.get("u"), Some(&Kv::Uint(u64::from(uv)))); + assert_eq!(got.get("i64"), Some(&Kv::Int(i64v))); + assert_eq!(got.get("u64"), Some(&Kv::Uint(u64v))); + assert_eq!(got.get("s"), Some(&s("héllo"))); + assert_eq!(got.get("b"), Some(&Kv::Bool(true))); + } +} + +#[test] +fn imports_the_fixture() { + let bytes = fixture(); + assert!(is_fig(&bytes)); + let (doc, warnings) = import(&bytes, "fallback").unwrap(); + assert_eq!(doc.name, "Fixture"); + assert_fixture_layers(&doc); + assert_fixture_styles(&doc); + assert_fixture_variables(&doc); + assert_fixture_components(&doc); + assert_fixture_prototype(&doc, &warnings); +} + +fn assert_fixture_layers(doc: &Document) { + // The internal page is skipped. + assert_eq!(doc.pages.len(), 1); + let page = doc.node(doc.pages[0]).unwrap(); + assert_eq!(page.name, "Page A"); + // Siblings by position: the frame comes first even though it's listed after the component. + assert_eq!(doc.node(page.children[0]).unwrap().name, "Screen"); + let screen = named(doc, "Screen"); + let names: Vec<&str> = screen.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy", "Note"]); + // Image fill from the archive. + let photo = named(doc, "Photo"); + let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") }; + assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1))); + assert_eq!((photo.corners.radii[0], photo.stroke.weight), (6.0, 2.0)); + // Text with a bold run over "the". + let NodeKind::Text(t) = &named(doc, "Title").kind else { panic!("not text") }; + assert_eq!(t.characters, "Hi there"); + assert_eq!(t.style.font_size, 16.0); + assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)"); + assert_eq!(t.runs.len(), 1); + assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold")); + // The group's children keep their place on the page (group at 100, 100). + assert!(matches!(named(doc, "Group").kind, NodeKind::Group)); + let tri = named(doc, "Tri"); + assert!(matches!(&tri.kind, NodeKind::Vector { network } if !network.regions.is_empty())); + let abs = doc.world_transform(tri.id).translation(); + assert!((abs.x - 105.0).abs() < 1e-6 && (abs.y - 106.0).abs() < 1e-6, "{abs:?}"); + assert!(matches!(named(doc, "Star").kind, NodeKind::Star { count: 6, .. })); +} + +fn assert_fixture_styles(doc: &Document) { + let styled = named(doc, "Styled"); + assert_eq!(styled.styles.fill.as_deref(), Some("1:3")); + assert!(doc.styles.iter().any(|s| s.name == "Brand")); + let figcraft_doc::color::PaintKind::Solid { color } = styled.fills[0].kind else { panic!("not solid") }; + assert_eq!((color.r, color.g, color.b), (0.0, 0.0, 1.0)); +} + +fn assert_fixture_variables(doc: &Document) { + let screen = named(doc, "Screen"); + assert_eq!(doc.variables.collections.len(), 1); + assert_eq!(doc.variables.variables[0].kind, VariableType::Color); + assert_eq!(named(doc, "Card").bound.get("fill").map(String::as_str), Some("1:5")); + assert_eq!(screen.var_modes.get("1:4").map(String::as_str), Some("1:91")); +} + +fn assert_fixture_components(doc: &Document) { + let button = named(doc, "Button"); + assert!(matches!(&button.kind, NodeKind::Component { props, .. } if props.contains_key("Show icon"))); + let inst = named(doc, "Button copy"); + assert!( + matches!(&inst.kind, NodeKind::Instance { component, props } if *component == button.id && props.get("Show icon") == Some(&Value::Bool(false))) + ); + let icon = doc.node(inst.children[0]).unwrap(); + assert_eq!(icon.name, "Renamed icon"); + assert!(!icon.visible); +} + +fn assert_fixture_prototype(doc: &Document, warnings: &[String]) { + let button = named(doc, "Button"); + let screen = named(doc, "Screen"); + let it = &named(doc, "Photo").interactions[0]; + assert!(matches!(it.actions[0], figcraft_doc::proto::Action::Navigate { dest, .. } if dest == button.id)); + assert_eq!(it.duration_ms, 250); + let Some(NodeKind::Page { flows, .. }) = doc.node(doc.pages[0]).map(|p| &p.kind) else { panic!("no page") }; + assert_eq!((flows[0].name.as_str(), flows[0].start), ("Main flow", screen.id)); + // The sticky note comes in as a frame, with a warning. + assert!(matches!(named(doc, "Note").kind, NodeKind::Frame)); + assert!(warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{warnings:?}"); +} + +#[test] +fn bare_canvas_and_zlib_chunks_open() { + let sc = schema(); + let c = canvas(&sc, &message()); + let (doc, w) = import(&c, "Bare").unwrap(); + assert_eq!(doc.name, "Bare"); + // Without the archive the image is a placeholder, reported. + assert!(w.iter().any(|w| w.contains("isn't in the file")), "{w:?}"); + // A zlib-wrapped schema chunk reads too. + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(&encode::schema(&sc)).unwrap(); + let zl = z.finish().unwrap(); + let parts = chunks(&c).unwrap(); + let mut out = c[..12].to_vec(); + for ch in [zl.as_slice(), parts[1]] { + out.extend_from_slice(&(ch.len() as u32).to_le_bytes()); + out.extend_from_slice(ch); + } + assert!(import(&out, "Z").is_ok()); +} + +/// The fixture message with the photo's image bytes in its own blobs, as copies written by plugins +/// and tools carry them: the PNG goes at the end of `blobs` and `image.dataBlob` points `shift` +/// places past it (0 is the PNG itself). +fn message_with_image_in_blobs(shift: u64) -> Kv<'static> { + fn point_images_at(value: &mut Kv<'static>, blob: u64) { + match value { + Kv::Object(fields) => { + for (name, field) in fields.iter_mut() { + match field { + Kv::Object(image) if *name == "image" => image.push(("dataBlob", u(blob))), + _ => point_images_at(field, blob), + } + } + } + Kv::Array(items) => items.iter_mut().for_each(|item| point_images_at(item, blob)), + _ => {} + } + } + let mut msg = message(); + let index = { + let Kv::Object(fields) = &mut msg else { panic!("the message is an object") }; + let Some((_, Kv::Array(blobs))) = fields.iter_mut().find(|(name, _)| *name == "blobs") else { panic!("no blobs") }; + blobs.push(o(vec![("bytes", Kv::Bytes(png()))])); + blobs.len() as u64 - 1 + }; + point_images_at(&mut msg, index + shift); + msg +} + +#[test] +fn a_bare_canvas_reads_images_carried_in_its_blobs() { + // The clipboard holds a bare canvas (no `images/`); the image comes from `image.dataBlob`. + let c = canvas(&schema(), &message_with_image_in_blobs(0)); + let (doc, warnings) = import(&c, "Pasted").unwrap(); + let photo = named(&doc, "Photo"); + let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") }; + assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1))); + assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + +#[test] +fn a_data_blob_past_the_blobs_stays_a_placeholder() { + let c = canvas(&schema(), &message_with_image_in_blobs(5)); + let (_, warnings) = import(&c, "Pasted").unwrap(); + assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + +/// A file can point any number of image hashes at one large blob; each used to get its own copy +/// of it (#1059). +#[test] +fn hashes_naming_one_blob_share_one_copy_of_it() { + let image = |hash: u8, blob: u64| o(vec![("image", o(vec![("hash", Kv::Bytes(vec![hash; 20])), ("dataBlob", u(blob))]))]); + let msg = o(vec![ + ("blobs", Kv::Array(vec![o(vec![("bytes", Kv::Bytes(png()))]), o(vec![("bytes", Kv::Bytes(vec![7; 16]))])])), + ("nodeChanges", Kv::Array((1..=50).map(|h| image(h, 0)).chain([image(99, 1), image(98, 2)]).collect())), + ]); + let copies = embedded_copies(&msg); + assert_eq!(copies.len(), 51, "the hash naming no blob is left out"); + let first = &copies[&"01".repeat(20)]; + assert_eq!(**first, *png()); + assert_eq!(copies.values().filter(|c| Arc::ptr_eq(c, first)).count(), 50); + assert_eq!(*copies[&"63".repeat(20)], [7; 16]); +} + +mod image_budget { + use super::*; + + fn image(hash: u8) -> Vec { + let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([hash, 0, 0, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + } + + fn file(references: &[u8], members: &[(u8, Vec)]) -> Vec { + let mut nodes = vec![node(0, None, "DOCUMENT", "Document", vec![]), node(1, Some((0, "!")), "CANVAS", "Page", vec![])]; + for (i, hash) in references.iter().enumerate() { + nodes.push(node( + i as u64 + 2, + Some((1, &format!("{i:04}"))), + "RECTANGLE", + &format!("Image {i}"), + vec![ + ("size", xy(10.0, 10.0)), + ("fillPaints", arr(vec![o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(vec![*hash; 20]))]))])])), + ], + )); + } + let canvas = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + z.start_file("canvas.fig", deflated).unwrap(); + z.write_all(&canvas).unwrap(); + for (hash, bytes) in members { + z.start_file(format!("images/{}", format!("{hash:02x}").repeat(20)), deflated).unwrap(); + z.write_all(bytes).unwrap(); + } + z.finish().unwrap().into_inner() + } + + fn bytes(doc: &Document, index: usize) -> &[u8] { + let node = named(doc, &format!("Image {index}")); + let figcraft_doc::color::PaintKind::Image(fill) = &node.fills[0].kind else { panic!("not an image fill") }; + &doc.images[&fill.image].bytes + } + + fn budget_warnings(warnings: &[String]) -> Vec<&str> { + warnings.iter().filter(|w| w.contains("total ZIP image byte budget")).map(String::as_str).collect() + } + + fn change_member_field(zip: &mut [u8], hash: u8, offset: usize, value: u32) { + let start = { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&*zip)).unwrap(); + z.by_name(&format!("images/{}", format!("{hash:02x}").repeat(20))).unwrap().central_header_start() as usize + }; + zip[start + offset..start + offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + #[test] + fn exact_total_limit_keeps_all_images_and_each_import_gets_its_own_budget() { + let members = [(1, image(1)), (2, image(2))]; + let limit = members.iter().map(|(_, b)| b.len() as u64).sum(); + let zip = file(&[1, 2], &members); + for _ in 0..2 { + let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap(); + assert_eq!(bytes(&doc, 0), members[0].1); + assert_eq!(bytes(&doc, 1), members[1].1); + assert!(warnings.is_empty(), "{warnings:?}"); + } + } + + #[test] + fn individually_acceptable_images_overflow_the_total_and_leave_placeholders() { + let members = [(1, image(1)), (2, image(2))]; + let limit = members.iter().map(|(_, b)| b.len() as u64).sum::() - 1; + assert!(members.iter().all(|(_, b)| b.len() as u64 <= limit)); + let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap(); + assert_eq!(bytes(&doc, 0), members[0].1); + assert_ne!(bytes(&doc, 1), members[1].1); + assert!(!doc.images.contains_key(&crate::image_hash(&members[1].1))); + let notes = budget_warnings(&warnings); + assert_eq!(notes.len(), 1, "{warnings:?}"); + assert!(notes[0].contains("1 ZIP image") && notes[0].contains(&limit.to_string()), "{warnings:?}"); + } + + #[test] + fn repeated_references_do_not_charge_or_count_a_rejection_twice() { + let members = [(1, image(1)), (2, image(2))]; + let (doc, warnings) = import_with_image_limit(&file(&[1, 1, 2, 2, 1], &members), "Images", members[0].1.len() as u64).unwrap(); + for i in [0, 1, 4] { + assert_eq!(bytes(&doc, i), members[0].1); + } + assert_eq!(bytes(&doc, 2), bytes(&doc, 3)); + assert_ne!(bytes(&doc, 2), members[1].1); + let notes = budget_warnings(&warnings); + assert_eq!(notes.len(), 1, "{warnings:?}"); + assert!(notes[0].contains("1 ZIP image"), "{warnings:?}"); + } + + #[test] + fn distinct_members_with_identical_contents_still_use_the_read_budget() { + let png = image(1); + let members = [(1, png.clone()), (2, png.clone())]; + let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap(); + assert_eq!(bytes(&doc, 0), png); + assert_ne!(bytes(&doc, 1), png); + assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); + } + + #[test] + fn rejecting_a_member_before_reading_leaves_room_for_a_smaller_one() { + let png = image(1); + let members = [(1, vec![0; png.len() + 1]), (2, png.clone())]; + let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap(); + assert_ne!(bytes(&doc, 0), members[0].1); + assert_eq!(bytes(&doc, 1), png); + assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); + } + + #[test] + fn failed_crc_reads_still_consume_the_bytes_read() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&[1, 2], &members); + change_member_field(&mut zip, 1, 16, 0); + let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64 + 1).unwrap(); + assert_ne!(bytes(&doc, 0), members[0].1); + assert_ne!(bytes(&doc, 1), members[1].1); + assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); + } + + #[test] + fn a_truncated_deflate_read_keeps_its_partial_charge() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&[1, 2], &members); + // Shorten the first member's compressed size, cutting its deflate stream mid-image. + change_member_field(&mut zip, 1, 20, 15); + let limit = members[0].1.len() as u64; + let archive = Archive::open(&zip).unwrap(); + archive.image_budget.remaining.set(limit); + assert!(archive.image(&"01".repeat(20)).is_none()); + let remaining = archive.image_budget.remaining.get(); + assert!(remaining > 0 && remaining < limit, "partial read left {remaining} of {limit} bytes"); + assert!(archive.image(&"02".repeat(20)).is_none()); + assert_eq!(archive.image_budget.remaining.get(), remaining); + let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap(); + assert_ne!(bytes(&doc, 1), members[1].1); + assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); + } + + #[test] + fn the_individual_image_cap_is_preserved_without_reading_a_large_member() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&[1, 2], &members); + change_member_field(&mut zip, 1, 24, (MAX_IMAGE + 1) as u32); + let (doc, warnings) = import_with_image_limit(&zip, "Images", members[1].1.len() as u64).unwrap(); + assert_ne!(bytes(&doc, 0), members[0].1); + assert_eq!(bytes(&doc, 1), members[1].1); + assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); + } + + #[test] + fn crc_is_checked_even_when_the_read_ends_exactly_at_the_budget() { + let members = [(1, image(1))]; + let mut zip = file(&[1], &members); + change_member_field(&mut zip, 1, 16, 0); + let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64).unwrap(); + assert_ne!(bytes(&doc, 0), members[0].1); + assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); + assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); + } + + #[test] + fn an_understated_size_cannot_read_past_the_remaining_budget() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&[1, 2], &members); + change_member_field(&mut zip, 1, 24, 1); + let archive = Archive::open(&zip).unwrap(); + archive.image_budget.remaining.set(members[0].1.len() as u64 - 1); + assert!(archive.image(&"01".repeat(20)).is_none()); + assert_eq!(archive.image_budget.remaining.get(), 0); + assert_eq!(archive.image_budget.skipped.get(), 1); + assert!(archive.image(&"02".repeat(20)).is_none()); + assert_eq!(archive.image_budget.remaining.get(), 0); + assert_eq!(archive.image_budget.skipped.get(), 2); + } + + #[test] + fn invalid_image_contents_are_charged_before_image_validation() { + let members = [(1, vec![0; 8]), (2, image(2))]; + let limit = members[1].1.len() as u64 + 7; + let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap(); + assert_ne!(bytes(&doc, 1), members[1].1); + assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); + } + + #[test] + fn missing_and_unreferenced_members_do_not_consume_the_budget() { + let members = [(1, image(1)), (2, vec![0; 128])]; + let (doc, warnings) = import_with_image_limit(&file(&[9, 1], &members), "Images", members[0].1.len() as u64).unwrap(); + assert_eq!(bytes(&doc, 1), members[0].1); + assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); + assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); + } + + #[test] + fn embedded_image_fallback_is_independent_of_the_zip_budget() { + let canvas = canvas(&schema(), &message_with_image_in_blobs(0)); + for bytes in [&canvas, &zip_file(&canvas)] { + let (doc, warnings) = import_with_image_limit(bytes, "Images", 0).unwrap(); + let photo = named(&doc, "Photo"); + let figcraft_doc::color::PaintKind::Image(fill) = &photo.fills[0].kind else { panic!("not an image fill") }; + assert_eq!(*doc.images[&fill.image].bytes, png()); + assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); + assert_eq!(budget_warnings(&warnings).len(), usize::from(bytes.starts_with(b"PK")), "{warnings:?}"); + } + } + + #[test] + fn default_import_preserves_image_bytes_dimensions_and_existing_warnings() { + let members = [(1, image(1)), (2, image(2))]; + let (doc, warnings) = import(&file(&[1, 2], &members), "Images").unwrap(); + assert_eq!(bytes(&doc, 0), members[0].1); + assert_eq!(bytes(&doc, 1), members[1].1); + assert!(doc.images.values().all(|img| (img.width, img.height) == (2, 1))); + assert!(warnings.is_empty(), "{warnings:?}"); + let (doc, warnings) = import(&fixture(), "Fixture").unwrap(); + assert!(doc.images.values().any(|img| *img.bytes == png() && (img.width, img.height) == (2, 1))); + assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); + } +} + +#[test] +fn other_files_are_not_fig() { + assert!(try_import(b"{\"document\": {}}", "x").is_none()); + assert!(try_import(b"", "x").is_none()); + let native = crate::save_bytes(&figcraft_doc::Document::new("N"), None).unwrap(); + assert!(try_import(&native, "x").is_none()); +} + +/// A cheap deterministic generator (mangled inputs, generated fixtures). +pub(super) struct Lcg(pub(super) u64); +impl Lcg { + pub(super) fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + self.0 >> 33 + } +} + +#[test] +fn mangled_files_never_panic() { + let bytes = fixture(); + let c = canvas(&schema(), &message()); + let mut r = Lcg(7); + for src in [&bytes, &c] { + for cut in (0..src.len()).step_by(37) { + let _ = import(&src[..cut], "x"); + } + for _ in 0..300 { + let mut m = src.clone(); + for _ in 0..1 + r.next() % 8 { + let i = (r.next() as usize) % m.len(); + m[i] ^= 1 << (r.next() % 8); + } + let _ = import(&m, "x"); + } + } +} + +#[test] +fn mangled_kiwi_never_panics() { + let sc = schema(); + let root = sc.find("Message").unwrap(); + let schema_bytes = encode::schema(&sc); + let msg = encode::message(&sc, root, &message()).unwrap(); + let mut r = Lcg(11); + for cut in 0..schema_bytes.len() { + let _ = Schema::decode(&schema_bytes[..cut]); + } + for cut in (0..msg.len()).step_by(3) { + if let Ok(kv) = sc.decode_message(root, &msg[..cut]) { + let _ = map::to_rest(&kv, "x", crate::rest::MAX_NODES); + } + } + for _ in 0..2000 { + let mut m = msg.clone(); + for _ in 0..1 + r.next() % 4 { + let i = (r.next() as usize) % m.len(); + m[i] = r.next() as u8; + } + if let Ok(kv) = sc.decode_message(root, &m) { + let (json, _, networks) = map::to_rest(&kv, "x", crate::rest::MAX_NODES); + let _ = crate::rest::import_with_networks(&json, &crate::rest::no_images, networks, crate::rest::MAX_NODES); + } + let mut sbytes = schema_bytes.clone(); + let i = (r.next() as usize) % sbytes.len(); + sbytes[i] = r.next() as u8; + if let Ok(s2) = Schema::decode(&sbytes) + && let Some(rt) = s2.find("Message") + { + let _ = s2.decode_message(rt, &msg); + } + } + // Pure junk, bare and framed. + for _ in 0..500 { + let junk: Vec = (0..(r.next() % 200)).map(|_| r.next() as u8).collect(); + let _ = Schema::decode(&junk); + let _ = sc.decode_message(root, &junk); + let mut framed = b"fig-kiwi\0\0\0\0".to_vec(); + framed.extend_from_slice(&junk); + let _ = import(&framed, "x"); + } +} + +#[test] +fn large_files_get_a_larger_value_budget() { + // Small files keep the fixed floor: a few bytes can't expand into a huge tree. + assert_eq!(kiwi::value_budget(0), 20_000_000); + assert_eq!(kiwi::value_budget(1 << 20), 20_000_000); + // A real 400 000-layer file: an 83.5 MB canvas whose message unpacks to 334 MB and holds + // 30.5 million values was refused as "too many values". + assert!(kiwi::value_budget(83_491_232) > 30_514_899); + // A real 194 MB design-system file (UX4G): a 39.5 MB canvas holding 29.7 million values. + assert!(kiwi::value_budget(39_528_420) > 29_703_730); + // Still bounded by the largest canvas read. + assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); + // Counting every scalar field, a dense file may reach about 2.4 values per compressed byte (the + // 15 MB text-heavy fixture in `value_budget_tests` measures 1.8): below the ceiling the budget must + // cover that, the floor still holds up to 5 MB of canvas, and 32 MiB is enough for the ceiling. + assert!(kiwi::value_budget(8 << 20) >= (8 << 20) * 12 / 5); + #[cfg(not(target_pointer_width = "32"))] + assert!(kiwi::value_budget(30_000_000) >= 30_000_000 * 12 / 5); + assert_eq!(kiwi::value_budget(5_000_000), 20_000_000); + assert_eq!(kiwi::value_budget(32 << 20), kiwi::value_budget(MAX_CANVAS as usize)); +} + +#[test] +fn the_layer_cap_follows_the_file_size() { + // A small (or broken) file keeps the REST import's cap: instances expand into a layer per + // layer of their component, and a few bytes mustn't claim millions of layers. + assert_eq!(layer_budget(0), crate::rest::MAX_NODES); + assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES); + // A real 894 004-layer file (an 83.5 MB canvas) stopped at 200 000 and came in with 27 of + // its 30 pages empty. + assert!(layer_budget(83_491_232) > 894_004); + // No file gets more than the ceiling. + assert_eq!(layer_budget(MAX_CANVAS as usize), MAX_FIG_NODES); +} + +#[test] +fn a_small_file_that_unpacks_large_gets_no_larger_budgets() { + // 40 MB of zeros in a blob pack into a few KB. Keyed to the unpacked message, they lifted the + // layer cap above the 231 231 layers its instances expand into; keyed to the file, the cap + // stays at the REST import's. + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.push(node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(10.0, 10.0))])); + nodes.extend((10..1010).map(|i| node(i, Some((5, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + nodes.extend((2000..2230).map(|i| node(i, Some((1, "!")), "INSTANCE", "I", vec![("symbolData", o(vec![("symbolID", g(5))]))]))); + let blob = o(vec![("bytes", Kv::Bytes(vec![0; 40 << 20]))]); + let file = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![blob]))])); + assert!(file.len() < 1 << 20, "{} bytes", file.len()); + let (doc, warnings) = import(&file, "x").unwrap(); + assert!(warnings.iter().any(|w| w.contains("more than 200000 layers")), "{warnings:?}"); + assert!(doc.node_count() <= crate::rest::MAX_NODES + 10); +} + +#[test] +fn the_mapper_stops_at_the_layer_cap_it_is_given() { + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.extend((2..12).map(|i| node(i, Some((1, "!")), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + let msg = o(vec![("nodeChanges", arr(nodes))]); + let (_, warnings, _) = map::to_rest(&msg, "x", 5); + assert!(warnings.iter().any(|w| w.contains("more than 5 layers")), "{warnings:?}"); + let (_, warnings, _) = map::to_rest(&msg, "x", crate::rest::MAX_NODES); + assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); +} + +#[test] +fn scalar_fields_count_against_the_value_budget() { + // A message of 1000 scalar fields (id 1, `true`) is 1001 values: a budget of 10 refuses it. + let fields = vec![Field { name: "b".into(), ty: Some(Type::Bool), array: false, value: 1 }]; + let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] }; + let mut data = [1u8, 1].repeat(1000); + data.push(0); + let e = sc.decode_message_within(0, &data, 10).unwrap_err(); + assert_eq!(e.0, "message: too many values"); + assert!(sc.decode_message_within(0, &data, 1001).is_ok()); +} + +#[test] +fn hostile_input_is_refused() { + // A message that contains itself, nested past the depth cap. + let field = |ty, array| vec![Field { name: "m".into(), ty: Some(ty), array, value: 1 }]; + let nested = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields: field(Type::Def(0), false) }] }; + assert!(nested.decode_message(0, &[1u8; 10_000]).is_err()); + // An array count larger than the data. + let array = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields: field(Type::Uint, true) }] }; + assert!(array.decode_message(0, &[1, 0xff, 0xff, 0xff, 0xff, 0x0f]).is_err()); + // A field type naming a missing definition. + let mut w = encode::Writer::default(); + w.var_uint(1); + w.string("Message"); + w.byte(2); + w.var_uint(1); + w.string("x"); + w.var_int(5); + w.byte(0); + w.var_uint(1); + assert!(Schema::decode(&w.0).is_err()); + // A schema without a Message definition. + let c = canvas(&Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields: vec![] }] }, &Kv::Object(vec![])); + let renamed: Vec = { + let sc = Schema { defs: vec![Def { name: "Other".into(), kind: Kind::Message, fields: vec![] }] }; + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(&encode::schema(&sc)).unwrap(); + let sch = z.finish().unwrap(); + let mut out = c[..12].to_vec(); + out.extend_from_slice(&(sch.len() as u32).to_le_bytes()); + out.extend_from_slice(&sch); + out.extend_from_slice(&c[12 + 4 + chunks(&c).unwrap()[0].len()..]); + out + }; + assert!(import(&c, "x").is_ok()); + assert!(import(&renamed, "x").is_err()); +} + +#[test] +fn instances_inside_their_own_main_stop() { + let sym = node(5, Some((1, "!")), "SYMBOL", "Loop", vec![("size", xy(10.0, 10.0))]); + let inner = node(6, Some((5, "!")), "INSTANCE", "Self", vec![("symbolData", o(vec![("symbolID", g(5))]))]); + let doc_nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![]), sym, inner]; + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(doc_nodes))])), "x").unwrap(); + 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))]; + let layer = |id, pos, ty, name, w, h, x, y, c: Vec<(&'static str, Kv<'static>)>| { + node(id, Some((5, pos)), ty, name, [vec![("size", xy(w, h)), ("transform", at(x, y))], c].concat()) + }; + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(5, Some((1, "!")), "SYMBOL", "Logo", vec![("size", xy(132.0, 22.0)), ("transform", at(0.0, 0.0))]), + layer(6, "!", "ROUNDED_RECTANGLE", "Mark", 132.0, 22.0, 0.0, 0.0, pin("SCALE", "SCALE")), + layer(7, "\"", "ROUNDED_RECTANGLE", "Badge", 32.0, 10.0, 100.0, 12.0, pin("MAX", "MAX")), + layer(8, "#", "ROUNDED_RECTANGLE", "Bar", 112.0, 2.0, 10.0, 0.0, pin("STRETCH", "MIN")), + // A group scales its layers with it. + layer(11, "$", "FRAME", "Wordmark", 66.0, 22.0, 66.0, 0.0, [pin("SCALE", "SCALE"), vec![("resizeToFit", Kv::Bool(true))]].concat()), + node(12, Some((11, "!")), "ROUNDED_RECTANGLE", "Letter", vec![("size", xy(66.0, 22.0)), ("transform", at(0.0, 0.0))]), + node( + 9, + Some((1, "\"")), + "INSTANCE", + "Small logo", + vec![("size", xy(90.0, 15.0)), ("transform", at(0.0, 100.0)), ("symbolData", o(vec![("symbolID", g(5))]))], + ), + // Derived layout the file gives wins over the constraints. + node( + 10, + Some((1, "#")), + "INSTANCE", + "Derived logo", + vec![ + ("size", xy(90.0, 15.0)), + ("transform", at(0.0, 200.0)), + ("symbolData", o(vec![("symbolID", g(5))])), + ("derivedSymbolData", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("size", xy(50.0, 5.0))])])), + ], + ), + // A size the instance overrides: the place still follows the constraints. + node( + 13, + Some((1, "$")), + "INSTANCE", + "Resized badge", + vec![ + ("size", xy(90.0, 15.0)), + ("transform", at(0.0, 300.0)), + ( + "symbolData", + o(vec![ + ("symbolID", g(5)), + ("symbolOverrides", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(7)]))])), ("size", xy(20.0, 10.0))])])), + ]), + ), + ], + ), + ]; + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + let box_of = |inst: &str, name: &str| { + let i = named(&doc, inst); + let n = i.children.iter().map(|c| doc.node(*c).unwrap()).find(|n| n.name == name).unwrap(); + let t = n.transform.translation(); + [t.x, t.y, n.width, n.height].map(|x| (x * 1e3).round() / 1e3) + }; + assert_eq!(box_of("Small logo", "Mark"), [0.0, 0.0, 90.0, 15.0]); + assert_eq!(box_of("Small logo", "Badge"), [58.0, 5.0, 32.0, 10.0]); + assert_eq!(box_of("Small logo", "Bar"), [10.0, 0.0, 70.0, 2.0]); + // The main's own, then the instances'. + let mut letters: Vec<[f64; 2]> = + doc.nodes().filter(|n| n.name == "Letter").map(|n| [n.width, n.height].map(|x| (x * 1e3).round() / 1e3)).collect(); + letters.sort_by(|a, b| a[0].total_cmp(&b[0])); + assert_eq!(letters, [[45.0, 15.0], [45.0, 15.0], [45.0, 15.0], [66.0, 22.0]]); + assert_eq!(box_of("Derived logo", "Mark")[2..], [50.0, 5.0]); + assert_eq!(box_of("Resized badge", "Badge"), [58.0, 5.0, 20.0, 10.0]); +} + +#[test] +fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { + let stretch = || vec![("horizontalConstraint", e("STRETCH")), ("verticalConstraint", e("MIN"))]; + let nodes = vec![ + node(0, None, "DOCUMENT", "D", vec![]), + node(1, Some((0, "!")), "CANVAS", "P", vec![]), + node(20, Some((1, "!")), "SYMBOL", "Track", vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))]), + node(21, Some((20, "!")), "ROUNDED_RECTANGLE", "Fill", [vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))], stretch()].concat()), + node(30, Some((1, "\"")), "SYMBOL", "Slider", vec![("size", xy(200.0, 50.0)), ("transform", at(0.0, 100.0))]), + // The nested instance is 150 wide in its main, with the layout derived for that. + node( + 31, + Some((30, "!")), + "INSTANCE", + "Inner", + [ + vec![ + ("size", xy(150.0, 10.0)), + ("transform", at(0.0, 0.0)), + ("symbolData", o(vec![("symbolID", g(20))])), + ( + "derivedSymbolData", + arr(vec![o(vec![ + ("guidPath", o(vec![("guids", arr(vec![g(21)]))])), + ("size", xy(150.0, 10.0)), + ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])), + ])]), + ), + ], + stretch(), + ] + .concat(), + ), + node( + 40, + Some((1, "#")), + "INSTANCE", + "Wide slider", + vec![("size", xy(300.0, 50.0)), ("transform", at(0.0, 200.0)), ("symbolData", o(vec![("symbolID", g(30))]))], + ), + ]; + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + let width = |outer: &str| { + let inner = doc.node(named(&doc, outer).children[0]).unwrap(); + (inner.width, doc.node(inner.children[0]).unwrap().width) + }; + // In its main the derived layout holds; stretched to 250 it no longer does. + assert_eq!(width("Slider"), (150.0, 150.0)); + assert_eq!(width("Wide slider"), (250.0, 250.0)); + // What else the derived data gives (the colour a variable resolves to, say) still holds. + let fill = doc.node(doc.node(named(&doc, "Wide slider").children[0]).unwrap().children[0]).unwrap(); + assert!(matches!(fill.fills.first().map(|p| &p.kind), Some(figcraft_doc::color::PaintKind::Solid { color }) if color.r == 1.0 && color.g == 0.0)); +} + +#[path = "tail_tests.rs"] +mod tail; + +#[path = "image_filter_tests.rs"] +mod image_filters; + +#[path = "text_list_tests.rs"] +mod text_list; + +/// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset). +pub(super) fn samples() -> Vec { + let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() }; + let dir = std::fs::read_dir(dir).into_iter().flatten().flatten(); + dir.map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "fig")).collect() +} + +/// Local only (`FIGCRAFT_FIG_SAMPLES`): every file imports without warnings. +#[test] +fn local_samples_import() { + for p in samples() { + let bytes = std::fs::read(&p).unwrap(); + let (doc, warnings) = import(&bytes, "sample").unwrap_or_else(|e| panic!("{}: {e}", p.display())); + assert!(warnings.is_empty(), "{}: {warnings:?}", p.display()); + assert!(doc.node_count() > 5, "{}", p.display()); + } +} + +/// Local only: dumps each sample's schema, decoded message and REST JSON when `FIG_DUMP` names a +/// folder (for working on the importer). +#[test] +fn dump_samples() { + let Ok(out) = std::env::var("FIG_DUMP") else { return }; + for p in samples() { + let bytes = std::fs::read(&p).unwrap(); + let a = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&a.canvas).unwrap(); + let stem = p.file_stem().unwrap().to_string_lossy().to_string(); + let mut txt = String::new(); + for d in &schema.defs { + txt += &format!("{:?} {}\n", d.kind, d.name); + for f in &d.fields { + let ty = match f.ty { + Some(Type::Def(i)) => schema.defs[i].name.clone(), + Some(t) => format!("{t:?}"), + None => String::new(), + }; + txt += &format!(" {} {}{} = {}\n", f.name, ty, if f.array { "[]" } else { "" }, f.value); + } + } + std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap(); + let (rest, ..) = map::to_rest(&msg, &stem, super::layer_budget(message.len())); + std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap(); + } +} + +#[path = "board_import_tests.rs"] +mod board; + +/// A file that took several updates of a library holds a copy of its collection and of each of +/// its variables per version it met (same asset key, another version). Layers and modes name any +/// of them by asset, never by guid: they all become the newest copy, which has every mode — a page +/// set to a mode only the newer copies have keeps it, and fills bound to an older copy of a +/// variable follow its newest values. +#[test] +fn library_copies_of_several_versions_become_the_newest() { + let mode = |id, name: &'static str, pos: &'static str| o(vec![("id", g(id)), ("name", s(name)), ("sortPosition", s(pos))]); + let by_asset = |key: &'static str, version: &'static str| o(vec![("assetRef", o(vec![("key", s(key)), ("version", s(version))]))]); + let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]); + let values = |pairs: Vec<(u64, Kv<'static>)>| { + o(vec![("entries", arr(pairs.into_iter().map(|(m, c)| o(vec![("modeID", g(m)), ("variableData", color_value(c))])).collect()))]) + }; + let asset_alias = + |key: &'static str, version: &'static str| o(vec![("value", o(vec![("alias", by_asset(key, version))])), ("dataType", e("ALIAS"))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + // The page is set to "Purple", a mode the oldest copy of the collection didn't have yet. + node( + 1, + Some((0, "!")), + "CANVAS", + "Page", + vec![( + "variableModeBySetMap", + o(vec![("entries", arr(vec![o(vec![("variableSetID", by_asset("products", "10:1")), ("variableModeID", g(92))])]))]), + )], + ), + node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + node( + 4, + Some((2, "!")), + "VARIABLE_SET", + "Products", + vec![("variableSetModes", arr(vec![mode(90, "Blue", "!")])), ("key", s("products")), ("version", s("10:1"))], + ), + node( + 6, + Some((2, "#")), + "VARIABLE_SET", + "Products", + vec![("variableSetModes", arr(vec![mode(90, "Blue", "!"), mode(92, "Purple", "\"")])), ("key", s("products")), ("version", s("20:1"))], + ), + node( + 5, + Some((2, "\"")), + "VARIABLE", + "button-bg", + vec![ + ("variableSetID", by_asset("products", "10:1")), + ("variableResolvedType", e("COLOR")), + ("variableDataValues", values(vec![(90, rgb(0.0, 0.0, 1.0))])), + ("key", s("button-bg")), + ("version", s("10:1")), + ], + ), + node( + 7, + Some((2, "$")), + "VARIABLE", + "button-bg", + vec![ + ("variableSetID", by_asset("products", "20:1")), + ("variableResolvedType", e("COLOR")), + ("variableDataValues", values(vec![(90, rgb(0.0, 0.0, 1.0)), (92, rgb(0.5, 0.0, 0.5))])), + ("key", s("button-bg")), + ("version", s("20:1")), + ], + ), + // The button names the older copy of the variable. + node( + 10, + Some((1, "!")), + "FRAME", + "Button", + vec![ + ("size", xy(100.0, 40.0)), + ( + "fillPaints", + arr(vec![o(vec![("type", e("SOLID")), ("color", rgb(0.0, 0.0, 1.0)), ("colorVar", asset_alias("button-bg", "10:1"))])]), + ), + ], + ), + ]; + let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert_eq!(doc.variables.collections.len(), 1, "{:?}", doc.variables.collections); + let products = &doc.variables.collections[0]; + assert_eq!(products.modes.iter().map(|m| m.name.as_str()).collect::>(), ["Blue", "Purple"]); + assert_eq!(doc.variables.variables.len(), 1); + let var = &doc.variables.variables[0]; + assert_eq!(var.collection, products.id); + assert_eq!(var.values.len(), 2, "the newest copy's values: {:?}", var.values); + let page = doc.nodes().find(|n| n.name == "Page").unwrap(); + assert_eq!(page.var_modes.get(&products.id).map(String::as_str), Some("1:92")); + let button = named(&doc, "Button"); + assert_eq!(button.bound.values().next(), Some(&var.id), "{:?}", button.bound); + assert!(!warnings.iter().any(|w| w.contains("variable modes of collections")), "{warnings:?}"); +}