From 70a080af5b90152c5a77778718b50d1a0798c33c Mon Sep 17 00:00:00 2001 From: Ricardo Date: Thu, 22 Oct 2026 12:47:12 -0600 Subject: [PATCH 1/5] .fig export: files Figma imports, with component set properties Checked by importing synthetic files into the incumbent (Drafts > Import, black-box) and against a "Save local copy" of a synthetic document. - Images are named and referenced by the SHA-1 of their bytes. With SHA-256 names, any file with an image failed to import ("Internal error during import"). - Component sets hold their property definitions, variant properties included (stateGroupPropertyValueOrders); each variant has its own copy of each definition (parentPropDefId) and its variantPropSpecs, and its layers and instances name that copy. Definitions and values also travel as varValue; text values carry their lines; layers get componentPropRefs next to the parameter consumption map. Before, sets came in without variant properties and with their other properties unbound. - An axis without a maximum size is written as infinite: 0 collapsed auto layout frames with only a maximum width. - Compound weights are spelt the way font families name their faces ("SemiBold"; "Semi Bold" in Inter): text in Libre Franklin Semi Bold was a missing font there and wasn't drawn. - The kiwi encoder writes infinite floats. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/format/Cargo.toml | 1 + crates/format/src/fig/export.rs | 335 +++++++++++++++++++++++--- crates/format/src/fig/export_tests.rs | 216 +++++++++++++++++ crates/format/src/fig/kiwi.rs | 6 +- docs/file-format-parity.md | 25 +- 7 files changed, 539 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d2052d22..026de366 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1602,6 +1602,7 @@ dependencies = [ "ruzstd", "serde", "serde_json", + "sha1", "sha2", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index b6fb2704..d1ff8732 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] } # `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe). flate2 = { version = "1", default-features = false, features = ["rust_backend"] } ruzstd = { version = "0.9.1", default-features = false, features = ["std"] } +sha1 = "0.11" sha2 = "0.10" base64 = "0.22" crc32fast = "1" diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 3dca3912..b144465f 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -16,5 +16,6 @@ serde_json = { workspace = true } zip = { workspace = true } flate2 = { workspace = true } ruzstd = { workspace = true } +sha1 = { workspace = true } sha2 = { workspace = true } image = { workspace = true } diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 0cad2122..c7e64c9f 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -31,6 +31,7 @@ use figcraft_doc::{ WindingRule, }; use serde_json::Value; +use sha1::Digest; use super::kiwi::{Kv, Schema, encode}; use super::network; @@ -104,7 +105,7 @@ enum ImageType PNG=0 JPEG=1 SVG=2 PDF=3 enum ExportConstraintType CONTENT_SCALE=0 CONTENT_WIDTH=1 CONTENT_HEIGHT=2 enum ComponentPropType BOOL=0 TEXT=1 INSTANCE_SWAP=3 VARIANT=4 enum VariableField MISSING=0 CORNER_RADIUS=1 PARAGRAPH_SPACING=2 PARAGRAPH_INDENT=3 STROKE_WEIGHT=4 STACK_SPACING=5 STACK_PADDING_LEFT=6 STACK_PADDING_TOP=7 STACK_PADDING_RIGHT=8 STACK_PADDING_BOTTOM=9 VISIBLE=10 TEXT_DATA=11 WIDTH=12 HEIGHT=13 RECTANGLE_TOP_LEFT_CORNER_RADIUS=14 RECTANGLE_TOP_RIGHT_CORNER_RADIUS=15 RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS=16 RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS=17 BORDER_TOP_WEIGHT=18 BORDER_BOTTOM_WEIGHT=19 BORDER_LEFT_WEIGHT=20 BORDER_RIGHT_WEIGHT=21 STACK_COUNTER_SPACING=23 MIN_WIDTH=24 MAX_WIDTH=25 MIN_HEIGHT=26 MAX_HEIGHT=27 FONT_FAMILY=28 FONT_STYLE=29 OPACITY=31 FONT_SIZE=32 LETTER_SPACING=34 LINE_HEIGHT=36 OVERRIDDEN_SYMBOL_ID=37 -enum VariableDataType BOOLEAN=0 FLOAT=1 STRING=2 ALIAS=3 COLOR=4 PROP_REF=13 +enum VariableDataType BOOLEAN=0 FLOAT=1 STRING=2 ALIAS=3 COLOR=4 SYMBOL_ID=7 TEXT_DATA=9 PROP_REF=13 enum VariableScope ALL_SCOPES=0 TEXT_CONTENT=1 CORNER_RADIUS=2 WIDTH_HEIGHT=3 GAP=4 ALL_FILLS=5 FRAME_FILL=6 SHAPE_FILL=7 TEXT_FILL=8 STROKE=9 STROKE_FLOAT=10 EFFECT_FLOAT=11 EFFECT_COLOR=12 OPACITY=13 FONT_STYLE=14 FONT_FAMILY=15 FONT_SIZE=16 LINE_HEIGHT=17 LETTER_SPACING=18 PARAGRAPH_SPACING=19 PARAGRAPH_INDENT=20 enum StyleType NONE=0 FILL=1 STROKE=2 TEXT=3 EFFECT=4 EXPORT=5 GRID=6 enum InteractionType ON_CLICK=0 DRAG=9 ON_HOVER=4 ON_PRESS=7 MOUSE_IN=2 MOUSE_OUT=3 MOUSE_DOWN=5 MOUSE_UP=6 AFTER_TIMEOUT=1 ON_KEY_DOWN=10 @@ -148,10 +149,16 @@ message ExportSettings string suffix=1, ImageType imageType=2, ExportConstraint message GUIDPath GUID[] guids=1 message SymbolData GUID symbolID=1, NodeChange[] symbolOverrides=2, float uniformScaleFactor=3 message ComponentPropValue bool boolValue=1, TextData textValue=2, GUID guidValue=3 -message ComponentPropDef GUID id=1, string name=2, ComponentPropValue initialValue=3, ComponentPropType type=6 -message ComponentPropAssignment GUID defID=1, ComponentPropValue value=2 +message ComponentPropPreferredValues string[] stringValues=1 +message ComponentPropDef GUID id=1, string name=2, ComponentPropValue initialValue=3, string sortPosition=4, GUID parentPropDefId=5, ComponentPropType type=6, ComponentPropPreferredValues preferredValues=8, VariableData varValue=9 +message ComponentPropAssignment GUID defID=1, ComponentPropValue value=2, VariableData varValue=3 +enum ComponentPropNodeField VISIBLE=0 TEXT_DATA=1 OVERRIDDEN_SYMBOL_ID=2 +message ComponentPropRef GUID defID=2, ComponentPropNodeField componentPropNodeField=4 +message VariantPropSpec GUID propDefId=1, string value=2 +message StateGroupPropertyValueOrder string property=1, string[] values=2 message PropRefValue GUID defId=1 -message VariableAnyValue bool boolValue=1, string textValue=2, float floatValue=3, VariableID alias=4, Color colorValue=5, PropRefValue propRefValue=13 +message SymbolId GUID guid=1 +message VariableAnyValue bool boolValue=1, string textValue=2, float floatValue=3, VariableID alias=4, Color colorValue=5, SymbolId symbolIdValue=8, TextData textDataValue=10, PropRefValue propRefValue=13 message VariableData VariableAnyValue value=1, VariableDataType dataType=2, VariableResolvedDataType resolvedDataType=3 message VariableDataMapEntry uint nodeField=1, VariableData variableData=2, VariableField variableField=3 message VariableDataMap VariableDataMapEntry[] entries=1 @@ -168,7 +175,7 @@ message PrototypeStartingPoint string name=1, string position=3 message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Color backgroundColor=2 message GUIDPositionMapEntry GUID id=1, string position=2 message GUIDPositionMap GUIDPositionMapEntry[] entries=1 -message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 +message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, StateGroupPropertyValueOrder[] stateGroupPropertyValueOrders=238, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropRef[] componentPropRefs=267, ComponentPropAssignment[] componentPropAssignments=268, VariantPropSpec[] variantPropSpecs=483, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 struct Blob byte[] bytes message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6 "; @@ -406,8 +413,13 @@ struct Exporter<'d> { images: BTreeMap>, /// `images/` members. files: Vec<(String, Arc>)>, - /// Component property (name, type) → local id of its definition's GUID (session [`AUX`]). - defs: BTreeMap<(String, &'static str), u32>, + /// Component property (owner, name, type) → local id of its definition's GUID (session [`AUX`]). + /// The owner is the component, or the set for a variant (variant properties included, type + /// `VARIANT`). + defs: BTreeMap<(NodeId, String, &'static str), u32>, + /// A variant's own definition of each of its set's properties (variant, name, type) → local + /// id (session [`AUX`]): what its layers and its instances refer to. + child_defs: BTreeMap<(NodeId, String, &'static str), u32>, /// Grid tracks of written grid frames: (columns, rows) local ids (session [`AUX`]). tracks: BTreeMap, Vec)>, aux: u32, @@ -447,6 +459,7 @@ impl<'d> Exporter<'d> { images: BTreeMap::new(), files: Vec::new(), defs: BTreeMap::new(), + child_defs: BTreeMap::new(), tracks: BTreeMap::new(), aux: 0, overlays: BTreeMap::new(), @@ -474,6 +487,54 @@ impl<'d> Exporter<'d> { self.ids.get(&id).map(|l| guid(LAYERS, *l)) } + /// The component set component `id` is a variant of. + fn set_of(&self, id: NodeId) -> Option { + let parent = self.doc.node(id)?.parent.and_then(|p| self.doc.node(p))?; + matches!(parent.kind, NodeKind::ComponentSet { .. }).then_some(parent.id) + } + + /// The local id a layer of component `comp` (or an instance of it) uses for its property + /// `name` of type `ty`: a variant's own definition, else the component's. + fn def_for(&self, comp: NodeId, name: &str, ty: &'static str) -> Option { + let key = (comp, name.to_string(), ty); + let defs = if self.set_of(comp).is_some() { &self.child_defs } else { &self.defs }; + defs.get(&key).copied() + } + + /// The main component a layer belongs to (itself when it is one). A layer inside an instance + /// mirrors its main's layer (`source`, through nested instances): that layer's component, so + /// the instance's layers refer to what the main's do. An instance placed in a component is the + /// component's own layer (its `source` is its main, which it doesn't belong to). + fn component_of(&self, n: &Node) -> Option { + let in_instance = |x: &Node| { + let mut p = x.parent.and_then(|p| self.doc.node(p)); + for _ in 0..MAX_DEPTH { + match p { + Some(a) if matches!(a.kind, NodeKind::Instance { .. }) => return true, + Some(a) => p = a.parent.and_then(|q| self.doc.node(q)), + None => return false, + } + } + false + }; + let mut n = n; + for _ in 0..MAX_DEPTH { + match n.source.and_then(|s| self.doc.node(s)) { + Some(src) if in_instance(n) => n = src, + _ => break, + } + } + let mut cur = Some(n); + for _ in 0..MAX_DEPTH { + let c = cur?; + if matches!(c.kind, NodeKind::Component { .. }) { + return Some(c.id); + } + cur = c.parent.and_then(|p| self.doc.node(p)); + } + None + } + /// The message: the document, its pages and their layers, and the blobs. fn message(&mut self) -> Kv<'static> { let doc = self.doc; @@ -520,12 +581,27 @@ impl<'d> Exporter<'d> { } match &n.kind { NodeKind::Component { props, .. } => { + let set = self.set_of(id); + let owner = set.unwrap_or(id); for (name, d) in props { if let Some(ty) = prop_type(d) { - let key = (name.clone(), ty); + let key = (owner, name.clone(), ty); if !self.defs.contains_key(&key) { - let id = self.next_aux(); - self.defs.insert(key, id); + let def = self.next_aux(); + self.defs.insert(key, def); + } + if set.is_some() { + let child = self.next_aux(); + self.child_defs.insert((id, name.clone(), ty), child); + } + } + } + if let Some(set) = set { + for (prop, _) in variant_pairs(&n.name) { + let key = (set, prop, "VARIANT"); + if !self.defs.contains_key(&key) { + let def = self.next_aux(); + self.defs.insert(key, def); } } } @@ -949,9 +1025,36 @@ impl<'d> Exporter<'d> { } } NodeKind::Component { description, props } if !nested => { - let defs: Vec = props.iter().filter_map(|(name, d)| self.prop_def(name, d)).collect(); - if !defs.is_empty() { - f.push(("componentPropDefs", arr(defs))); + if let Some(set) = self.set_of(n.id) { + // A variant: its set holds the definitions; the variant has its own copy of + // each (pointing at the set's), and its values of the variant properties. + let own: Vec = props + .iter() + .filter_map(|(name, d)| { + let ty = prop_type(d)?; + let id = *self.child_defs.get(&(n.id, name.clone(), ty))?; + let parent = *self.defs.get(&(set, name.clone(), ty))?; + Some(o(vec![("id", guid(AUX, id)), ("parentPropDefId", guid(AUX, parent))])) + }) + .collect(); + if !own.is_empty() { + f.push(("componentPropDefs", arr(own))); + } + let specs: Vec = variant_pairs(&n.name) + .into_iter() + .filter_map(|(prop, value)| { + let id = *self.defs.get(&(set, prop, "VARIANT"))?; + Some(o(vec![("propDefId", guid(AUX, id)), ("value", s(&value))])) + }) + .collect(); + if !specs.is_empty() { + f.push(("variantPropSpecs", arr(specs))); + } + } else { + let defs: Vec = props.iter().enumerate().filter_map(|(i, (name, d))| self.prop_def(n.id, name, d, i)).collect(); + if !defs.is_empty() { + f.push(("componentPropDefs", arr(defs))); + } } if props.values().any(|d| matches!(d, PropDef::Slot { .. })) { self.warn("slot properties were left out"); @@ -962,6 +1065,7 @@ impl<'d> Exporter<'d> { } NodeKind::ComponentSet { description } if !nested => { f.push(("isStateGroup", b(true))); + self.set_props(n, f); if !description.is_empty() { f.push(("symbolDescription", s(description))); } @@ -1066,9 +1170,10 @@ impl<'d> Exporter<'d> { f.push(("exportSettings", arr(n.exports.iter().map(export_setting).collect()))); } self.prototype(n, f); - let refs: Vec = n.prop_refs.iter().filter_map(|(field, prop)| self.prop_ref(field, prop)).collect(); - if !refs.is_empty() { - f.push(("parameterConsumptionMap", o(vec![("entries", arr(refs))]))); + let (entries, refs): (Vec, Vec) = n.prop_refs.iter().filter_map(|(field, prop)| self.prop_ref(n, field, prop)).unzip(); + if !entries.is_empty() { + f.push(("parameterConsumptionMap", o(vec![("entries", arr(entries))]))); + f.push(("componentPropRefs", arr(refs))); } let text = matches!(n.kind, NodeKind::Text(_)); let links = [ @@ -1213,7 +1318,7 @@ impl<'d> Exporter<'d> { None => { let h = match self.doc.images.get(key) { Some(img) if img.bytes.len() as u64 <= MAX_IMAGE => { - let h = crate::image_hash(&img.bytes); + let h = sha1_hex(&img.bytes); if !self.files.iter().any(|(x, _)| *x == h) { self.files.push((h.clone(), img.bytes.clone())); } @@ -1433,9 +1538,12 @@ impl<'d> Exporter<'d> { /// and are written whatever its parent is. fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) { let cl = &n.child_layout; - for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] { + // An axis without a minimum is 0; one without a maximum is infinite (0 would be a maximum + // of 0, which collapses the layer in readers that apply it). + for (key, w, h, none) in [("minSize", cl.min_w, cl.min_h, 0.0), ("maxSize", cl.max_w, cl.max_h, f32::INFINITY)] { if w.is_some() || h.is_some() { - f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))]))); + let axis = |x: Option| x.map_or(Kv::Float(none), fl); + f.push((key, o(vec![("value", o(vec![("x", axis(w)), ("y", axis(h))]))]))); } } let Some(pl) = parent.and_then(|p| p.layout.as_ref()) else { return }; @@ -1803,26 +1911,96 @@ impl<'d> Exporter<'d> { Some(i) } - /// A component property definition (variant properties are implied by the variants' names). - fn prop_def(&mut self, name: &str, d: &PropDef) -> Option> { + /// A component property definition of `owner` (a component, or a set), `index`-th in its list. + /// The default value is written twice: as `initialValue` and as `varValue`, which is what + /// readers that know variables take. + fn prop_def(&mut self, owner: NodeId, name: &str, d: &PropDef, index: usize) -> Option> { let ty = prop_type(d)?; - let id = *self.defs.get(&(name.to_string(), ty))?; - let value = match d { - PropDef::Boolean { default } => o(vec![("boolValue", b(*default))]), - PropDef::Text { default } => o(vec![("textValue", o(vec![("characters", s(default))]))]), + let id = *self.defs.get(&(owner, name.to_string(), ty))?; + let (value, var) = match d { + PropDef::Boolean { default } => (o(vec![("boolValue", b(*default))]), bool_data(*default)), + PropDef::Text { default } => (o(vec![("textValue", text_value(default))]), text_data(default)), PropDef::InstanceSwap { default, preferred } => { if !preferred.is_empty() { self.warn("preferred values of instance swap properties were left out"); } - o(vec![("guidValue", self.guid_of(*default)?)]) + let main = self.guid_of(*default)?; + (o(vec![("guidValue", main.clone())]), symbol_data(main)) } PropDef::Variant { .. } | PropDef::Slot { .. } => return None, }; - Some(o(vec![("id", guid(AUX, id)), ("name", s(name)), ("initialValue", value), ("type", e(ty))])) + Some(o(vec![ + ("id", guid(AUX, id)), + ("name", s(name)), + ("initialValue", value), + ("sortPosition", s(&sort_position(index))), + ("type", e(ty)), + ("preferredValues", o(Vec::new())), + ("varValue", var), + ])) } - /// Which property controls a layer's `field`. - fn prop_ref(&mut self, field: &str, prop: &str) -> Option> { + /// A component set's property definitions: its variant properties (from the variants' names, + /// in the order they first appear, with their values in order) and the other properties its + /// variants share; written on the set, as the format keeps them. + fn set_props(&mut self, set: &Node, f: &mut Fields) { + let doc = self.doc; + let variants: Vec<&Node> = + set.children.iter().filter_map(|c| doc.node(*c)).filter(|c| matches!(c.kind, NodeKind::Component { .. })).collect(); + let mut order: Vec<(String, Vec)> = Vec::new(); + for v in &variants { + for (prop, value) in variant_pairs(&v.name) { + let i = match order.iter().position(|(p, _)| *p == prop) { + Some(i) => i, + None => { + order.push((prop, Vec::new())); + order.len() - 1 + } + }; + if let Some((_, values)) = order.get_mut(i) + && !values.contains(&value) + { + values.push(value); + } + } + } + let mut defs = Vec::new(); + for (prop, _) in &order { + if let Some(&id) = self.defs.get(&(set.id, prop.clone(), "VARIANT")) { + defs.push(o(vec![ + ("id", guid(AUX, id)), + ("name", s(prop)), + ("initialValue", o(Vec::new())), + ("sortPosition", s(&sort_position(defs.len()))), + ("type", e("VARIANT")), + ("preferredValues", o(Vec::new())), + ("varValue", var_data(("textValue", s("")), "STRING", "STRING")), + ])); + } + } + let mut seen = std::collections::BTreeSet::new(); + for v in &variants { + let NodeKind::Component { props, .. } = &v.kind else { continue }; + for (name, d) in props { + if prop_type(d).is_some() && seen.insert(name.clone()) { + let index = defs.len(); + defs.extend(self.prop_def(set.id, name, d, index)); + } + } + } + if !defs.is_empty() { + f.push(("componentPropDefs", arr(defs))); + } + if !order.is_empty() { + let orders = order.into_iter().map(|(p, values)| o(vec![("property", s(&p)), ("values", arr(values.iter().map(|x| s(x)).collect()))])); + f.push(("stateGroupPropertyValueOrders", arr(orders.collect()))); + } + } + + /// Which property controls a layer's `field`: the entry of its parameter consumption map and + /// its component property reference (readers take one or the other), both naming the + /// definition of the component the layer is in (a variant's own copy, in a set). + fn prop_ref(&mut self, n: &Node, field: &str, prop: &str) -> Option<(Kv<'static>, Kv<'static>)> { let (var_field, ty, resolved) = match field { "visible" => ("VISIBLE", "BOOL", "BOOLEAN"), "characters" => ("TEXT_DATA", "TEXT", "TEXT_DATA"), @@ -1832,19 +2010,24 @@ impl<'d> Exporter<'d> { return None; } }; - let id = *self.defs.get(&(prop.to_string(), ty))?; + let id = self.def_for(self.component_of(n)?, prop, ty)?; let data = o(vec![ ("value", o(vec![("propRefValue", o(vec![("defId", guid(AUX, id))]))])), ("dataType", e("PROP_REF")), ("resolvedDataType", e(resolved)), ]); - Some(o(vec![("variableData", data), ("variableField", e(var_field))])) + let entry = o(vec![("variableData", data), ("variableField", e(var_field))]); + let reference = o(vec![("defID", guid(AUX, id)), ("componentPropNodeField", e(var_field))]); + Some((entry, reference)) } /// An instance: its main, the fields it and its layers override, the size, place and outline /// its layers get from that (derived data), its property values. `f`: its own fields so far. fn instance(&mut self, n: &Node, main: NodeId, props: &BTreeMap, f: &mut Fields) { - const OWN: [&str; 7] = ["guid", "phase", "parentIndex", "type", "transform", "fillGeometry", "strokeGeometry"]; + // Which properties control the instance itself (a nested instance in a main component) is + // its own too, not an override of its main. + const OWN: [&str; 9] = + ["guid", "phase", "parentIndex", "type", "transform", "fillGeometry", "strokeGeometry", "parameterConsumptionMap", "componentPropRefs"]; let doc = self.doc; let (Some(m), Some(symbol)) = (doc.node(main), self.guid_of(main)) else { return }; // What the instance itself changes from its main (its size, its paints, …) is an override @@ -1948,15 +2131,19 @@ impl<'d> Exporter<'d> { } let mut assignments = Vec::new(); for (name, v) in props { - let (ty, value) = match v { - Value::Bool(x) => ("BOOL", Some(o(vec![("boolValue", b(*x))]))), - Value::String(t) => ("TEXT", Some(o(vec![("textValue", o(vec![("characters", s(t))]))]))), - Value::Number(c) => ("INSTANCE_SWAP", c.as_u64().and_then(|c| self.guid_of(NodeId(c))).map(|g| o(vec![("guidValue", g)]))), + let (ty, value, var) = match v { + Value::Bool(x) => ("BOOL", o(vec![("boolValue", b(*x))]), bool_data(*x)), + Value::String(t) => ("TEXT", o(vec![("textValue", text_value(t))]), text_data(t)), + Value::Number(c) => { + let Some(g) = c.as_u64().and_then(|c| self.guid_of(NodeId(c))) else { continue }; + ("INSTANCE_SWAP", o(vec![("guidValue", g.clone())]), symbol_data(g)) + } _ => continue, }; - // Variant values have no definition of their own: the main they show says them. - if let (Some(&id), Some(value)) = (self.defs.get(&(name.clone(), ty)), value) { - assignments.push(o(vec![("defID", guid(AUX, id)), ("value", value)])); + // Variant values have no definition of their own: the main they show says them. A + // variant's instance names the variant's own copy of the definition. + if let Some(id) = self.def_for(main, name, ty) { + assignments.push(o(vec![("defID", guid(AUX, id)), ("value", value), ("varValue", var)])); } } if !assignments.is_empty() { @@ -2393,6 +2580,74 @@ fn unset(key: &str) -> Option> { }) } +/// An image's name in the file: the SHA-1 of its bytes (lowercase hex). Paints refer to images by +/// these 20 bytes, and readers reject other hash lengths (the document's own SHA-256 key is not +/// used here). +pub(super) fn sha1_hex(bytes: &[u8]) -> String { + sha1::Sha1::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() +} + +/// A style name as readers look the face up: the standard name ([`super::style_name`]) with the +/// compound weights spelt the way most font families name their faces ("SemiBold", "ExtraBold", +/// "ExtraLight Italic"), except Inter, whose faces are named with a space ("Semi Bold"). A name +/// the family doesn't have reads as a missing font, and its text isn't drawn. +pub(super) fn face_style(family: &str, style: &str) -> String { + let standard = super::style_name(style); + if family == "Inter" || family.starts_with("Inter ") { + return standard; + } + ["Extra", "Ultra", "Semi", "Demi"].iter().fold(standard, |acc, p| acc.replace(&format!("{p} "), p)) +} + +/// The pairs of a variant's name, `Property=Value, …` (parts without `=` are left out). +fn variant_pairs(name: &str) -> Vec<(String, String)> { + name.split(',') + .filter_map(|part| { + let (p, v) = part.split_once('=')?; + let (p, v) = (p.trim(), v.trim()); + (!p.is_empty()).then(|| (p.to_string(), v.to_string())) + }) + .collect() +} + +/// The `index`-th sort position of a list: one printable character from `"` on, two past the end +/// of that range. +fn sort_position(index: usize) -> String { + const FIRST: u32 = 34; + const SPAN: usize = 93; + let ch = |i: usize| char::from_u32(FIRST + u32::try_from(i % SPAN).unwrap_or(0)).unwrap_or('"'); + if index < SPAN { ch(index).to_string() } else { format!("~{}", ch(index - SPAN)) } +} + +/// Text as a property value: its characters and one plain line per paragraph. +fn text_value(t: &str) -> Kv<'static> { + let line = || { + o(vec![ + ("lineType", e("PLAIN")), + ("styleId", int(0)), + ("indentationLevel", int(0)), + ("sourceDirectionality", e("AUTO")), + ("listStartOffset", int(0)), + ("isFirstLineOfList", b(false)), + ]) + }; + o(vec![("characters", s(t)), ("lines", arr(t.split('\n').map(|_| line()).collect()))]) +} + +/// A property value as variable data (`varValue`). +fn var_data(value: (&'static str, Kv<'static>), ty: &'static str, resolved: &'static str) -> Kv<'static> { + o(vec![("value", o(vec![value])), ("dataType", e(ty)), ("resolvedDataType", e(resolved))]) +} +fn bool_data(x: bool) -> Kv<'static> { + var_data(("boolValue", b(x)), "BOOLEAN", "BOOLEAN") +} +fn text_data(t: &str) -> Kv<'static> { + var_data(("textDataValue", text_value(t)), "TEXT_DATA", "TEXT_DATA") +} +fn symbol_data(main: Kv<'static>) -> Kv<'static> { + var_data(("symbolIdValue", o(vec![("guid", main)])), "SYMBOL_ID", "SYMBOL_ID") +} + /// The format's type of a component property (`None`: not written). fn prop_type(d: &PropDef) -> Option<&'static str> { match d { @@ -2448,7 +2703,7 @@ fn style_fields(st: &TextStyle, f: &mut Fields) { TextDecoration::Strikethrough => "STRIKETHROUGH", }; f.extend([ - ("fontName", o(vec![("family", s(&st.font_family)), ("style", s(&super::style_name(&st.font_style))), ("postscript", s(""))])), + ("fontName", o(vec![("family", s(&st.font_family)), ("style", s(&face_style(&st.font_family, &st.font_style))), ("postscript", s(""))])), ("fontSize", fl(st.font_size)), ("lineHeight", line_height), ("letterSpacing", letter_spacing), diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 0eac9571..c7036f03 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -1103,3 +1103,219 @@ fn local_samples_round_trip() { assert_same(&a, &b, &format!("{} (export warnings: {warnings:?})", p.display())); } } + +#[test] +fn images_are_named_by_the_sha1_of_their_bytes() { + // FIPS 180 test vectors. + assert_eq!(export::sha1_hex(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709"); + assert_eq!(export::sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d"); + assert_eq!(export::sha1_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), "84983e441c3bd26ebaae4aa1f95129e5e54670f1"); + assert_eq!(export::sha1_hex(&vec![b'a'; 1_000_000]), "34aa973cd4c4daa4f61eeb2bdbad27316534016f"); + let (bytes, _) = export(&generate(3), None).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap(); + let names: Vec = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect(); + assert!(!names.is_empty()); + for name in names { + let data = crate::read_member(&mut z, &name, 1 << 26).unwrap(); + assert_eq!(name.strip_prefix("images/"), Some(export::sha1_hex(&data).as_str())); + } + // Paints name the image by its 20 bytes. + let archive = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&archive.canvas).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let mut hashes = 0; + for nc in msg.get("nodeChanges").unwrap().list() { + for p in nc.get("fillPaints").map(Kv::list).unwrap_or_default() { + if let Some(h) = p.get("image").and_then(|i| i.get("hash")).and_then(Kv::bytes) { + assert_eq!(h.len(), 20); + hashes += 1; + } + } + } + assert!(hashes > 0); +} + +#[test] +fn an_axis_without_a_maximum_size_is_written_as_infinite() { + let mut doc = Document::new("File"); + let page = doc.first_page().unwrap(); + let id = doc.create(page, NodeKind::Frame, "Subject", 0.0, 0.0, 100.0, 100.0).unwrap(); + let node = doc.node_mut(id).unwrap(); + node.layout = Some(AutoLayout::default()); + node.child_layout.max_w = Some(1200.0); + node.child_layout.min_h = Some(40.0); + let (bytes, _) = export(&doc, None).unwrap(); + let archive = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&archive.canvas).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let nc = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("Subject")).unwrap(); + let axes = |k: &str| { + let v = nc.get(k).unwrap().get("value").unwrap(); + (v.get("x").cloned().unwrap(), v.get("y").cloned().unwrap()) + }; + assert_eq!(axes("maxSize"), (Kv::Float(1200.0), Kv::Float(f32::INFINITY))); + assert_eq!(axes("minSize"), (Kv::Float(0.0), Kv::Float(40.0))); + let (back, _) = import(&bytes, "File").unwrap(); + let cl = &back.nodes().find(|n| n.name == "Subject").unwrap().child_layout; + assert_eq!([cl.min_w, cl.max_w, cl.min_h, cl.max_h], [None, Some(1200.0), Some(40.0), None]); +} + +#[test] +fn compound_weights_are_spelt_the_way_font_families_name_their_faces() { + assert_eq!(export::face_style("Libre Franklin", "Semi Bold"), "SemiBold"); + assert_eq!(export::face_style("Fira Sans", "SemiBold"), "SemiBold"); + assert_eq!(export::face_style("Roboto", "Extra Light Italic"), "ExtraLight Italic"); + assert_eq!(export::face_style("Roboto", "Bold"), "Bold"); + assert_eq!(export::face_style("Inter", "SemiBold"), "Semi Bold"); + assert_eq!(export::face_style("Inter Display", "Extra Bold"), "Extra Bold"); +} + +/// A component set whose variants share a text and a boolean property, its layers bound to them, +/// and an instance of the second variant with its own label. +fn set_with_properties() -> (Document, NodeId) { + fn mirror(doc: &mut Document, src: NodeId, parent: NodeId) { + let mut n = doc.node(src).unwrap().clone(); + let kids = std::mem::take(&mut n.children); + n.id = doc.alloc_id(); + n.source = Some(src); + let id = doc.insert(n, parent, None).unwrap(); + for k in kids { + mirror(doc, k, id); + } + } + let mut doc = Document::new("File"); + let page = doc.first_page().unwrap(); + let glyph = + doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Glyph", 400.0, 0.0, 16.0, 16.0).unwrap(); + doc.create(glyph, NodeKind::Rectangle, "Shape", 0.0, 0.0, 16.0, 16.0).unwrap(); + let set = doc.create(page, NodeKind::ComponentSet { description: String::new() }, "Button", 0.0, 0.0, 300.0, 120.0).unwrap(); + let mut variants = Vec::new(); + for (i, name) in ["Size=Small", "Size=Large"].into_iter().enumerate() { + let props = BTreeMap::from([ + ("Label".to_string(), PropDef::Text { default: "Button".into() }), + ("Show icon".to_string(), PropDef::Boolean { default: true }), + ]); + let v = doc.create(set, NodeKind::Component { description: String::new(), props }, name, 0.0, 60.0 * i as f64, 120.0, 40.0).unwrap(); + let label = doc.create(v, NodeKind::Text(TextData::new("Button")), "Label", 0.0, 0.0, 60.0, 20.0).unwrap(); + doc.node_mut(label).unwrap().prop_refs.insert("characters".into(), "Label".into()); + // The icon is an instance placed in the variant: like the editor's, its `source` is its + // main, which the binding doesn't belong to. + let icon = doc.create(v, NodeKind::Instance { component: glyph, props: BTreeMap::new() }, "Icon", 70.0, 0.0, 16.0, 16.0).unwrap(); + let n = doc.node_mut(icon).unwrap(); + n.source = Some(glyph); + n.prop_refs.insert("visible".into(), "Show icon".into()); + variants.push(v); + } + let large = variants[1]; + let main = doc.node(large).unwrap().clone(); + let id = doc.alloc_id(); + let mut n = main.clone(); + let props = BTreeMap::from([("Label".to_string(), Value::String("Hi".into()))]); + (n.id, n.kind, n.name, n.parent, n.source) = (id, NodeKind::Instance { component: large, props }, "Button".into(), None, None); + n.children.clear(); + n.transform = Affine::translate((0.0, 200.0)); + doc.insert(n, page, None).unwrap(); + for k in main.children { + mirror(&mut doc, k, id); + } + let kids = doc.node(id).unwrap().children.clone(); + for k in kids { + let n = doc.node_mut(k).unwrap(); + if let NodeKind::Text(td) = &mut n.kind { + td.characters = "Hi".into(); + td.runs.clear(); + n.overrides.insert("characters".into()); + } + } + (doc, id) +} + +#[test] +fn set_properties_live_on_the_set_with_a_copy_per_variant() { + let (doc, _) = set_with_properties(); + let (bytes, _) = export(&doc, None).unwrap(); + let archive = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&archive.canvas).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let changes = msg.get("nodeChanges").unwrap().list(); + let key = |g: &Kv| format!("{}:{}", g.get("sessionID").unwrap().f64().unwrap(), g.get("localID").unwrap().f64().unwrap()); + let named = |name: &str| changes.iter().filter(|n| n.get("name").and_then(Kv::str) == Some(name)).collect::>(); + let id_of = |n: &Kv| key(n.get("guid").unwrap()); + let parent_of = |n: &Kv| key(n.get("parentIndex").unwrap().get("guid").unwrap()); + + // The set: every definition, the variant property first, with its values in order. + let set = named("Button").into_iter().find(|n| n.get("isStateGroup").is_some()).unwrap(); + let defs = set.get("componentPropDefs").unwrap().list(); + let def = |name: &str| defs.iter().find(|d| d.get("name").and_then(Kv::str) == Some(name)).unwrap(); + assert_eq!(def("Size").get("type").and_then(Kv::str), Some("VARIANT")); + assert_eq!(def("Label").get("type").and_then(Kv::str), Some("TEXT")); + assert_eq!(def("Show icon").get("type").and_then(Kv::str), Some("BOOL")); + assert_eq!(defs.first().and_then(|d| d.get("name")).and_then(Kv::str), Some("Size")); + for d in defs { + assert!(d.get("varValue").is_some() && d.get("sortPosition").is_some(), "{d:?}"); + } + let label_default = def("Label").get("varValue").unwrap().get("value").unwrap().get("textDataValue").unwrap(); + assert_eq!(label_default.get("characters").and_then(Kv::str), Some("Button")); + assert_eq!(label_default.get("lines").map(|l| l.list().len()), Some(1)); + let orders = set.get("stateGroupPropertyValueOrders").unwrap().list(); + assert_eq!(orders.len(), 1); + let values: Vec<&str> = orders[0].get("values").unwrap().list().iter().filter_map(Kv::str).collect(); + assert_eq!((orders[0].get("property").and_then(Kv::str), values), (Some("Size"), vec!["Small", "Large"])); + + // Each variant: its own copy of each definition and its variant value; its layers name the copy. + let set_ids: BTreeMap = + defs.iter().map(|d| (key(d.get("id").unwrap()), d.get("name").and_then(Kv::str).unwrap().to_string())).collect(); + let mut copies = BTreeSet::new(); + let mut large_label = None; + for (name, value) in [("Size=Small", "Small"), ("Size=Large", "Large")] { + let v = named(name)[0]; + let mut own = BTreeMap::new(); + for d in v.get("componentPropDefs").unwrap().list() { + let parent = set_ids.get(&key(d.get("parentPropDefId").unwrap())).unwrap().clone(); + assert!(copies.insert(key(d.get("id").unwrap())), "copies are the variant's own"); + own.insert(parent, key(d.get("id").unwrap())); + } + assert_eq!(own.keys().map(String::as_str).collect::>(), ["Label", "Show icon"]); + let specs = v.get("variantPropSpecs").unwrap().list(); + assert_eq!(set_ids.get(&key(specs[0].get("propDefId").unwrap())).map(String::as_str), Some("Size")); + assert_eq!(specs[0].get("value").and_then(Kv::str), Some(value)); + for (layer, prop, field) in [("Label", "Label", "TEXT_DATA"), ("Icon", "Show icon", "VISIBLE")] { + let l = named(layer).into_iter().find(|l| parent_of(l) == id_of(v)).unwrap(); + let r = &l.get("componentPropRefs").unwrap().list()[0]; + assert_eq!((key(r.get("defID").unwrap()), r.get("componentPropNodeField").and_then(Kv::str)), (own[prop].clone(), Some(field))); + let entry = &l.get("parameterConsumptionMap").unwrap().get("entries").unwrap().list()[0]; + let def_id = entry.get("variableData").unwrap().get("value").unwrap().get("propRefValue").unwrap().get("defId").unwrap(); + assert_eq!(key(def_id), own[prop]); + } + if value == "Large" { + large_label = Some(own["Label"].clone()); + } + } + + // The instance names the large variant's copy, with the value as variable data too. + let inst = named("Button").into_iter().find(|n| n.get("type").and_then(Kv::str) == Some("INSTANCE")).unwrap(); + let a = &inst.get("componentPropAssignments").unwrap().list()[0]; + assert_eq!(Some(key(a.get("defID").unwrap())), large_label); + let chars = a.get("varValue").unwrap().get("value").unwrap().get("textDataValue").unwrap().get("characters"); + assert_eq!(chars.and_then(Kv::str), Some("Hi")); +} + +#[test] +fn set_properties_round_trip() { + let (doc, inst) = set_with_properties(); + let (bytes, warnings) = export(&doc, None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let (back, _) = import(&bytes, "File").unwrap(); + for name in ["Size=Small", "Size=Large"] { + let v = back.nodes().find(|n| n.name == name).unwrap(); + let NodeKind::Component { props, .. } = &v.kind else { panic!("{name} is a component") }; + assert_eq!(props.get("Label"), Some(&PropDef::Text { default: "Button".into() }), "{name}"); + assert_eq!(props.get("Show icon"), Some(&PropDef::Boolean { default: true }), "{name}"); + let label = v.children.iter().filter_map(|c| back.node(*c)).find(|c| c.name == "Label").unwrap(); + assert_eq!(label.prop_refs.get("characters").map(String::as_str), Some("Label")); + } + let name = &doc.node(inst).unwrap().name; + let i = back.nodes().find(|n| &n.name == name && matches!(n.kind, NodeKind::Instance { .. })).unwrap(); + let NodeKind::Instance { props, .. } = &i.kind else { panic!() }; + assert_eq!(props.get("Label"), Some(&Value::String("Hi".into()))); +} diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 5c735b28..bf7509d8 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -559,7 +559,11 @@ pub mod encode { Type::Byte => w.byte(ranged(0, 255)? as u8), Type::Int => w.var_int(ranged(i32::MIN.into(), i32::MAX.into())? as i32), Type::Uint => w.var_uint(ranged(0, u32::MAX.into())? as u32), - Type::Float => w.var_float(v.f64().map(|x| x as f32).filter(|x| x.is_finite()).ok_or_else(bad)?), + // Infinity is a value of its own (an axis with no maximum size); NaN is not. + Type::Float => w.var_float(match v { + Kv::Float(f) if f.is_infinite() => *f, + _ => v.f64().map(|x| x as f32).filter(|x| x.is_finite()).ok_or_else(bad)?, + }), Type::Str => w.string(v.str().ok_or_else(bad)?), Type::Int64 => w.var_int64(ranged(i64::MIN.into(), i64::MAX.into())? as i64), Type::Uint64 => w.var_uint64(ranged(0, u64::MAX.into())? as u64), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index adf0b0f2..35ecb7c0 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (`.fig` export: files the incumbent imports, with component set properties; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -83,17 +83,21 @@ each. their filled areas), text, boolean groups, slices; the fill outline of frames and shapes and the stroke outline of plain strokes (a ring around boxes and whole ellipses, the widened path of other centred strokes); - solid, gradient (handles as the paint transform) and image paints (the image bytes, scale mode, + solid, gradient (handles as the paint transform) and image paints (the image bytes, named by their SHA-1, scale mode, rotation, tile scale, crop); strokes (weight, align, join, caps, dashes, miter limit, per-side weights); shadows, layer and background blurs (progressive too), noise and texture; corners and smoothing, constraints, clip content, masks with their type; auto layout (direction, wrap, gaps, padding, alignment, hug, strokes in layout, stacking order, grids with their tracks and cells) and how - layers sit in it (fill, absolute, min/max sizes); layout grids; export settings; text style - (font, with styles under their standard names such as "Semi Bold"; size; line height, a share of + layers sit in it (fill, absolute, min/max sizes, an axis without a maximum as infinite); layout grids; export settings; text style + (font, with styles under the names font families give their faces: "SemiBold", "ExtraBold"…, and + "Semi Bold" in Inter; size; line height, a share of the font size as a raw multiplier; letter spacing, case, decoration, links), alignment, auto resize, truncation, paragraph spacing and indent, list types and styled runs with their fills (indexed by UTF-16 unit); components (description, boolean, text and instance swap properties and the - layers bound to them), component sets, instances (their main, property values, what the instance + layers bound to them, with their values also as variable data), component sets (the set holds + the definitions, variant properties included with their values in order; each variant has its + own copy of each definition and its variant values, and its layers and instances name that + copy), instances (their main, property values, what the instance itself changes from its main, such as its size, name and paints, as an override addressed by the main's id, the fields its layers change, by id path, through nested instances and swaps, and what follows from those as derived data: each layer's size, place and outline and the colours its @@ -137,6 +141,16 @@ each. Not working or unchecked there: number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there); effects such as glass and noise, images and prototype interactions are unchecked. Text boxes sized to their content can come out a pixel wider or narrower there. + Then (2026-10-22), importing synthetic files into the incumbent (Drafts ▸ Import): any file with an + image failed ("Internal error during import") while images were named by their SHA-256; named by + their SHA-1 they import with their images. Component sets came in without their variant + properties and with boolean, text and instance swap properties unbound; with the set holding the + definitions and each variant its own copies (the layout of a file the incumbent saves itself), + the set lists every property, the variants' layers are bound to them and instances keep their + values. Frames with only a maximum width collapsed (the missing maximum height was written as 0), + and text in a family that spells compound weights without a space (Libre Franklin Semi Bold) was + a missing font and not drawn; both fixed. Variables with their modes, shared styles and their + links, colour bindings and the modes frames set came across. - **Round trips through our own importer:** 60 generated documents (every layer kind above, components, nested instances with overrides, a grid inside a component, interactions) are exported, read back and compared layer by layer: structure, geometry within @@ -165,6 +179,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | `.fig` export imports into the incumbent (checked with synthetic files, black-box): images are named and referenced by their SHA-1 (with SHA-256 names any file with an image failed to import); component sets hold their property definitions, variant properties included, with a copy per variant that its layers and instances name, and values also as variable data, so properties, bindings and instance values come across; an axis without a maximum size is written as infinite (0 collapsed frames with only a maximum width); compound weights are spelt the way font families name their faces ("SemiBold"; "Semi Bold" in Inter), so their text isn't a missing font; the kiwi encoder writes infinite floats. Numbers unchanged | | 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | From 2c044f6b80c2bf10340ca6ef36b85694cb72c69a Mon Sep 17 00:00:00 2001 From: Ricardo Date: Thu, 22 Oct 2026 13:16:16 -0600 Subject: [PATCH 2/5] .fig export: an override that clears a maximum size writes it as infinite Like an unset axis, a cleared maximum was written as 0 and could collapse the layer (CodeRabbit review on #1152). Co-Authored-By: Claude Opus 5.5 --- crates/format/src/fig/export.rs | 4 +++- crates/format/src/fig/export_tests.rs | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index c7e64c9f..312aba13 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -2569,7 +2569,9 @@ fn unset(key: &str) -> Option> { "cornerSmoothing" | "stackChildPrimaryGrow" => fl(0.0), "stackMode" => e("NONE"), "stackChildAlignSelf" | "stackPositioning" => e("AUTO"), - "minSize" | "maxSize" => o(vec![("value", xy(0.0, 0.0))]), + "minSize" => o(vec![("value", xy(0.0, 0.0))]), + // No maximum is infinite (a maximum of 0 collapses the layer). + "maxSize" => o(vec![("value", o(vec![("x", Kv::Float(f32::INFINITY)), ("y", Kv::Float(f32::INFINITY))]))]), "gridColumnSpan" | "gridRowSpan" | "maxLines" => int(0), "scrollDirection" => e("NONE"), "scrollBehavior" => e("SCROLLS"), diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index c7036f03..4ef073b1 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -1319,3 +1319,20 @@ fn set_properties_round_trip() { let NodeKind::Instance { props, .. } = &i.kind else { panic!() }; assert_eq!(props.get("Label"), Some(&Value::String("Hi".into()))); } + +#[test] +fn an_instance_without_its_mains_maximum_size_overrides_it_with_infinity() { + let mut doc = Document::new("File"); + let page = doc.first_page().unwrap(); + let main = doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Box", 0.0, 0.0, 100.0, 40.0).unwrap(); + doc.node_mut(main).unwrap().child_layout.max_w = Some(300.0); + doc.create(page, NodeKind::Instance { component: main, props: BTreeMap::new() }, "Box", 0.0, 100.0, 100.0, 40.0).unwrap(); + let (bytes, _) = export(&doc, None).unwrap(); + let archive = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&archive.canvas).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let inst = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("type").and_then(Kv::str) == Some("INSTANCE")).unwrap(); + let overrides = inst.get("symbolData").unwrap().get("symbolOverrides").unwrap().list(); + let max = overrides.iter().find_map(|o| o.get("maxSize")).unwrap().get("value").unwrap(); + assert_eq!((max.get("x"), max.get("y")), (Some(&Kv::Float(f32::INFINITY)), Some(&Kv::Float(f32::INFINITY)))); +} From 19d6c17b6d7f66da744ecd5a1693b15c30be41e3 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Thu, 22 Oct 2026 16:45:20 -0600 Subject: [PATCH 3/5] .fig export: grid track sizes Grids wrote their tracks as equal ones, with a warning. Track sizes (fixed px, hug, fill shares) are now written as gridColumnsSizing / gridRowsSizing, the fields the import already reads. Checked on a synthetic grid in the incumbent (fixed 320/140/112/34/34 px columns): written as equal tracks it came in with five equal columns and its layers overflowing their cells; now its columns keep their sizes. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/fig/export.rs | 42 ++++++++++++++++++------ crates/format/src/fig/export_tests.rs | 46 +++++++++++++++++++++++++++ docs/file-format-parity.md | 9 +++--- 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 312aba13..85a4726a 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -25,10 +25,10 @@ use figcraft_doc::proto::{ Action, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, }; use figcraft_doc::{ - AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, HConstraint, LayoutMode, LetterSpacing, - LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, StrokeJoin, StyleKind, - TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, VariableValue, VectorNetwork, - WindingRule, + AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode, + LetterSpacing, LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, + StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, + VariableValue, VectorNetwork, WindingRule, }; use serde_json::Value; use sha1::Digest; @@ -175,7 +175,12 @@ message PrototypeStartingPoint string name=1, string position=3 message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Color backgroundColor=2 message GUIDPositionMapEntry GUID id=1, string position=2 message GUIDPositionMap GUIDPositionMapEntry[] entries=1 -message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, StateGroupPropertyValueOrder[] stateGroupPropertyValueOrders=238, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropRef[] componentPropRefs=267, ComponentPropAssignment[] componentPropAssignments=268, VariantPropSpec[] variantPropSpecs=483, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 +enum GridTrackSizingType FLEX=0 FIXED=1 HUG=2 +message GridTrackSizingFunction GridTrackSizingType type=1, float value=2 +message GridTrackSize GridTrackSizingFunction minSizing=1, GridTrackSizingFunction maxSizing=2 +message GUIDGridTrackSizeMapEntry GUID id=1, GridTrackSize trackSize=2 +message GUIDGridTrackSizeMap GUIDGridTrackSizeMapEntry[] entries=1 +message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, StateGroupPropertyValueOrder[] stateGroupPropertyValueOrders=238, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GUIDGridTrackSizeMap gridColumnsSizing=474, GUIDGridTrackSizeMap gridRowsSizing=475, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropRef[] componentPropRefs=267, ComponentPropAssignment[] componentPropAssignments=268, VariantPropSpec[] variantPropSpecs=483, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 struct Blob byte[] bytes message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6 "; @@ -1519,10 +1524,29 @@ impl<'d> Exporter<'d> { arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()), )]) }; - f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]); - if l.column_tracks.iter().chain(&l.row_tracks).any(|t| t.sizing != Sizing::Fill) { - self.warn("grid tracks of fixed or content size were written as equal tracks"); - } + // Each track's size (fixed px, hug, or a share of the free space), by track id; a track + // the layout doesn't list fills one share. Readers without it make tracks up. + let sizes = |ids: &[u32], tracks: &[GridTrack]| { + let entries = ids.iter().enumerate().map(|(i, id)| { + let t = tracks.get(i).copied().unwrap_or_default(); + let (ty, value) = match t.sizing { + Sizing::Fixed => ("FIXED", t.value), + Sizing::Hug => ("HUG", 0.0), + Sizing::Fill => ("FLEX", if t.value.is_finite() && t.value > 0.0 { t.value } else { 1.0 }), + }; + let sizing = || o(vec![("type", e(ty)), ("value", fl(value))]); + o(vec![("id", guid(AUX, *id)), ("trackSize", o(vec![("minSizing", sizing()), ("maxSizing", sizing())]))]) + }); + o(vec![("entries", arr(entries.collect()))]) + }; + f.extend([ + ("gridColumnGap", fl(l.gap)), + ("gridRowGap", fl(l.cross_gap)), + ("gridColumns", map(&cols)), + ("gridRows", map(&rows)), + ("gridColumnsSizing", sizes(&cols, &l.column_tracks)), + ("gridRowsSizing", sizes(&rows, &l.row_tracks)), + ]); self.tracks.insert(key, (cols, rows)); } else { let spread = l.content_align == ContentAlign::SpaceBetween; diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 4ef073b1..e9d0a601 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -635,6 +635,12 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) { let d = AutoLayout::default(); (l.grid_columns, l.grid_rows) = (d.grid_columns, d.grid_rows); } + // Equal (one-share) tracks say what no track list says. + for tracks in [&mut l.column_tracks, &mut l.row_tracks] { + if tracks.iter().all(|t| *t == GridTrack::default()) { + tracks.clear(); + } + } } n.child_layout.grid_align_h = None; n.child_layout.grid_align_v = None; @@ -1336,3 +1342,43 @@ fn an_instance_without_its_mains_maximum_size_overrides_it_with_infinity() { let max = overrides.iter().find_map(|o| o.get("maxSize")).unwrap().get("value").unwrap(); assert_eq!((max.get("x"), max.get("y")), (Some(&Kv::Float(f32::INFINITY)), Some(&Kv::Float(f32::INFINITY)))); } + +#[test] +fn grid_track_sizes_are_written_and_read_back() { + let mut doc = Document::new("File"); + let page = doc.first_page().unwrap(); + let id = doc.create(page, NodeKind::Frame, "Row", 0.0, 0.0, 720.0, 70.0).unwrap(); + let columns = vec![GridTrack::fixed(320.0), GridTrack::hug(), GridTrack { sizing: Sizing::Fill, value: 2.0 }, GridTrack::fixed(34.0)]; + doc.node_mut(id).unwrap().layout = Some(AutoLayout { + mode: LayoutMode::Grid, + grid_columns: 4, + grid_rows: 1, + column_tracks: columns.clone(), + row_tracks: vec![GridTrack::fixed(69.0)], + ..AutoLayout::default() + }); + let (bytes, warnings) = export(&doc, None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let archive = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&archive.canvas).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let row = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("Row")).unwrap(); + let key = |g: &Kv| format!("{}:{}", g.get("sessionID").unwrap().f64().unwrap(), g.get("localID").unwrap().f64().unwrap()); + let order: Vec = row.get("gridColumns").unwrap().get("entries").unwrap().list().iter().map(|e| key(e.get("id").unwrap())).collect(); + let sizes = row.get("gridColumnsSizing").unwrap().get("entries").unwrap().list(); + let written: Vec<(String, f64)> = order + .iter() + .map(|id| { + let size = sizes.iter().find(|e| key(e.get("id").unwrap()) == *id).unwrap().get("trackSize").unwrap(); + let (min, max) = (size.get("minSizing").unwrap(), size.get("maxSizing").unwrap()); + assert_eq!(min, max); + (min.get("type").and_then(Kv::str).unwrap().to_string(), min.get("value").unwrap().f64().unwrap()) + }) + .collect(); + let types: Vec<(&str, f64)> = written.iter().map(|(t, v)| (t.as_str(), *v)).collect(); + assert_eq!(types, [("FIXED", 320.0), ("HUG", 0.0), ("FLEX", 2.0), ("FIXED", 34.0)]); + let (back, _) = import(&bytes, "File").unwrap(); + let l = back.nodes().find(|n| n.name == "Row").unwrap().layout.clone().unwrap(); + assert_eq!(l.column_tracks, columns); + assert_eq!(l.row_tracks, vec![GridTrack::fixed(69.0)]); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 35ecb7c0..9ad4b65c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (`.fig` export: files the incumbent imports, with component set properties; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (`.fig` export: grid track sizes; files the incumbent imports, with component set properties; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -87,7 +87,7 @@ each. rotation, tile scale, crop); strokes (weight, align, join, caps, dashes, miter limit, per-side weights); shadows, layer and background blurs (progressive too), noise and texture; corners and smoothing, constraints, clip content, masks with their type; auto layout (direction, wrap, gaps, padding, - alignment, hug, strokes in layout, stacking order, grids with their tracks and cells) and how + alignment, hug, strokes in layout, stacking order, grids with their tracks, their sizes (fixed, hug, fill) and cells) and how layers sit in it (fill, absolute, min/max sizes, an axis without a maximum as infinite); layout grids; export settings; text style (font, with styles under the names font families give their faces: "SemiBold", "ExtraBold"…, and "Semi Bold" in Inter; size; line height, a share of @@ -126,8 +126,8 @@ each. image adjustments, glass settings other than frost, springs other than Gentle (written as ease out), set-variable, set-variable-mode and conditional actions, scroll-to offsets, text decoration looks, variable font axes, text on a path, nested list levels, list spacing, hanging punctuation, - leading trim, OpenType features, per-layer operations inside boolean groups, grid tracks of fixed - or content size, different stroke ends on lines. Not written at all: stroke outlines of dashed and + leading trim, OpenType features, per-layer operations inside boolean groups, different + stroke ends on lines. Not written at all: stroke outlines of dashed and per-side strokes and of inside or outside strokes on polygons, stars and vectors, glyph outlines of text (a reader lays text out with its own fonts), version history and branches. - **Checked by opening written files in other software:** the example documents and a small test @@ -179,6 +179,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | `.fig` export writes grid track sizes (`gridColumnsSizing` / `gridRowsSizing`: fixed px, hug, fill shares), which the import already read; they were written as equal tracks (with a warning). Checked on a synthetic grid there (fixed 320/140/112/34/34 px columns): written as equal tracks it came in with five equal columns and its layers overflowing their cells; now its columns keep their sizes. Numbers unchanged | | 2026-10-22 | minor | `.fig` export imports into the incumbent (checked with synthetic files, black-box): images are named and referenced by their SHA-1 (with SHA-256 names any file with an image failed to import); component sets hold their property definitions, variant properties included, with a copy per variant that its layers and instances name, and values also as variable data, so properties, bindings and instance values come across; an axis without a maximum size is written as infinite (0 collapsed frames with only a maximum width); compound weights are spelt the way font families name their faces ("SemiBold"; "Semi Bold" in Inter), so their text isn't a missing font; the kiwi encoder writes infinite floats. Numbers unchanged | | 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | From 4c6c4d9667bf688a4ad23e1a07ec61c94fa2f0df Mon Sep 17 00:00:00 2001 From: Ricardo Date: Thu, 22 Oct 2026 20:46:12 -0600 Subject: [PATCH 4/5] .fig export: warn when a set's variants disagree on a property default The format keeps one default per property of a set; the first variant's is written (CodeRabbit review on #1152). Co-Authored-By: Claude Opus 5.5 --- crates/format/src/fig/export.rs | 18 ++++++++++++++---- crates/format/src/fig/export_tests.rs | 11 +++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 85a4726a..65f30698 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -2002,13 +2002,23 @@ impl<'d> Exporter<'d> { ])); } } - let mut seen = std::collections::BTreeSet::new(); + // The format keeps one default per property of a set (a variant's copy holds none): the first + // variant's, with a warning when another variant's differs. + let mut seen: BTreeMap = BTreeMap::new(); for v in &variants { let NodeKind::Component { props, .. } = &v.kind else { continue }; for (name, d) in props { - if prop_type(d).is_some() && seen.insert(name.clone()) { - let index = defs.len(); - defs.extend(self.prop_def(set.id, name, d, index)); + if prop_type(d).is_none() { + continue; + } + match seen.get(name) { + Some(first) if first != d => self.warn("variants with different defaults for one property were written with the first variant's"), + Some(_) => {} + None => { + seen.insert(name.clone(), d.clone()); + let index = defs.len(); + defs.extend(self.prop_def(set.id, name, d, index)); + } } } } diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index e9d0a601..78eeb758 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -1382,3 +1382,14 @@ fn grid_track_sizes_are_written_and_read_back() { assert_eq!(l.column_tracks, columns); assert_eq!(l.row_tracks, vec![GridTrack::fixed(69.0)]); } + +#[test] +fn variants_that_disagree_on_a_default_are_a_warning() { + let (mut doc, _) = set_with_properties(); + let large = doc.nodes().find(|n| n.name == "Size=Large").unwrap().id; + if let NodeKind::Component { props, .. } = &mut doc.node_mut(large).unwrap().kind { + props.insert("Label".into(), PropDef::Text { default: "Large".into() }); + } + let (_, warnings) = export(&doc, None).unwrap(); + assert!(warnings.iter().any(|w| w.contains("different defaults")), "{warnings:?}"); +} From c19b7d07999686db4c5fc78b5542c046ea837ee6 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Fri, 23 Oct 2026 12:00:22 -0600 Subject: [PATCH 5/5] .fig export: a property name used with two types in a set writes both definitions The set wrote one definition per name, so a variant whose property of that name had another type named a definition the file didn't hold (CodeRabbit review on #1152). Co-Authored-By: Claude Opus 5.5 --- crates/format/src/fig/export.rs | 11 +++++---- crates/format/src/fig/export_tests.rs | 32 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 65f30698..9fda302d 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -2004,18 +2004,17 @@ impl<'d> Exporter<'d> { } // The format keeps one default per property of a set (a variant's copy holds none): the first // variant's, with a warning when another variant's differs. - let mut seen: BTreeMap = BTreeMap::new(); + // A name used with two types is two properties (their definitions have their own ids). + let mut seen: BTreeMap<(String, &'static str), PropDef> = BTreeMap::new(); for v in &variants { let NodeKind::Component { props, .. } = &v.kind else { continue }; for (name, d) in props { - if prop_type(d).is_none() { - continue; - } - match seen.get(name) { + let Some(ty) = prop_type(d) else { continue }; + match seen.get(&(name.clone(), ty)) { Some(first) if first != d => self.warn("variants with different defaults for one property were written with the first variant's"), Some(_) => {} None => { - seen.insert(name.clone(), d.clone()); + seen.insert((name.clone(), ty), d.clone()); let index = defs.len(); defs.extend(self.prop_def(set.id, name, d, index)); } diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 78eeb758..7fc579a5 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -1393,3 +1393,35 @@ fn variants_that_disagree_on_a_default_are_a_warning() { let (_, warnings) = export(&doc, None).unwrap(); assert!(warnings.iter().any(|w| w.contains("different defaults")), "{warnings:?}"); } + +#[test] +fn a_name_used_with_two_types_in_a_set_writes_both_definitions() { + let (mut doc, _) = set_with_properties(); + let large = doc.nodes().find(|n| n.name == "Size=Large").unwrap().id; + if let NodeKind::Component { props, .. } = &mut doc.node_mut(large).unwrap().kind { + props.insert("Label".into(), PropDef::Boolean { default: true }); + } + let (bytes, _) = export(&doc, None).unwrap(); + let archive = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&archive.canvas).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let changes = msg.get("nodeChanges").unwrap().list(); + let key = |g: &Kv| format!("{}:{}", g.get("sessionID").unwrap().f64().unwrap(), g.get("localID").unwrap().f64().unwrap()); + let set = changes.iter().find(|n| n.get("isStateGroup").is_some()).unwrap(); + let written: BTreeSet = set.get("componentPropDefs").unwrap().list().iter().map(|d| key(d.get("id").unwrap())).collect(); + let labels: Vec<&str> = set + .get("componentPropDefs") + .unwrap() + .list() + .iter() + .filter(|d| d.get("name").and_then(Kv::str) == Some("Label")) + .filter_map(|d| d.get("type").and_then(Kv::str)) + .collect(); + assert_eq!(labels.len(), 2, "{labels:?}"); + // Every variant copy names a definition the set holds. + for v in changes.iter().filter(|n| n.get("variantPropSpecs").is_some()) { + for d in v.get("componentPropDefs").unwrap().list() { + assert!(written.contains(&key(d.get("parentPropDefId").unwrap()))); + } + } +}