From dfe703deb26857bd8b930bcf992d079ef22142b9 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 13:46:24 +0330 Subject: [PATCH 01/10] Add files via upload --- map.rs | 490 +++++++++++++++++ tests.rs | 1588 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 2078 insertions(+) create mode 100644 map.rs create mode 100644 tests.rs 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:?}"); +} From a5ebc01d2c912f6308e5f10040880b816aac8445 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 13:50:48 +0330 Subject: [PATCH 02/10] Add files via upload --- crates/format/src/fig/map/document.rs | 59 ++++++++++++++++++-------- crates/format/src/fig/map/variables.rs | 4 +- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/crates/format/src/fig/map/document.rs b/crates/format/src/fig/map/document.rs index 69f221c1a..6868de485 100644 --- a/crates/format/src/fig/map/document.rs +++ b/crates/format/src/fig/map/document.rs @@ -259,28 +259,37 @@ impl<'a> Mapper<'a> { }; let mut order = Vec::new(); let mut collections = Obj::new(); + // Copies of one library collection (one per version the file met) become one: the newest + // keeps its modes in its order, the others add the modes it no longer has. + let mut extra: BTreeMap> = BTreeMap::new(); for (id, n) in of_type("VARIABLE_SET", "parentIndex") { - order.push(json!(id)); - let mut modes: Vec<(&str, Value)> = n - .get("variableSetModes") - .map(Kv::list) - .unwrap_or_default() - .iter() - .filter_map(|m| { - let mode = json!({"modeId": m.get("id").and_then(guid)?, "name": m.get("name").and_then(Kv::str).unwrap_or("Mode")}); - Some((m.get("sortPosition").and_then(Kv::str).unwrap_or(""), mode)) - }) - .collect(); - modes.sort_by(|a, b| a.0.cmp(b.0)); - let default = modes.first().map_or(Value::Null, |m| m.1["modeId"].clone()); - let name = n.get("name").and_then(Kv::str).unwrap_or("Collection"); - let modes: Vec = modes.into_iter().map(|m| m.1).collect(); - collections.insert(id.clone(), json!({"id": id, "name": name, "modes": modes, "defaultModeId": default})); + let modes = sorted_modes(n); + match self.canonical_collection(id).filter(|c| c != id) { + Some(canonical) => extra.entry(canonical).or_default().extend(modes), + None => { + order.push(json!(id)); + let default = modes.first().map_or(Value::Null, |m| m.1["modeId"].clone()); + let name = n.get("name").and_then(Kv::str).unwrap_or("Collection"); + let modes: Vec = modes.into_iter().map(|m| m.1).collect(); + collections.insert(id.clone(), json!({"id": id, "name": name, "modes": modes, "defaultModeId": default})); + } + } + } + for (canonical, more) in extra { + let Some(modes) = collections.get_mut(&canonical).and_then(|c| c.get_mut("modes")).and_then(Value::as_array_mut) else { continue }; + for (_, m) in more { + if !modes.iter().any(|x| x["modeId"] == m["modeId"]) { + modes.push(m); + } + } } 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(|s| self.resource_id(s)).filter(|s| collections.contains_key(s)) else { continue }; + if super::VarCanon::superseded(id) { + continue; + } + let Some(set) = n.get("variableSetID").and_then(|s| self.collection_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; @@ -306,3 +315,19 @@ impl<'a> Mapper<'a> { .then(|| json!({"variableCollections": collections, "variables": variables, "collectionOrder": order, "variableOrder": variable_order})) } } + +/// A collection node's modes in their order (`sortPosition`), as `(position, {modeId, name})`. +fn sorted_modes(n: &Kv) -> Vec<(String, Value)> { + let mut modes: Vec<(String, Value)> = n + .get("variableSetModes") + .map(Kv::list) + .unwrap_or_default() + .iter() + .filter_map(|m| { + let mode = json!({"modeId": m.get("id").and_then(guid)?, "name": m.get("name").and_then(Kv::str).unwrap_or("Mode")}); + Some((m.get("sortPosition").and_then(Kv::str).unwrap_or("").to_string(), mode)) + }) + .collect(); + modes.sort_by(|a, b| a.0.cmp(&b.0)); + modes +} diff --git a/crates/format/src/fig/map/variables.rs b/crates/format/src/fig/map/variables.rs index 8d891ded7..12b433fc0 100644 --- a/crates/format/src/fig/map/variables.rs +++ b/crates/format/src/fig/map/variables.rs @@ -49,7 +49,7 @@ impl<'a> Mapper<'a> { let mut lost = false; for e in v.get("variableModeBySetMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() { let (Some(set), Some(mode)) = (e.get("variableSetID"), e.get("variableModeID").and_then(guid)) else { continue }; - match self.resource_id(set) { + match self.collection_id(set) { Some(id) => { modes.insert(id, json!(mode)); } @@ -143,7 +143,7 @@ pub(super) fn alias(d: Option<&Kv>) -> Option { if d.get("dataType").and_then(Kv::str) != Some("ALIAS") { return None; } - d.get("value").and_then(|v| v.get("alias")).and_then(ref_guid) + d.get("value").and_then(|v| v.get("alias")).and_then(super::VarCanon::resolve) } pub(super) fn variable_value(d: &Kv) -> Option { From 69692323267133138377d14e03fbc7b142fb0638 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 13:53:11 +0330 Subject: [PATCH 03/10] Add files via upload --- crates/format/src/fig/map.rs | 104 +++++++++++++++++++++++++++++++++ crates/format/src/fig/tests.rs | 99 +++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 3d410e07d..98239cd7b 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -48,6 +48,7 @@ const MAX_ASSET_PART: usize = 256; /// 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. @@ -365,6 +366,30 @@ impl<'a> Mapper<'a> { .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 @@ -384,3 +409,82 @@ impl<'a> Mapper<'a> { 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/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 657f2f81d..0e2c5016e 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -1487,3 +1487,102 @@ fn dump_samples() { #[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:?}"); +} From f282a30164b642346e265fb412030d0ca66b0f69 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 13:54:49 +0330 Subject: [PATCH 04/10] Delete map.rs --- map.rs | 490 --------------------------------------------------------- 1 file changed, 490 deletions(-) delete mode 100644 map.rs diff --git a/map.rs b/map.rs deleted file mode 100644 index 98239cd7b..000000000 --- a/map.rs +++ /dev/null @@ -1,490 +0,0 @@ -//! 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)) - } -} From 08537cf425c201be99db433e9d5f2e909de87c8d Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 13:55:16 +0330 Subject: [PATCH 05/10] Delete tests.rs --- tests.rs | 1588 ------------------------------------------------------ 1 file changed, 1588 deletions(-) delete mode 100644 tests.rs diff --git a/tests.rs b/tests.rs deleted file mode 100644 index 0e2c5016e..000000000 --- a/tests.rs +++ /dev/null @@ -1,1588 +0,0 @@ -//! `.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:?}"); -} From 5d4a5082ec8188f03ea6107184c771fa0dc7e7c0 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 14:54:48 +0330 Subject: [PATCH 06/10] Add files via upload --- crates/format/src/fig/map.rs | 16 +++++++++---- crates/format/src/fig/tests.rs | 42 +++++++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 98239cd7b..e24a93b2d 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -427,6 +427,8 @@ fn version_rank(v: &str) -> (u64, u64) { pub(super) struct VarCanon { by_key: HashMap, by_id: HashMap, + /// Every live variable node of the file, with a key or not. + local: BTreeSet, } thread_local! { @@ -446,10 +448,12 @@ impl VarCanon { fn install(m: &Mapper) -> VarCanonGuard { let mut newest: HashMap = HashMap::new(); let mut copies: Vec<(String, String)> = Vec::new(); + let mut local = BTreeSet::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; } + local.insert(id.clone()); 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())); @@ -465,21 +469,25 @@ impl VarCanon { } } t.by_key = newest.into_iter().map(|(k, (_, id))| (k, id)).collect(); + t.local = local; VAR_CANON.with(|c| *c.borrow_mut() = Some(t)); VarCanonGuard } - /// The variable a reference (`{guid}` or `{assetRef}`) names, as its newest copy. + /// The variable a reference (`{guid}` or `{assetRef}`) names, as its newest copy. A guid + /// counts when this file holds a variable by it; one from another file (a paste carries the + /// library's own guids) names nothing here, so the asset it also names is tried before it is + /// kept as it is. 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 { + if let Some(g) = by_guid.as_ref().filter(|g| t.local.contains(*g)) { 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() + let by_asset = r.get("assetRef").and_then(|a| a.get("key")).and_then(Kv::str).and_then(|k| t.by_key.get(k)).cloned(); + by_asset.or(by_guid) }) } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 0e2c5016e..d0cea1932 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -1522,7 +1522,8 @@ fn library_copies_of_several_versions_become_the_newest() { Some((2, "!")), "VARIABLE_SET", "Products", - vec![("variableSetModes", arr(vec![mode(90, "Blue", "!")])), ("key", s("products")), ("version", s("10:1"))], + // "Legacy" was removed from the library since: only this older copy has it. + vec![("variableSetModes", arr(vec![mode(90, "Blue", "!"), mode(93, "Legacy", "\"")])), ("key", s("products")), ("version", s("10:1"))], ), node( 6, @@ -1557,6 +1558,37 @@ fn library_copies_of_several_versions_become_the_newest() { ("version", s("20:1")), ], ), + // A pasted layer names the variable by the library's own guid (not a node of this file) + // and by its asset: the asset wins. + node( + 11, + Some((1, "\"")), + "FRAME", + "Pasted 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", + o(vec![ + ( + "value", + o(vec![( + "alias", + o(vec![("guid", g(777)), ("assetRef", o(vec![("key", s("button-bg")), ("version", s("20:1"))]))]), + )]), + ), + ("dataType", e("ALIAS")), + ]), + ), + ])]), + ), + ], + ), // The button names the older copy of the variable. node( 10, @@ -1575,14 +1607,18 @@ fn library_copies_of_several_versions_become_the_newest() { 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!(products.modes.iter().map(|m| m.name.as_str()).collect::>(), ["Blue", "Purple", "Legacy"]); 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); + // The newest copy's values: Purple included (a mode only it has), Legacy filled from the default. + assert_eq!(var.values.len(), 3, "{:?}", var.values); + assert!(matches!(var.values.get("1:92"), Some(figcraft_doc::VariableValue::Color(c)) if (c.r - 0.5).abs() < 1e-6), "{:?}", 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); + let pasted = named(&doc, "Pasted button"); + assert_eq!(pasted.bound.values().next(), Some(&var.id), "{:?}", pasted.bound); assert!(!warnings.iter().any(|w| w.contains("variable modes of collections")), "{warnings:?}"); } From c411152b877508613bee62f332c91f77c0a480b0 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 15:34:41 +0330 Subject: [PATCH 07/10] Add files via upload --- crates/format/src/fig/map.rs | 45 +++++++++++++++++++++------- crates/format/src/fig/tests.rs | 55 +++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index e24a93b2d..97e758e7f 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -372,22 +372,23 @@ impl<'a> Mapper<'a> { /// 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. + /// default mode of the newest copy. A reference by asset that names no copy by itself (no + /// version, or a version the file doesn't hold, while it holds several) still names the asset: + /// its newest live copy. pub(super) fn collection_id(&self, v: &Kv) -> Option { - let id = self.resource_id(v)?; - Some(self.canonical_collection(&id).unwrap_or(id)) + let by_asset = || v.get("assetRef")?.get("key").and_then(Kv::str).and_then(VarCanon::newest_set); + match self.resource_id(v) { + Some(id) if self.nodes.contains_key(&id) => Some(self.canonical_collection(&id).unwrap_or(id)), + // The asset index can name a node a later change removed: the asset's live copy instead. + Some(id) => by_asset().or(Some(id)), + None => by_asset(), + } } /// 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()) + VarCanon::newest_set(key) } /// A note about what this file's mapping left out or changed. Counted, as the REST importer @@ -429,6 +430,10 @@ pub(super) struct VarCanon { by_id: HashMap, /// Every live variable node of the file, with a key or not. local: BTreeSet, + /// The newest live copy of each library collection, by asset key. Read from the file's live + /// nodes (the last change of each guid, REMOVED ones gone), not from the asset index, which + /// keeps the first node it met for a key and version even when a later change removed it. + sets_by_key: HashMap, } thread_local! { @@ -449,8 +454,20 @@ impl VarCanon { let mut newest: HashMap = HashMap::new(); let mut copies: Vec<(String, String)> = Vec::new(); let mut local = BTreeSet::new(); + let mut sets: HashMap = HashMap::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) { + let ty = n.get("type").and_then(Kv::str); + if !matches!(ty, Some("VARIABLE" | "VARIABLE_SET")) || n.get("isSoftDeleted").and_then(Kv::bool) == Some(true) { + continue; + } + if ty == Some("VARIABLE_SET") { + if let Some(key) = n.get("key").and_then(Kv::str).filter(|k| !k.is_empty()) { + let rank = version_rank(n.get("version").and_then(Kv::str).unwrap_or("")); + let e = sets.entry(key.to_string()).or_insert_with(|| (rank, id.clone())); + if rank > e.0 || (rank == e.0 && *id > e.1) { + *e = (rank, id.clone()); + } + } continue; } local.insert(id.clone()); @@ -470,6 +487,7 @@ impl VarCanon { } t.by_key = newest.into_iter().map(|(k, (_, id))| (k, id)).collect(); t.local = local; + t.sets_by_key = sets.into_iter().map(|(k, (_, id))| (k, id)).collect(); VAR_CANON.with(|c| *c.borrow_mut() = Some(t)); VarCanonGuard } @@ -491,6 +509,11 @@ impl VarCanon { }) } + /// The newest live copy of the library collection with asset key `key`. + pub(super) fn newest_set(key: &str) -> Option { + VAR_CANON.with(|c| c.borrow().as_ref().and_then(|t| t.sets_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/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index d0cea1932..d365ff9f7 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -28,6 +28,7 @@ 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 NodePhase CREATED REMOVED enum ConnectorLineStyle ELBOWED STRAIGHT CURVED enum ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQUALS enum NavigationType NAVIGATE OVERLAY @@ -74,7 +75,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, string key, string version, LeadingTrim leadingTrim, float[] dashPattern +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, NodePhase phase message Blob byte[] bytes message Message NodeChange[] nodeChanges, Blob[] blobs "; @@ -1589,6 +1590,23 @@ fn library_copies_of_several_versions_become_the_newest() { ), ], ), + // A page section set by a versionless reference, while the file holds several copies. + node( + 12, + Some((1, "#")), + "FRAME", + "Versionless", + vec![ + ("size", xy(100.0, 40.0)), + ( + "variableModeBySetMap", + o(vec![( + "entries", + arr(vec![o(vec![("variableSetID", o(vec![("assetRef", o(vec![("key", s("products"))]))])), ("variableModeID", g(92))])]), + )]), + ), + ], + ), // The button names the older copy of the variable. node( 10, @@ -1618,7 +1636,42 @@ fn library_copies_of_several_versions_become_the_newest() { 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_eq!(named(&doc, "Versionless").var_modes.get(&products.id).map(String::as_str), Some("1:92")); let pasted = named(&doc, "Pasted button"); assert_eq!(pasted.bound.values().next(), Some(&var.id), "{:?}", pasted.bound); assert!(!warnings.iter().any(|w| w.contains("variable modes of collections")), "{warnings:?}"); } + +/// A copy of a library collection that a later change removed, followed by a live node for the +/// same asset key and version: the live one is the collection, not the removed one the asset +/// index met first. +#[test] +fn removed_library_collection_copies_give_way_to_live_ones() { + let mode = |id, name: &'static str, pos: &'static str| o(vec![("id", g(id)), ("name", s(name)), ("sortPosition", s(pos))]); + let by_asset = |version: &'static str| o(vec![("assetRef", o(vec![("key", s("products")), ("version", s(version))]))]); + let set = |id, version: &'static str, modes: Vec>| { + node(id, Some((2, "!")), "VARIABLE_SET", "Products", vec![("variableSetModes", arr(modes)), ("key", s("products")), ("version", s(version))]) + }; + let removed = o(vec![("guid", g(8)), ("phase", e("REMOVED"))]); + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node( + 1, + Some((0, "!")), + "CANVAS", + "Page", + vec![("variableModeBySetMap", o(vec![("entries", arr(vec![o(vec![("variableSetID", by_asset("30:1")), ("variableModeID", g(92))])]))]))], + ), + node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + set(4, "10:1", vec![mode(90, "Blue", "!")]), + set(8, "30:1", vec![mode(90, "Blue", "!"), mode(92, "Purple", "\"")]), + removed, + set(9, "30:1", vec![mode(90, "Blue", "!"), mode(92, "Purple", "\"")]), + ]; + 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.id, "1:9"); + let page = doc.nodes().find(|n| n.name == "Page").unwrap(); + assert_eq!(page.var_modes.get("1:9").map(String::as_str), Some("1:92"), "{warnings:?}"); +} From 7f4b295983ab8527ae09e535aedc22a4fa411747 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 16:17:10 +0330 Subject: [PATCH 08/10] Add files via upload --- crates/format/src/fig/map.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 97e758e7f..e125028c8 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -444,12 +444,15 @@ thread_local! { pub(super) struct VarCanonGuard; impl Drop for VarCanonGuard { + /// Clears the thread's table, so a later file on this thread starts without it. fn drop(&mut self) { VAR_CANON.with(|c| c.borrow_mut().take()); } } impl VarCanon { + /// Builds the table from mapper `m`'s live nodes (newest copy per asset key, for variables and + /// for collections) and installs it for this thread until the returned guard is dropped. fn install(m: &Mapper) -> VarCanonGuard { let mut newest: HashMap = HashMap::new(); let mut copies: Vec<(String, String)> = Vec::new(); From ef52759ebc3c794b6c318a8aeff94613fd7f6b24 Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Sun, 1 Nov 2026 16:17:46 +0330 Subject: [PATCH 09/10] Add files via upload --- crates/format/src/fig/map/variables.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/format/src/fig/map/variables.rs b/crates/format/src/fig/map/variables.rs index 12b433fc0..0794ef155 100644 --- a/crates/format/src/fig/map/variables.rs +++ b/crates/format/src/fig/map/variables.rs @@ -2,6 +2,9 @@ use super::paint::color; use super::{Kv, MAX_EXPR_DEPTH, Mapper, Obj, Value, View, guid, json, num, ref_guid}; impl<'a> Mapper<'a> { + /// Layer `v`'s variable bindings (`boundVariables`, and the text style's) and its explicitly + /// set modes (`explicitVariableModes`) into `o`, in the REST shape. Variables and collections + /// named by library asset resolve to their newest copy in the file. pub(super) fn variable_bindings(&mut self, o: &mut Obj, v: &View<'a>) { let mut bound = Obj::new(); let mut text_bound = Obj::new(); @@ -137,7 +140,8 @@ pub(super) fn variable_data(d: &Kv, depth: usize) -> Option { Some(out) } -/// The variable an `ALIAS` variable data points at. +/// The variable an `ALIAS` variable data points at: by guid, or by library asset (the newest copy +/// of it in the file). pub(super) fn alias(d: Option<&Kv>) -> Option { let d = d?; if d.get("dataType").and_then(Kv::str) != Some("ALIAS") { From 5c9bb79b1abe44e57ec919499127b37138a9d14a Mon Sep 17 00:00:00 2001 From: mortezanaeemi-ops Date: Mon, 2 Nov 2026 10:34:09 +0330 Subject: [PATCH 10/10] Add files via upload --- crates/format/src/fig/map.rs | 6 +++++ crates/format/src/fig/tests.rs | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index e125028c8..927c233a2 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -517,6 +517,12 @@ impl VarCanon { VAR_CANON.with(|c| c.borrow().as_ref().and_then(|t| t.sets_by_key.get(key).cloned())) } + /// Whether a table is installed on this thread (for tests: none outlives the mapping). + #[cfg(test)] + pub(crate) fn installed() -> bool { + VAR_CANON.with(|c| c.borrow().is_some()) + } + /// 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/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index d365ff9f7..787c70865 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -1675,3 +1675,51 @@ fn removed_library_collection_copies_give_way_to_live_ones() { let page = doc.nodes().find(|n| n.name == "Page").unwrap(); assert_eq!(page.var_modes.get("1:9").map(String::as_str), Some("1:92"), "{warnings:?}"); } + +/// Two copies of a library collection with the same version: the choice doesn't depend on the order +/// of the node changes (the higher guid wins), and the thread's lookup table is gone after the import. +#[test] +fn same_version_library_copies_resolve_deterministically_and_leave_no_table() { + let mode = |id, name: &'static str| o(vec![("id", g(id)), ("name", s(name)), ("sortPosition", s("!"))]); + let set = |id, modes: Vec>| { + node(id, Some((2, "!")), "VARIABLE_SET", "Products", vec![("variableSetModes", arr(modes)), ("key", s("products")), ("version", s("20:1"))]) + }; + let page = node( + 1, + Some((0, "!")), + "CANVAS", + "Page", + vec![( + "variableModeBySetMap", + o(vec![( + "entries", + arr(vec![o(vec![("variableSetID", o(vec![("assetRef", o(vec![("key", s("products"))]))])), ("variableModeID", g(90))])]), + )]), + )], + ); + let base = || { + vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + page.clone(), + node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + ] + }; + let mut picked = Vec::new(); + for swap in [false, true] { + let (a, b) = (set(4, vec![mode(90, "Blue")]), set(6, vec![mode(90, "Blue")])); + let mut nodes = base(); + if swap { + nodes.extend([b, a]); + } else { + nodes.extend([a, b]); + } + let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert_eq!(doc.variables.collections.len(), 1, "{:?}", doc.variables.collections); + let id = doc.variables.collections[0].id.clone(); + let page = doc.nodes().find(|n| n.name == "Page").unwrap(); + assert_eq!(page.var_modes.get(&id).map(String::as_str), Some("1:90")); + picked.push(id); + assert!(!super::map::VarCanon::installed()); + } + assert_eq!(picked, ["1:6", "1:6"]); +}