diff --git a/Cargo.lock b/Cargo.lock index d181b1a3..6d0b74ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1687,6 +1687,7 @@ dependencies = [ "ruzstd", "serde", "serde_json", + "sha1", "sha2", "zip 2.6.1", ] diff --git a/Cargo.toml b/Cargo.toml index 70cb0099..76b73afc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,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 801aeed9..80d46056 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -31,6 +31,7 @@ use figcraft_doc::{ VariableValue, VectorNetwork, 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 @@ -169,11 +176,11 @@ message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Colo message GUIDPositionMapEntry GUID id=1, string position=2 message GUIDPositionMap GUIDPositionMapEntry[] entries=1 enum GridTrackSizingType FLEX=0 FIXED=1 HUG=2 UNKNOWN=3 -message GridTrackSizeFunction GridTrackSizingType type=1, float value=2 -message GridTrackSize GridTrackSizeFunction minSizing=1, GridTrackSizeFunction maxSizing=2 -message GridTrackSizeEntry GUID id=1, GridTrackSize trackSize=2 -message GridTrackSizeMap GridTrackSizeEntry[] 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, GridTrackSizeMap gridColumnsSizing=443, GridTrackSizeMap gridRowsSizing=444, 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 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 "; @@ -442,8 +449,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, @@ -483,6 +495,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(), @@ -510,6 +523,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; @@ -556,12 +617,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); } } } @@ -985,9 +1061,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"); @@ -998,6 +1101,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))); } @@ -1102,9 +1206,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 = [ @@ -1254,7 +1359,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())); } @@ -1484,9 +1589,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 }; @@ -1854,26 +1962,105 @@ 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")), + ])); + } + } + // 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. + // 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 { + 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(), ty), d.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"), @@ -1883,19 +2070,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 @@ -1999,15 +2191,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() { @@ -2433,7 +2629,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"), @@ -2444,6 +2642,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 { @@ -2499,7 +2765,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 1f0fe172..8365e48b 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; @@ -1178,3 +1184,319 @@ 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()))); +} + +#[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)))); +} + +#[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)]); +} + +#[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:?}"); +} + +#[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()))); + } + } +} diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index ab8f3218..0f431082 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -563,7 +563,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 b834ed9b..790f8ec8 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-26 · **Change:** minor (Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#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)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (`.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#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)) · **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 @@ -84,17 +84,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 + 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 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 @@ -138,6 +142,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 @@ -171,6 +185,8 @@ each. | 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged | | 2026-10-24 | minor | Slices and contents only (Figma's Ignore overlapping layers): with it on, a slice inside a frame or group exports only that frame or group and the layers in it, and its Export preview shows the same; with it off, or for a slice on the page or in a section, the slice exports everything in its area as before | | 2026-10-24 | minor | Boards (M12): `.figcraft` saves the file kind (`kind: "board"`, absent for design files) and each board object's `board` metadata; the `.jam` row points at boards | +| 2026-10-22 | minor | `.fig` export writes grid track sizes (`gridColumnsSizing` / `gridRowsSizing`) at the field ids the incumbent reads (474 / 475). Checked on a synthetic grid there (fixed 320/140/112/34/34 px columns): 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-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Sketch 2025 and newer: frames, graphics, stacks as auto layout, corners, pins and sizing, tints, per-side borders, blur lists. Checked on fixtures and locally on real Sketch 50, 64 and 2026 files and the IBM Carbon Design Kit (Sketch 68 and 83 open; the Sketch 47 edition is refused). Numbers unchanged | | 2026-10-15 | minor | `.fig` import (#279, #281; #266, #267): resized instances lay out their layers by their constraints, and explicit variable modes whose collection is named by its asset key come across (before, they were dropped and bound layers resolved in the default mode); fixture-tested, real files still owed. `.figcraft`: atomic saves and exports through unique staging files (#284), optional auto-save (#305). SVG export keeps a nested frame's fill and stroke inside its parent's clip (#327). Numbers unchanged | | 2026-10-20 | minor | `.fig` export writes grid track sizes (`gridColumnsSizing` / `gridRowsSizing`): fixed px, fractional fill and content-sized tracks keep their sizes and come back through our own importer (#465). Tracks whose size the format can't hold (a negative or out-of-range value) keep their id and read back equal, with a warning. Numbers unchanged |