diff --git a/Cargo.lock b/Cargo.lock index ef0302e..dad36ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1533,6 +1533,7 @@ name = "figcraft-format" version = "0.5.1" dependencies = [ "figcraft-doc", + "figcraft-pathops", "flate2", "image", "ruzstd", diff --git a/ROADMAP.md b/ROADMAP.md index da538d0..961ee4a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,7 +135,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). their main (size, name, paints) as an override addressed by the main's id, what follows for their layers as derived data and a scale factor when scaled as a whole; variable collections, modes, variables and bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the - import reads all of it back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), and colour styles are listed; + import reads all of it back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; + 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). Numbers unchanged. text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes are not yet checked there. Numbers unchanged. - **2026-10-13** · Starter templates and first-run help: eight templates built in code (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter), a @@ -296,7 +297,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-13 | minor | `.fig` export (gap 1): instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes are written and read back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), and colour styles are listed; text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes are not yet checked there. Numbers unchanged | +| 2026-10-13 | minor | `.fig` export (gap 1): instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes are written and read back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; 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). Numbers unchanged | | 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials ~57% → ~59% (discoverability ×0.92 → ×0.94), its hours ~60–110 h → ~55–100 h; other numbers unchanged | | 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. The draft catalogs (French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese) translate the 71 strings and 5 command labels they lacked and the new ones. Numbers unchanged | | 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains | diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 0b60198..3dca391 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] figcraft-doc = { workspace = true } +figcraft-pathops = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } zip = { workspace = true } diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index 165dc66..502fcfe 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -645,36 +645,134 @@ fn layers_of_a_swapped_nested_instance_keep_what_they_change() { let _ = insts; } -#[test] -fn variable_width_strokes_say_their_reach_on_each_side() { +/// A line with a stroke of weight 8 and `profile` (`[t, scale, bias]` points). +fn tapered(profile: &[(f64, f64, f64)]) -> Document { let mut doc = Document::new("Widths"); let page = doc.first_page().unwrap(); - add(&mut doc, page, NodeKind::Line, "Tapered", (0.0, 0.0), (100.0, 0.0), &|n| { + add(&mut doc, page, NodeKind::Line, "Tapered", (20.0, 20.0), (100.0, 0.0), &|n| { n.fills.clear(); n.strokes = vec![solid(0.0, 0.0, 0.0)]; n.stroke.weight = 8.0; n.stroke.align = StrokeAlign::Center; - let mut profile = WidthProfile::new([(0.0, 0.25), (0.5, 1.5), (1.0, 0.0)]); - profile.points[1].bias = 0.5; - n.stroke.width_profile = Some(profile); + let mut p = WidthProfile::new(profile.iter().map(|q| (q.0, q.1))); + for (point, q) in p.points.iter_mut().zip(profile) { + point.bias = q.2; + } + n.stroke.width_profile = Some(p); }); + doc +} + +#[test] +fn variable_width_strokes_say_their_reach_as_a_share_of_the_weight() { + let mut doc = tapered(&[(0.0, 0.25, 0.0), (0.5, 1.0, 0.0), (1.0, 0.0, 0.0)]); let nodes = written(&doc); let line = named(&nodes, "Tapered"); let points = line["variableWidthPoints"].as_array().unwrap(); assert_eq!(points.len(), 3); assert_eq!(points[0], json!({"position": 0.0, "ascent": 0.125, "descent": 0.125})); - // Wider on the right: one and a half times the weight, a quarter of it on the left. - assert_eq!(points[1], json!({"position": 0.5, "ascent": 0.375, "descent": 1.125})); + // The full weight is half of it on each side. + assert_eq!(points[1], json!({"position": 0.5, "ascent": 0.5, "descent": 0.5})); // The outline is left to the reader: it follows from the points. - assert!(line.get("strokeGeometry").is_none()); + assert!(line.get("strokeGeometry").is_none() && line.get("pluginData").is_none()); let warnings = same_after_round_trip(&doc, "widths"); assert!(warnings.is_empty(), "{warnings:?}"); - // A stroke without a weight has no reach to say; brushes are still written plain. + // A stroke without a weight has no reach to say. let line = doc.nodes().find(|n| n.name == "Tapered").unwrap().id; doc.node_mut(line).unwrap().stroke.weight = 0.0; assert!(named(&written(&doc), "Tapered").get("variableWidthPoints").is_none()); } +#[test] +fn strokes_wider_on_one_side_are_written_as_their_outline_and_come_back_as_strokes() { + let doc = tapered(&[(0.0, 0.0, 1.0), (0.5, 1.0, 1.0), (1.0, 0.0, 1.0)]); + let nodes = written(&doc); + let line = named(&nodes, "Tapered"); + // A vector filled with the stroke's paint, in the line's place; no stroke left to draw. + assert_eq!(line["type"], "VECTOR"); + assert!(line.get("variableWidthPoints").is_none()); + assert_eq!((line["fillPaints"].as_array().unwrap().len(), line["strokePaints"].as_array().unwrap().len()), (1, 0)); + assert!(line["fillGeometry"].is_array() && line["vectorData"].is_object()); + assert_eq!(line["transform"]["m02"], 20.0); + let kept = &line["pluginData"][0]; + assert_eq!((kept["pluginID"].as_str(), kept["key"].as_str()), (Some("figcraft"), Some("stroke"))); + assert!(kept["value"].as_str().unwrap().contains("width_profile")); + // Our own import gets the line and its stroke back. + let (bytes, warnings) = export(&doc, None).unwrap(); + assert!(warnings.iter().any(|w| w.contains("filled outlines")), "{warnings:?}"); + let (back, _) = import(&bytes, "x").unwrap(); + let (a, b) = (doc.nodes().find(|n| n.name == "Tapered").unwrap(), back.nodes().find(|n| n.name == "Tapered").unwrap()); + assert_eq!((&b.kind, &b.stroke, &b.strokes, &b.fills), (&a.kind, &a.stroke, &a.strokes, &a.fills)); + assert_eq!((b.width, b.height, b.transform), (a.width, a.height, a.transform)); + // A filled shape keeps its stroke, with the width on both sides and a warning. + let mut filled = doc.clone(); + let id = a.id; + let n = filled.node_mut(id).unwrap(); + n.kind = NodeKind::Rectangle; + (n.height, n.fills) = (40.0, vec![solid(1.0, 0.0, 0.0)]); + let (_, warnings) = export(&filled, None).unwrap(); + assert!(warnings.iter().any(|w| w.contains("on both sides")), "{warnings:?}"); + let shape = written(&filled); + let shape = named(&shape, "Tapered"); + assert_eq!(shape["type"], "ROUNDED_RECTANGLE"); + assert_eq!(shape["variableWidthPoints"][1], json!({"position": 0.5, "ascent": 0.5, "descent": 0.5})); +} + +#[test] +fn junk_kept_strokes_are_ignored_or_made_safe() { + let node = |kept: Value| { + json!({"name": "x", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "type": "CANVAS", "name": "P", "children": [ + {"id": "1:2", "type": "VECTOR", "name": "V", "size": {"x": 10, "y": 10}, "fills": [], "outlinedStroke": kept}]}]}}) + }; + let stroke = json!({"weight": 1e300, "dashes": vec![1.0; 500], "width_profile": {"points": [{"t": 9.0, "scale": -4.0, "bias": 77.0}]}}); + for kept in [ + json!(null), + json!("stroke"), + json!({"kind": {"type": "page"}, "fills": [], "strokes": [], "stroke": {}}), + json!({"kind": {"type": "instance", "component": 5}, "fills": [], "strokes": [], "stroke": {}}), + json!({"kind": {"type": "line"}, "fills": 5, "strokes": [], "stroke": {}}), + json!({"kind": {"type": "line"}, "fills": [], "strokes": [], "stroke": stroke}), + json!({"kind": {"type": "vector", "network": {"vertices": [{"x": 0.0, "y": 0.0}], "segments": [{"a": 0, "b": 99, "ta": [0.0, 0.0], "tb": [0.0, 0.0]}], "regions": [{"loops": [[7]], "winding": "nonZero"}]}}, + "fills": [], "strokes": [], "stroke": {}}), + ] { + let (doc, _) = crate::rest::import(&node(kept), &|_| None).unwrap(); + let v = doc.nodes().find(|n| n.name == "V").unwrap(); + assert!(matches!(v.kind, NodeKind::Vector { .. } | NodeKind::Line)); + assert!(v.stroke.weight <= 1e6 && v.stroke.dashes.len() <= 64); + if let NodeKind::Vector { network } = &v.kind { + assert!(network.segments.iter().all(|s| s.a < network.vertices.len() && s.b < network.vertices.len())); + } + export(&doc, None).unwrap(); + } +} + +#[test] +fn bound_layer_fields_name_their_property_and_the_field_that_holds_it() { + let nodes = written(&themed()); + let entries = |name: &str| -> BTreeMap { + let list = named(&nodes, name)["variableConsumptionMap"]["entries"].as_array().unwrap(); + list.iter().map(|e| (e["variableField"].as_str().unwrap().to_string(), e["nodeField"].as_u64().unwrap())).collect() + }; + let card = entries("Card"); + // The numbers are those of the layer's own fields in the schema. + for (field, id) in [ + ("STACK_SPACING", 107), + ("STACK_PADDING_LEFT", 209), + ("STACK_PADDING_RIGHT", 233), + ("CORNER_RADIUS", 20), + ("WIDTH", 11), + ("OPACITY", 8), + ("BORDER_TOP_WEIGHT", 295), + ] { + assert_eq!(card.get(field), Some(&id), "{field}"); + } + let title = entries("Title"); + for (field, id) in [("TEXT_DATA", 42), ("FONT_SIZE", 21), ("FONT_FAMILY", 41), ("LINE_HEIGHT", 40)] { + assert_eq!(title.get(field), Some(&id), "{field}"); + } + assert_eq!(entries("Swatch").get("VISIBLE"), Some(&6)); +} + #[test] fn the_schema_numbers_variables_styles_and_instance_data_as_the_format_does() { let s = Schema::parse(export::SCHEMA).unwrap(); diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 3a66282..9bb1a8a 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -101,7 +101,7 @@ enum Axis X=0 Y=1 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 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 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 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 @@ -124,6 +124,7 @@ struct ColorStop Color color, float position struct ParentIndex GUID guid, string position message Image byte[] hash=1, string name=2 message VariableWidthPoint float position=1, float ascent=2, float descent=3 +message PluginData string pluginID=1, string value=2, string key=3 message StyleId GUID guid=1 message VariableID GUID guid=1 message VariableSetID GUID guid=1 @@ -150,7 +151,7 @@ message ComponentPropAssignment GUID defID=1, ComponentPropValue value=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 VariableData VariableAnyValue value=1, VariableDataType dataType=2, VariableResolvedDataType resolvedDataType=3 -message VariableDataMapEntry VariableData variableData=2, VariableField variableField=3 +message VariableDataMapEntry uint nodeField=1, VariableData variableData=2, VariableField variableField=3 message VariableDataMap VariableDataMapEntry[] entries=1 message VariableModeBySetMapEntry VariableSetID variableSetID=1, GUID variableModeID=2 message VariableModeBySetMap VariableModeBySetMapEntry[] entries=1 @@ -165,7 +166,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, 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, 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, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, 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 struct Blob byte[] bytes message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6 "; @@ -782,6 +783,7 @@ impl<'d> Exporter<'d> { (_, "paddingVertical") => &["STACK_PADDING_TOP", "STACK_PADDING_BOTTOM"], (_, "padding") => &["STACK_PADDING_LEFT", "STACK_PADDING_TOP", "STACK_PADDING_RIGHT", "STACK_PADDING_BOTTOM"], (_, "cornerRadius") => &[ + "CORNER_RADIUS", "RECTANGLE_TOP_LEFT_CORNER_RADIUS", "RECTANGLE_TOP_RIGHT_CORNER_RADIUS", "RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS", @@ -815,9 +817,12 @@ impl<'d> Exporter<'d> { continue; } }; + // Each entry names the bound property and the number of the layer field that holds it. for field in fields { if let Some(a) = self.alias(var) { - out.entries.push(o(vec![("variableData", a), ("variableField", e(field))])); + let mut entry: Fields = node_field(field).into_iter().map(|id| ("nodeField", u(id))).collect(); + entry.extend([("variableData", a), ("variableField", e(field))]); + out.entries.push(o(entry)); } } } @@ -865,6 +870,15 @@ impl<'d> Exporter<'d> { /// layers (an instance's come from its main). fn layer(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields, nested: bool) -> bool { let doc = self.doc; + if let Some((outline, original)) = outlined(n) { + // The format's variable width is the same on both sides of the path: a stroke wider on + // one side is written as the area it paints, with the stroke itself kept as plugin data + // (which the import puts back). + self.warn("strokes wider on one side of their path were written as filled outlines (the stroke itself is kept for FigCraft)"); + let children = self.layer(&outline, parent, f, nested); + f.push(("pluginData", arr(vec![o(vec![("pluginID", s(STASH.0)), ("value", s(&original)), ("key", s(STASH.1))])]))); + return children; + } let main = match &n.kind { NodeKind::Instance { component, .. } => { let found = self.ids.contains_key(component) && doc.node(*component).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. })); @@ -1232,14 +1246,16 @@ impl<'d> Exporter<'d> { ("borderLeftWeight", fl(l)), ]); } - // A variable width: how far the stroke reaches on each side of the path (left first, looking - // along it) at positions along its length, as a share of the stroke weight. + // A variable width: how far the stroke reaches on each side of the path at positions along + // its length, as a share of the stroke weight (half of it each for the full weight). The + // two sides are the same: a reader draws nothing for a point with unequal sides. if let Some(p) = sp.profile().filter(|_| sp.weight.is_finite() && sp.weight > 0.0) { - let point = |q: &figcraft_doc::WidthPoint| { - let half = q.scale / 2.0; - o(vec![("position", fl(q.t)), ("ascent", fl(half * (1.0 - q.bias))), ("descent", fl(half * (1.0 + q.bias)))]) - }; - f.push(("variableWidthPoints", arr(p.sanitized().points.iter().map(point).collect()))); + let point = |q: &figcraft_doc::WidthPoint| o(vec![("position", fl(q.t)), ("ascent", fl(q.scale / 2.0)), ("descent", fl(q.scale / 2.0))]); + let p = p.sanitized(); + if one_sided(&p) { + self.warn("strokes wider on one side of their path were written with that width on both sides"); + } + f.push(("variableWidthPoints", arr(p.points.iter().map(point).collect()))); } if sp.brush.is_some() { self.warn("brush strokes were written as plain strokes"); @@ -2124,6 +2140,100 @@ impl<'d> Exporter<'d> { } } +/// The plugin id and key under which a layer written as an outline keeps what it was. +pub(super) const STASH: (&str, &str) = ("figcraft", "stroke"); +/// Longest kept description of such a layer (bytes of JSON). +const MAX_STASH: usize = 1 << 20; + +/// Whether a width profile reaches further on one side of the path than on the other. +fn one_sided(p: &figcraft_doc::WidthProfile) -> bool { + p.points.iter().any(|q| q.bias.abs() > 1e-6) +} + +/// A shape whose stroke is wider on one side of its path, as the area that stroke paints (a vector +/// filled with the stroke's paints), with what it was as JSON: its kind, paints and stroke. Only +/// for shapes that show nothing but that stroke; `None` for every other layer. +fn outlined(n: &Node) -> Option<(Node, String)> { + let sp = &n.stroke; + let shape = matches!( + n.kind, + NodeKind::Line | NodeKind::Vector { .. } | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Rectangle + ); + let shown = |paints: &[Paint]| paints.iter().any(|p| p.visible); + if !shape + || sp.brush.is_some() + || !sp.profile().is_some_and(one_sided) + || !(sp.weight.is_finite() && sp.weight > 0.0) + || !shown(&n.strokes) + || shown(&n.fills) + { + return None; + } + let path = figcraft_doc::geometry::stroke_geometry(n)?; + let net = match &n.kind { + NodeKind::Vector { network } => Some(network), + _ => None, + }; + let area = figcraft_pathops::stroke_outline_of(&path, sp, figcraft_doc::geometry::is_closed(n), net); + if area.elements().is_empty() || area.elements().len() > MAX_NETWORK / 4 { + return None; + } + let original = serde_json::json!({"kind": n.kind, "fills": n.fills, "strokes": n.strokes, "stroke": n.stroke}).to_string(); + if original.len() > MAX_STASH { + return None; + } + let mut network = VectorNetwork::from_path(&area); + for r in &mut network.regions { + r.winding = WindingRule::NonZero; + } + let mut v = n.clone(); + v.kind = NodeKind::Vector { network }; + v.fills = std::mem::take(&mut v.strokes); + v.stroke = Default::default(); + v.styles.fill = v.styles.stroke.take(); + // Bindings name the stroke's paints, which are the fills now: the values stay. + v.bound.retain(|k, _| !(k == "stroke" || k == "fill" || k.starts_with("strokes/") || k.starts_with("fills/") || k.starts_with("stroke"))); + Some((v, original)) +} + +/// The number of the layer field a bound property (`VariableField`) is held in. +fn node_field(field: &str) -> Option { + let name = match field { + "WIDTH" | "HEIGHT" => "size", + "MIN_WIDTH" | "MIN_HEIGHT" => "minSize", + "MAX_WIDTH" | "MAX_HEIGHT" => "maxSize", + "STACK_SPACING" => "stackSpacing", + "STACK_COUNTER_SPACING" => "stackCounterSpacing", + "STACK_PADDING_LEFT" => "stackHorizontalPadding", + "STACK_PADDING_TOP" => "stackVerticalPadding", + "STACK_PADDING_RIGHT" => "stackPaddingRight", + "STACK_PADDING_BOTTOM" => "stackPaddingBottom", + "CORNER_RADIUS" => "cornerRadius", + "RECTANGLE_TOP_LEFT_CORNER_RADIUS" => "rectangleTopLeftCornerRadius", + "RECTANGLE_TOP_RIGHT_CORNER_RADIUS" => "rectangleTopRightCornerRadius", + "RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS" => "rectangleBottomLeftCornerRadius", + "RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS" => "rectangleBottomRightCornerRadius", + "OPACITY" => "opacity", + "VISIBLE" => "visible", + "TEXT_DATA" => "textData", + "STROKE_WEIGHT" => "strokeWeight", + "BORDER_TOP_WEIGHT" => "borderTopWeight", + "BORDER_RIGHT_WEIGHT" => "borderRightWeight", + "BORDER_BOTTOM_WEIGHT" => "borderBottomWeight", + "BORDER_LEFT_WEIGHT" => "borderLeftWeight", + "FONT_SIZE" => "fontSize", + "FONT_FAMILY" | "FONT_STYLE" => "fontName", + "LETTER_SPACING" => "letterSpacing", + "LINE_HEIGHT" => "lineHeight", + "PARAGRAPH_SPACING" => "paragraphSpacing", + "PARAGRAPH_INDENT" => "paragraphIndent", + _ => return None, + }; + // The numbers are the schema's. + let layer = SCHEMA.lines().find(|l| l.starts_with("message NodeChange "))?; + layer.split(", ").find_map(|f| f.rsplit_once(' ')?.1.strip_prefix(name)?.strip_prefix('=')?.parse().ok()) +} + /// Whether `a` is `b` with its lengths scaled by `k`: every number is `k` times the other's (within /// `slack`, for sizes that snap to whole pixels) or the same, and the rest is equal. fn scaled(a: &Kv<'static>, b: &Kv<'static>, k: f64, slack: f64) -> bool { diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 3c794e7..9b21651 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1243,6 +1243,13 @@ fn stroke(o: &mut Obj, v: &View) { if let Some(cap) = v.str("strokeCap") { o.insert("strokeCap".into(), json!(cap_name(cap))); } + // A layer written as the outline of its stroke keeps what it was as plugin data + // (`outlinedStroke`, our extension of the file JSON). + let (id, key) = super::export::STASH; + let kept = v.list("pluginData").iter().find(|p| p.get("pluginID").and_then(Kv::str) == Some(id) && p.get("key").and_then(Kv::str) == Some(key)); + if let Some(original) = kept.and_then(|p| p.get("value")).and_then(Kv::str).and_then(|s| serde_json::from_str::(s).ok()) { + o.insert("outlinedStroke".into(), original); + } // A variable width: each point's reach on the two sides of the path (shares of the stroke // weight), as `[t, scale, bias]` (`strokeWidthProfile`, our extension of the file JSON). let profile: Vec = v diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 52d8704..5c4ed79 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -190,6 +190,38 @@ pub fn import_with(v: &Value, variables: Option<&Value>, images: ImageResolver) Ok((cx.doc, warnings)) } +/// Put back the shape a layer was before the `.fig` export wrote it as the outline of its stroke +/// (`outlinedStroke`: `{kind, fills, strokes, stroke}` as the document holds them). Anything that +/// isn't such a shape is ignored. +fn restore_stroke(n: &mut Node, original: &Value) { + #[derive(serde::Deserialize)] + struct Original { + kind: NodeKind, + fills: Vec, + strokes: Vec, + stroke: figcraft_doc::StrokeProps, + } + let Ok(mut o) = serde_json::from_value::(original.clone()) else { return }; + match &mut o.kind { + NodeKind::Vector { network } => { + if network.vertices.len() + network.segments.len() > MAX_PATH_ELEMENTS { + return; + } + network.sanitize(); + } + NodeKind::Line | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Rectangle => {} + _ => return, + } + let sp = &mut o.stroke; + sp.weight = sp.weight.clamp(0.0, props::MAX_SIZE); + sp.width_profile = sp.width_profile.take().map(|p| p.sanitized()); + sp.brush = sp.brush.take().map(|b| b.sanitized()); + sp.dashes.truncate(64); + o.fills.truncate(64); + o.strokes.truncate(64); + (n.kind, n.fills, n.strokes, n.stroke) = (o.kind, o.fills, o.strokes, o.stroke); +} + fn children(v: &Value) -> &[Value] { v.get("children").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() } @@ -532,6 +564,9 @@ impl<'a> Ctx<'a> { self.overlay_meta.entry(rid).or_insert(j); } self.bindings(n, j); + if let Some(original) = j.get("outlinedStroke") { + restore_stroke(n, original); + } } /// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, our diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8e3913a..7183c82 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -49,7 +49,7 @@ and prototype interactions are still unchecked there. | WebP | — | yes (ours) | | — | | Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — | | Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML and React | see [dev-mode-parity](dev-mode-parity.md) | — | -| `.fig` ("Save local copy") | yes | **experimental** | the whole file; see [Export to `.fig`](#export-to-fig-experimental). Round trips through our own importer; written files checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), and colour styles are listed | 5–15 (checks of the rest of styles and bindings, effects, images and prototypes, and against real files) | +| `.fig` ("Save local copy") | yes | **experimental** | the whole file; see [Export to `.fig`](#export-to-fig-experimental). Round trips through our own importer; written files checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width | 5–15 (non-colour bindings as bindings, checks of effects, images and prototypes, and against real files) | ### Export to `.fig` (experimental) @@ -94,7 +94,9 @@ each. padding, radii, stroke weights, opacity, visibility, characters, font and paragraph settings), and the modes frames and pages set; shared paint, text, effect and grid styles as nodes on that page with the links from the layers that use them (unused styles too); variable-width strokes (each - point's reach on the two sides of the path, as a share of the stroke weight); prototype + point's reach on either side of the path as a share of the stroke weight, half of it for the + full weight; a stroke wider on one side, on a shape that shows nothing else, as a vector filled + with the area it paints); prototype interactions (clicks, hovers, presses, drags, mouse triggers, delays, keys with modifiers and controller buttons; navigate, overlays with their position, outside-click setting and background, swap, change to, scroll to, back, close, open link; transitions and easing, custom @@ -105,7 +107,8 @@ each. collections (written as collections of their own), comments, Dev Mode annotations, statuses, resources and code links, plugin data, ruler guides, prototype device settings, slot properties and slot content, brush - strokes (written plain), pattern fills, WebP export settings (written as PNG), + strokes (written plain), strokes wider on one side of their path (written as filled outlines, or + with the width on both sides when the shape has a fill too), pattern fills, WebP export settings (written as PNG), 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, @@ -120,9 +123,9 @@ each. height that is a share of the font size is written as a raw multiplier, since percent units count from the font's own line height); the import now reads per-side stroke weights that leave the sides without a border out. Then (2026-10-13), on the example documents and small test files: - resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), and colour styles are listed; a nested instance swapped for another variant shows that variant at its size. - Still unchecked there: text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes, effects such as - glass and noise, images, prototype interactions. Text boxes sized to their content can come out + resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; a nested instance swapped for another variant shows that variant at its size. + 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. - **Round trips through our own importer:** 60 generated documents (every layer kind above, components, nested instances with overrides, a grid inside a component, @@ -135,7 +138,7 @@ each. outlines, text settings, schema numbering, font style names, line height units, mask types) has its own tests (`crates/format/src/fig/interop_tests.rs`). Variables, modes, every kind of binding, styles, resized, scaled and swapped instances and variable-width strokes are checked field by - field as written and compared after reading back, files with scrambled values never panic + field as written and compared after reading back, files with scrambled values and junk kept strokes never panic (`crates/format/src/fig/assets_tests.rs`), and every example document keeps its variables, styles and links through `file.export` and `file.open` (`crates/engine/src/fig_export_assets_tests.rs`). @@ -152,7 +155,7 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), and colour styles are listed; text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes are not yet checked there. Numbers unchanged | +| 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; 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). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | | 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder that mirrors the decoder and the schema of the fields written; what's left out is a warning. The import gained ellipse arcs, boolean operation types and component set descriptions, and reads durations as whole milliseconds. Verified only by round trips through our own importer. Summary ~59% → ~60%; `.fig` write 15–30 h → 5–15 h (checks against real files) | | 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Summary ~55% → ~56%; SVG options 1–2 h → done | diff --git a/docs/gaps.md b/docs/gaps.md index 0280fce..34c4f43 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -58,7 +58,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe the format does, to write outlines, text settings, standard font style names and line height units. Then (2026-10-13): instances keep their own size (an override addressed by the main's id, derived data for their layers, a scale factor), variables with modes and bindings, shared styles - and variable-width strokes are written and read back; checked there: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), and colour styles are listed; text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes are not yet checked there, + and variable-width strokes are written and read back; checked there: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; 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), like effects such as glass and noise, images and prototype interactions. - **Missing:** connector labels, elbowed or curved connectors without a saved path (drawn straight), table cells beyond their own layers, filled areas of a vector with their own fills, per-point stroke @@ -394,7 +394,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| -| 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), and colour styles are listed; text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes are not yet checked there. Hours and the ranking unchanged | +| 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; 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). Hours and the ranking unchanged | | 2026-10-12 | minor | Gap 2: your online files landed (the app records the files it put on a server with their owner tokens, Home ▸ Online lists them and opens them again as their owner, with sharing, rename, removal and forget from the list; one request per server tells which are still there; tabs mark files in a live session). What is left is unchanged: accounts, teams and per-person permissions, shared comments and versions. Hours unchanged | | 2026-10-12 | minor | Gap 2: sharing with a join code (Share ▸ Online or Local network, one Start sharing button, a short code to give others, one join field for a code or a link; the server names links with codes and limits wrong ones) added to the landed list; usability, so the estimate and ranking are unchanged | | 2026-10-12 | minor | Gap 3: first measured fidelity data. Text box sizes and auto layout results of our example documents were compared with the same documents re-saved by other software; three text rules (whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only) were found and applied, and all 230 self-sizing text boxes in 7 documents now match to the pixel (one font family). Real files are still unchecked: hours and the ranking unchanged |