Merge fix/rest-grid-tracks into batch 15

This commit is contained in:
bflatastic
2026-10-17 23:17:37 +07:00
4 changed files with 121 additions and 6 deletions
@@ -0,0 +1,42 @@
//! REST import of grid track sizes (`gridColumnsSizing`, `gridRowsSizing`: CSS
//! `grid-template-columns`/`-rows` strings). Fixtures are synthetic, written by hand.
use super::*;
use figcraft_doc::GridTrack;
use serde_json::json;
fn tracks(columns: Value, rows: Value) -> (Vec<GridTrack>, Vec<GridTrack>) {
let file = json!({"name": "Fixture", "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
{"id": "1", "name": "Grid", "type": "FRAME", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 400, "y": 200},
"layoutMode": "GRID", "gridColumnCount": 7, "gridRowCount": 2, "gridColumnsSizing": columns, "gridRowsSizing": rows}
]}
]}});
let (doc, _) = import(&file, &no_images).unwrap();
let layout = doc.nodes().find(|n| n.name == "Grid").unwrap().layout.clone().unwrap();
(layout.column_tracks, layout.row_tracks)
}
#[test]
fn css_track_strings_set_the_track_sizes() {
let (cols, rows) = tracks(json!("120px 2fr auto"), json!("repeat(2, minmax(0, 1fr))"));
assert_eq!(cols, [GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()]);
assert_eq!(rows, [GridTrack::fill(1.0); 2]);
let (cols, rows) = tracks(json!(" repeat(2, 40px 1.5fr) minmax(64px, 64px) "), json!("min-content max-content"));
assert_eq!(cols, [GridTrack::fixed(40.0), GridTrack::fill(1.5), GridTrack::fixed(40.0), GridTrack::fill(1.5), GridTrack::fixed(64.0)]);
assert_eq!(rows, [GridTrack::hug(); 2]);
}
#[test]
fn unsupported_or_junk_tracks_are_equal_tracks() {
// Ranges the grid can't express, units it doesn't have and junk keep their place as 1fr.
let (cols, rows) = tracks(json!("minmax(10px, 80px) 5em -3px 0fr NaNpx repeat(x, 1px) 10px"), json!(7));
assert_eq!(cols, [[GridTrack::default(); 6].as_slice(), &[GridTrack::fixed(10.0)]].concat());
assert!(rows.is_empty(), "a non-string is ignored");
// Tracks past the grid's own count are left out; huge repeats and deep nesting stay bounded.
let (cols, rows) = tracks(json!(format!("repeat(999999999999, 1px) {}", "minmax(".repeat(10_000))), json!("1px 2px 3px"));
assert_eq!(cols, [GridTrack::fixed(1.0); 7]);
assert_eq!(rows, [GridTrack::fixed(1.0), GridTrack::fixed(2.0)]);
let (cols, rows) = tracks(json!("1e9px"), json!("( ) )) repeat(2,)"));
assert_eq!((cols, rows.len()), (vec![GridTrack::default()], 2));
}
+2
View File
@@ -29,6 +29,8 @@ mod fig_sources;
#[cfg(test)]
mod gamepad_tests;
#[cfg(test)]
mod grid_track_tests;
#[cfg(test)]
mod import_regression_tests;
pub(crate) mod props;
pub(crate) mod proto;
+74 -4
View File
@@ -424,6 +424,7 @@ pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option<AutoLayout> {
let pad = |k: &str| len(v, k).unwrap_or(0.0);
let count = |k: &str, d: u32| num(v, k).filter(|n| *n >= 1.0).map_or(d, |n| n.min(1000.0) as u32);
let grid = mode == LayoutMode::Grid;
let (columns, rows) = (count("gridColumnCount", 2), count("gridRowCount", 2));
Some(AutoLayout {
mode,
wrap: text(v, "layoutWrap") == Some("WRAP"),
@@ -448,15 +449,84 @@ pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option<AutoLayout> {
Some("SPACE_BETWEEN") => ContentAlign::SpaceBetween,
_ => ContentAlign::Auto,
},
grid_columns: count("gridColumnCount", 2),
grid_rows: count("gridRowCount", 2),
grid_columns: columns,
grid_rows: rows,
strokes_included: flag(v, "strokesIncludedInLayout").unwrap_or(false),
reverse_z: flag(v, "itemReverseZIndex").unwrap_or(false),
column_tracks: if fig_tracks && grid { fig_grid_tracks(v, "columnTracks") } else { Vec::new() },
row_tracks: if fig_tracks && grid { fig_grid_tracks(v, "rowTracks") } else { Vec::new() },
column_tracks: grid_tracks(v, grid, fig_tracks, "columnTracks", "gridColumnsSizing", columns),
row_tracks: grid_tracks(v, grid, fig_tracks, "rowTracks", "gridRowsSizing", rows),
})
}
/// A grid axis's track sizes: from the `.fig` source when there is one, else the API's CSS
/// `grid-template-*` string (at most `count` tracks, the axis's own).
fn grid_tracks(v: &Value, grid: bool, fig_tracks: bool, fig_axis: &str, css_key: &str, count: u32) -> Vec<GridTrack> {
match (grid, fig_tracks) {
(false, _) => Vec::new(),
(true, true) => fig_grid_tracks(v, fig_axis),
(true, false) => text(v, css_key).map(|css| css_grid_tracks(css, count as usize)).unwrap_or_default(),
}
}
/// The first `max` tracks of a CSS `grid-template-columns`/`-rows` value: `120px`, `2fr`, `auto`
/// (hug), `minmax(0, 1fr)` and `repeat(3, …)`; any other track is an equal (1fr) one.
fn css_grid_tracks(css: &str, max: usize) -> Vec<GridTrack> {
let mut out = Vec::new();
for item in css_split(css, char::is_whitespace) {
let repeated = item.strip_prefix("repeat(").and_then(|r| r.strip_suffix(')')).and_then(|r| {
let (count, tracks) = r.split_once(',')?;
Some((count.trim().parse::<usize>().ok()?, css_split(tracks, char::is_whitespace)))
});
let (count, tracks) = repeated.unwrap_or((1, vec![item]));
for t in tracks.iter().cycle().take(count.saturating_mul(tracks.len())) {
if out.len() >= max {
return out;
}
out.push(css_track(t));
}
}
out
}
fn css_track(t: &str) -> GridTrack {
let Some(args) = t.strip_prefix("minmax(").and_then(|r| r.strip_suffix(')')) else { return css_size(t).unwrap_or_default() };
// A flexible maximum is the track's share whatever its minimum; other ranges only when both ends agree.
match css_split(args, |c| c == ',').iter().map(|a| css_size(a.trim())).collect::<Vec<_>>()[..] {
[_, Some(max)] if max.sizing == Sizing::Fill => max,
[Some(min), Some(max)] if min == max => min,
_ => GridTrack::default(),
}
}
fn css_size(t: &str) -> Option<GridTrack> {
let n = |s: &str| s.parse::<f64>().ok().filter(|n| n.is_finite() && (0.0..=MAX_SIZE).contains(n));
if let Some(px) = t.strip_suffix("px").and_then(n) {
Some(GridTrack::fixed(px))
} else if let Some(fr) = t.strip_suffix("fr").and_then(n) {
(fr > 0.0).then(|| GridTrack::fill(fr))
} else {
matches!(t, "auto" | "min-content" | "max-content").then(GridTrack::hug)
}
}
/// `s` split at the `sep` characters outside parentheses, empty parts dropped.
fn css_split(s: &str, sep: fn(char) -> bool) -> Vec<&str> {
let (mut parts, mut depth, mut start) = (Vec::new(), 0usize, 0);
for (i, c) in s.char_indices() {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
c if depth == 0 && sep(c) => {
parts.extend(s.get(start..i).filter(|p| !p.trim().is_empty()));
start = i + c.len_utf8();
}
_ => {}
}
}
parts.extend(s.get(start..).filter(|p| !p.trim().is_empty()));
parts
}
fn fig_grid_tracks(v: &Value, axis: &str) -> Vec<GridTrack> {
v.get(crate::rest::FIG_SOURCE_FIELD)
.and_then(|source| source.get("gridTracks"))
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (`.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -19,7 +19,7 @@ and prototype interactions are still unchecked there.
|---|---|---|---|---|---|
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
| `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — |
| Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) |
@@ -165,6 +165,7 @@ each.
| Date | Change | Summary |
|---|---|---|
| 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged |
| 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged |
| 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged |
| 2026-10-17 | minor | Instance layers' own size and resizing (#731): a `.fig` import keeps the sizes an instance gives its layers and their hug/fill/fixed and min/max as overrides, a REST import reads the resizing fields of `overrides`, and a `.fig` export writes a layer's own size as an override (it went with the derived layout, which a relayout replaces). Numbers unchanged |