diff --git a/Cargo.lock b/Cargo.lock
index cdbb2cb..5e2cfdc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1415,6 +1415,7 @@ dependencies = [
"env_logger",
"figcraft-collab-net",
"figcraft-engine",
+ "figcraft-format",
"figcraft-text",
"figcraft-ui-egui",
"image",
@@ -1530,7 +1531,9 @@ name = "figcraft-format"
version = "0.3.1"
dependencies = [
"figcraft-doc",
+ "flate2",
"image",
+ "ruzstd",
"serde",
"serde_json",
"sha2",
@@ -3907,6 +3910,12 @@ dependencies = [
"unicode-script",
]
+[[package]]
+name = "ruzstd"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
+
[[package]]
name = "ryu"
version = "1.0.23"
diff --git a/Cargo.toml b/Cargo.toml
index 27c61ec..ba84d06 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -52,6 +52,9 @@ harfrust = "0.13"
unicode-bidi = "0.3"
linesweeper = "0.4"
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", default-features = false, features = ["std"] }
sha2 = "0.10"
base64 = "0.22"
crc32fast = "1"
diff --git a/README.md b/README.md
index 517fb67..96b7557 100644
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
π Offline and private
- Your files are plain .figcraft archives on your own disk. No account, no cloud, no telemetry. SVG, PNG, JPG, WebP and PDF in and out; designs exported as the public REST API file JSON import too, with their prototypes and variables.
+ Your files are plain .figcraft archives on your own disk. No account, no cloud, no telemetry. SVG, PNG, JPG, WebP and PDF in and out; .fig files open (best effort), and so do designs exported as the public REST API file JSON, with their prototypes and variables.
|
π€ Agent-ready
@@ -169,7 +169,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
-> **Status:** FigCraft is in **alpha**: about 47% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft can't open `.fig` files yet, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
+> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
## The Crafting Apps
diff --git a/ROADMAP.md b/ROADMAP.md
index 9099e52..5a3e93a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,8 +1,8 @@
# FigCraft Roadmap
-**Stage: alpha** Β· next: beta, ~28 points and ~195β355 h away (one Opus 5.5 agent; ~40β80 h wall-clock with 5 agents)
+**Stage: alpha** Β· next: beta, ~26 points and ~173β310 h away (one Opus 5.5 agent; ~35β70 h wall-clock with 5 agents)
-> **Last reviewed:** 2026-10-10 Β· **Last updated:** 2026-10-11 Β· **Change:** minor (layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) Β· **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 Β· **Last updated:** 2026-10-11 Β· **Change:** minor (layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) Β· **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -14,20 +14,20 @@ start with [`docs/gaps.md`](docs/gaps.md).
| | Value | Kind |
|---|---|---|
| Menu/command breadth | **100%** (204 / 204) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
-| Feature breadth | **~79%** (core editor ~90%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
-| Ready for real work | **~47%** | estimated, same doc (written dimension weights and scores) |
-| Essentials user | **~53%** | estimated: core features 77.9% Γ 0.92 launch/stability Γ 0.92 discoverability Γ 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
-| Mainstream practitioner | **~43%** | estimated: weekly areas 67.1% Γ 0.9 interaction Γ 0.9 stability Γ 0.8 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
-| Remaining to beta | **~195β355 h** one agent | estimated |
-| Remaining to full parity | **~344β663 h** one agent (~92β160 h wall-clock with 5 agents) | estimated |
+| Feature breadth | **~80%** (core editor ~91%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
+| Ready for real work | **~49%** | estimated, same doc (written dimension weights and scores) |
+| Essentials user | **~57%** | estimated: core features 77.8% Γ 0.92 launch/stability Γ 0.92 discoverability Γ 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
+| Mainstream practitioner | **~46%** | estimated: weekly areas 68.1% Γ 0.9 interaction Γ 0.9 stability Γ 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
+| Remaining to beta | **~173β310 h** one agent | estimated |
+| Remaining to full parity | **~322β618 h** one agent (~86β150 h wall-clock with 5 agents) | estimated |
### Readiness by audience
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
-| Full target (ready for real work) | ~47% | ~327β629 h | multiplayer (45β90), localization (45β75), plugins (15β40), the interaction pass (40β60), `.fig` import (30β60), GPU canvas (30β60), AI (30β60) |
-| Mainstream practitioner | ~43% | ~162β304 h | core editor depth incl. `.fig` import (80β157), the interaction pass (40β60), real-file corpus and platform QA (25β50), `.fig` export for handing work back (15β30) |
-| Essentials user | ~53% | ~85β160 h | `.fig` import so files people send open (30β60), core tool polish (25β45), interaction pass on the core tools (15β25), stability QA (10β20), onboarding and starter templates (5β10) |
+| Full target (ready for real work) | ~49% | ~305β584 h | multiplayer (45β90), localization (45β75), plugins (15β40), the interaction pass (40β60), GPU canvas (30β60), AI (30β60), the `.fig` import's tail (8β15) |
+| Mainstream practitioner | ~46% | ~140β259 h | core editor depth incl. the `.fig` import's tail (58β112), the interaction pass (40β60), real-file corpus and platform QA (25β50), `.fig` export for handing work back (15β30) |
+| Essentials user | ~57% | ~63β115 h | the `.fig` import's tail so the files people send open well (8β15), core tool polish (25β45), interaction pass on the core tools (15β25), stability QA (10β20), onboarding and starter templates (5β10) |
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
per 1,000 added lines, 1β2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see
@@ -45,9 +45,9 @@ lines, a typical feature PR 1β2.5 h). Details and what needs a human:
**Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components
with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but
-FigCraft can't open Figma's own `.fig` files, has only a preview of multiplayer, and nothing has been checked
-against real Figma documents. Ready-for-real-work is ~47%, inside alpha's ~40β75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is missing, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs
-~75% **and** reliable `.fig` import: the `.fig` reader (30β60 h), a real-file corpus with measured
+FigCraft opens `.fig` files only best effort, has only a preview of multiplayer, and nothing has been checked
+against real Figma documents. Ready-for-real-work is ~49%, inside alpha's ~40β75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs
+~75% **and** reliable `.fig` import: the `.fig` import's tail (8β15 h), a real-file corpus with measured
fidelity (15β30 h), the interaction pass (25β40 h of the 40β60), core depth to ~80% (40β75 h),
basic multiplayer on the landed core (30β55 h), stability and platform QA (20β40 h), a partial GPU path (15β30 h) and
Figma's own languages on the landed localization layer (15β25 h).
@@ -58,7 +58,7 @@ Figma's own languages on the landed localization layer (15β25 h).
|---|---|---|---|
| Features (depth) | 57% (core editor 67%) | 59β112 | [target-app-parity](docs/target-app-parity.md) |
| UI/UX fidelity | 58% | 40β60 | [ui-parity](docs/ui-parity.md) |
-| File formats | 40% (no `.fig`) | 30β60 | [file-format-parity](docs/file-format-parity.md) |
+| File formats | 55% (`.fig` read best effort, no write) | 8β15 | [file-format-parity](docs/file-format-parity.md) |
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49β98 | [collaboration-parity](docs/collaboration-parity.md) |
| Stability | 55% | 20β40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30β60 | [hardware-parity](docs/hardware-parity.md) |
@@ -120,7 +120,7 @@ shaping, IME) already works. Detail: [`docs/localization-parity.md`](docs/locali
Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
-1. `.fig` import, best effort, with a synthetic `.fig` corpus: 30β60 h.
+1. `.fig` import's tail: editable vector networks, the skipped layer kinds that matter, fixes from real files (the best-effort import landed 2026-10-11): 8β15 h.
2. Real-file corpus and measured fidelity: 15β30 h.
3. Tool-by-tool interaction pass against Figma: 40β60 h.
4. Languages on the landed localization layer: ja, ko, pt-BR, fr, de, and a native review of es: 15β30 h.
@@ -128,6 +128,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
+- **2026-10-11** Β· `.fig` import, best effort (gap 1): `file.open`, `file.import`, drag and drop, the CLI and
+ MCP open `.fig` files. A generic kiwi decoder reads each file's own schema, and the decoded nodes map by field name
+ onto the REST importer: layers, paints and styles, text runs, vectors as outlines, auto layout, components and
+ instances (expanded with their overrides and property values), variables, prototyping and images from the archive;
+ the rest is a warning. File formats 55%, ready ~49%.
- **2026-10-11** Β· Gamut warnings (gap 18): the colour picker of a Display P3 file flags colours
outside sRGB and View βΈ Soft-proof sRGB stripes them on the canvas. Showing P3 itself waits on
eframe/egui-wgpu exposing the surface colour space wgpu 30 already supports.
@@ -190,7 +195,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
-| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`β¦, bindable fields in the Design panel and REST import). Layers panel 95%/78% β 98%/82%, 2β4 h β 1β2 h; Strokes 90%/72% β 95%/75%, 1β3 h β 1β2 h; feature breadth (~79%), ready for real work (~47%), essentials (~53%) and mainstream (~43%) unchanged at this rounding; full parity ~345β666 h β ~344β663 h |
+| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`β¦, bindable fields in the Design panel and REST import). Layers panel 95%/78% β 98%/82%, 2β4 h β 1β2 h; Strokes 90%/72% β 95%/75%, 1β3 h β 1β2 h; mainstream depth 67.8% β 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~323β621 h β ~322β618 h |
+| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 in the desktop app and through MCP; eight fixes: recovery copies of unsaved documents reopen after a crash or a forced quit (`file.recover`), Ctrl moves and deletes by word in text on Windows and Linux, scrubbed fields keep their value, typing on a field Tab reached edits it, Escape closes dialogs, linear gradients keep square bands, panel hints wrap, the window-close question and "Open in new window" are translated. Percentages and hours unchanged |
| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% β 98%/74%, 6β12 h β 3β6 h; percentages unchanged at this rounding; full parity ~348β672 h β ~345β666 h |
| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View βΈ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13β27 h β 12β26 h; gap 18 6β12 h β 5β11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363β697 h β ~362β696 h |
| 2026-10-11 | minor | Several windows (gap 18): File βΈ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% β 60%, 13β27 h β 10β21 h; gap 18 6β12 h β 4β8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363β697 h β ~360β691 h |
diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs
index d5cb0ee..9337b97 100644
--- a/apps/figcraft-cli/src/main.rs
+++ b/apps/figcraft-cli/src/main.rs
@@ -26,9 +26,10 @@ USAGE:
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
figcraft-cli help | --help | --version
-Files: .figcraft (native), .json (FigCraft JSON), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image
-layer). `export` writes one file per top-level frame into the directory, or one file when
-has an image extension or --node is given; PDFs take print options (a bleed like 3mm or 0.125in,
+Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
+wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file
+per top-level frame into the directory, or one file when has an image extension or --node
+is given; PDFs take print options (a bleed like 3mm or 0.125in,
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
See docs/cli.md and docs/mcp.md.";
diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml
index c534d94..b94b17b 100644
--- a/apps/figcraft/Cargo.toml
+++ b/apps/figcraft/Cargo.toml
@@ -14,6 +14,7 @@ workspace = true
figcraft-engine = { workspace = true }
figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
+figcraft-format = { workspace = true }
figcraft-text = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs
index 83f08ac..6b2b89a 100644
--- a/apps/figcraft/src/main.rs
+++ b/apps/figcraft/src/main.rs
@@ -11,10 +11,13 @@
mod args;
mod control_server;
+mod recovery;
mod services;
#[cfg(test)]
mod args_tests;
+#[cfg(test)]
+mod recovery_tests;
use std::sync::{Arc, Mutex};
@@ -30,6 +33,8 @@ struct App {
/// The window title last sent: a viewport command asks for another frame, so it's only sent
/// when the title changes (sending it every frame kept an idle window redrawing).
title: String,
+ /// Shared with the services, which write the copies after a panic.
+ recovery: recovery::Shared,
}
impl eframe::App for App {
@@ -56,6 +61,10 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
self.title = title;
}
+ let now = ctx.input(|i| i.time);
+ if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() {
+ ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
+ }
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
@@ -73,6 +82,9 @@ impl eframe::App for App {
fn on_exit(&mut self) {
with_app(|fig| services::save_state(fig));
+ if let Some(r) = recovery::lock(&self.recovery).take() {
+ r.finish();
+ }
}
}
@@ -112,7 +124,11 @@ fn main() -> eframe::Result {
log::error!("{f}: {e}");
}
}
- let svc = services::Desktop::load();
+ // Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
+ let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
+ let recovered = recovery_root.as_deref().map_or(0, |r| recovery::recover(r, &mut session));
+ let recovery: recovery::Shared = Arc::new(Mutex::new(recovery_root.as_deref().and_then(recovery::Recovery::start)));
+ let svc = services::Desktop::load(recovery.clone());
if let Some(prefs) = svc.prefs.clone() {
session.prefs = prefs;
}
@@ -125,6 +141,7 @@ fn main() -> eframe::Result {
}
let mut fig = FigApp::new(session, Box::new(svc));
fig.ui.home = false;
+ fig.note_recovered(recovered);
if let Some(link) = &args.join {
figcraft_ui_egui::live::open_invite(&mut fig, link);
}
@@ -152,5 +169,5 @@ fn main() -> eframe::Result {
}
let options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
figcraft_ui_egui::windows::install(fig);
- eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new() }))))
+ eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))))
}
diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs
new file mode 100644
index 0000000..f45fc9b
--- /dev/null
+++ b/apps/figcraft/src/recovery.rs
@@ -0,0 +1,163 @@
+//! Recovery copies, so a crash, a forced quit or a power cut loses at most a few seconds of work.
+//!
+//! While the app runs, each document with unsaved changes is copied to `Recovery//.figcraft`
+//! in the config folder, in the background, at most every [`INTERVAL`] seconds, and the copy goes
+//! once the document is saved or closed. The app holds a lock on its folder. At startup, the folders
+//! whose lock is free belong to runs that ended without closing: their copies open again as
+//! unsaved documents (`file.recover`) and the folder goes. Closing the app normally removes its own.
+
+use std::collections::HashMap;
+use std::fs::File;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex, MutexGuard};
+use std::thread::JoinHandle;
+
+use figcraft_engine::Session;
+use serde_json::json;
+
+/// Seconds between copies of a document that keeps changing.
+pub const INTERVAL: f64 = 10.0;
+
+const LOCK: &str = "lock";
+
+/// The app's recovery, shared by its frame loop and its services (the panic path).
+pub type Shared = Arc>>;
+
+pub fn lock(r: &Shared) -> MutexGuard<'_, Option> {
+ r.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
+}
+
+pub struct Recovery {
+ dir: PathBuf,
+ /// Held while the app runs: a free lock means the run that wrote the folder is gone.
+ _lock: File,
+ /// The revision each document was last copied at, and the write in flight.
+ written: HashMap>)>,
+ /// When (UI clock) the next copies are due.
+ next: f64,
+}
+
+impl Recovery {
+ /// Take this run's folder under `root` (`None` when it can't be created or locked: no copies).
+ pub fn start(root: &Path) -> Option {
+ let dir = root.join(std::process::id().to_string());
+ std::fs::create_dir_all(&dir).ok()?;
+ let lock = File::create(dir.join(LOCK)).ok()?;
+ lock.try_lock().ok()?;
+ Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0 })
+ }
+
+ /// Copy the documents that changed since their last copy and drop the copies of documents that
+ /// were saved or closed. Returns how long until it needs to run again (`None`: nothing pending,
+ /// an idle window can sleep).
+ pub fn tick(&mut self, s: &Session, now: f64) -> Option {
+ let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision));
+ if now < self.next {
+ return pending.then_some(self.next - now);
+ }
+ self.write(s, false);
+ self.next = now + INTERVAL;
+ None
+ }
+
+ /// Copy every document with unsaved changes; `wait`: before returning (after a panic).
+ pub fn write(&mut self, s: &Session, wait: bool) {
+ for d in &s.docs {
+ let path = self.dir.join(format!("{}.figcraft", d.uid));
+ if !needs_copy(d) {
+ if self.written.remove(&d.uid).is_some() {
+ let _ = std::fs::remove_file(&path);
+ }
+ continue;
+ }
+ let entry = self.written.entry(d.uid).or_insert((0, None));
+ let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished());
+ if entry.0 == d.revision || busy {
+ continue;
+ }
+ let doc = d.doc.clone();
+ let job = move || write_atomic(&path, &doc);
+ if wait {
+ job();
+ entry.1 = None;
+ } else {
+ entry.1 = std::thread::Builder::new().name("recovery".into()).spawn(job).ok();
+ }
+ entry.0 = d.revision;
+ }
+ // Closed documents.
+ let open: Vec = s.docs.iter().map(|d| d.uid).collect();
+ let dir = &self.dir;
+ self.written.retain(|uid, _| {
+ let keep = open.contains(uid);
+ if !keep {
+ let _ = std::fs::remove_file(dir.join(format!("{uid}.figcraft")));
+ }
+ keep
+ });
+ }
+
+ /// A normal quit: the documents were saved or their changes discarded on purpose.
+ pub fn finish(self) {
+ let Self { dir, _lock, written, .. } = self;
+ // Windows removes a folder only once nothing in it is open: copies being written first.
+ for h in written.into_values().filter_map(|w| w.1) {
+ let _ = h.join();
+ }
+ drop(_lock);
+ let _ = std::fs::remove_dir_all(dir);
+ }
+}
+
+/// A document whose changes would be lost (a branch tab's edits are in its main file).
+fn needs_copy(d: &figcraft_engine::DocState) -> bool {
+ d.is_dirty() && d.branch_of.is_none()
+}
+
+fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document) {
+ let Ok(bytes) = figcraft_format::save_bytes(doc, None) else { return };
+ let tmp = path.with_extension("tmp");
+ if std::fs::write(&tmp, bytes).is_ok() {
+ let _ = std::fs::rename(&tmp, path);
+ }
+}
+
+/// Open the copies left by runs that ended without closing (folders under `root` whose lock is
+/// free, and copies directly in `root` from older versions) as unsaved documents, then remove them.
+/// A copy that can't be read stays where it is. Returns how many documents came back.
+pub fn recover(root: &Path, s: &mut Session) -> usize {
+ let Ok(entries) = std::fs::read_dir(root) else { return 0 };
+ let mut n = 0;
+ let mut open = |file: &Path, s: &mut Session| {
+ if file.extension().is_some_and(|e| e == "figcraft") && s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
+ n += 1;
+ let _ = std::fs::remove_file(file);
+ }
+ };
+ for e in entries.flatten() {
+ let path = e.path();
+ if !path.is_dir() {
+ open(&path, s);
+ continue;
+ }
+ // Still running (another window of the app), or not ours to take.
+ let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue };
+ if lock.try_lock().is_err() {
+ continue;
+ }
+ let files: Vec = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
+ for f in &files {
+ if f.extension().is_some_and(|e| e == "tmp") {
+ // A copy cut off mid-write; the one before it is still there.
+ let _ = std::fs::remove_file(f);
+ } else {
+ open(f, s);
+ }
+ }
+ drop(lock);
+ // Empty now unless a copy couldn't be read (kept for the next start).
+ let _ = std::fs::remove_file(path.join(LOCK));
+ let _ = std::fs::remove_dir(&path);
+ }
+ n
+}
diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs
new file mode 100644
index 0000000..9556031
--- /dev/null
+++ b/apps/figcraft/src/recovery_tests.rs
@@ -0,0 +1,64 @@
+//! Recovery copies: a run that ends without closing leaves copies of its unsaved documents that the
+//! next start opens again (a running app's are left alone); a normal quit leaves nothing.
+
+use figcraft_engine::Session;
+use serde_json::json;
+
+use crate::recovery::{Recovery, recover};
+
+fn root(name: &str) -> std::path::PathBuf {
+ let r = std::env::temp_dir().join(format!("figcraft-recovery-{name}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&r);
+ r
+}
+
+fn edited() -> Session {
+ let mut s = Session::new();
+ s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
+ s
+}
+
+#[test]
+fn a_crashed_runs_unsaved_documents_come_back() {
+ let root = root("crash");
+ let s = edited();
+ let mut r = Recovery::start(&root).unwrap();
+ r.write(&s, true);
+ // While it runs, another start leaves its copies alone.
+ let mut other = Session::empty();
+ assert_eq!(recover(&root, &mut other), 0);
+ // It ends without closing (the lock goes with the process).
+ drop(r);
+ let mut next = Session::empty();
+ assert_eq!(recover(&root, &mut next), 1);
+ let ds = next.docs.first().unwrap();
+ assert!(ds.is_dirty() && ds.path.is_none());
+ assert_eq!(next.doc().unwrap().node(next.doc().unwrap().pages[0]).map(|p| p.children.len()), Some(1));
+ assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone");
+ let _ = std::fs::remove_dir_all(&root);
+}
+
+#[test]
+fn copies_follow_saves_and_a_normal_quit_leaves_nothing() {
+ let root = root("quit");
+ let mut s = edited();
+ let mut r = Recovery::start(&root).unwrap();
+ r.write(&s, true);
+ let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
+ let copies =
+ |d: &std::path::Path| std::fs::read_dir(d).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count();
+ assert_eq!(copies(&dir), 1);
+ // Saved: the copy goes.
+ s.ds_mut().unwrap().mark_saved();
+ r.write(&s, true);
+ assert_eq!(copies(&dir), 0);
+ // Changes pending while the interval runs: it asks to be woken for them.
+ s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap();
+ assert!(r.tick(&s, 0.0).is_none());
+ assert!(r.tick(&s, 1.0).is_none(), "copied at 0 s: nothing new");
+ s.execute("shape.rectangle", &json!({"x": 9, "y": 0, "width": 4, "height": 4})).unwrap();
+ assert!(r.tick(&s, 1.0).is_some_and(|w| w > 0.0));
+ r.finish();
+ assert!(!dir.exists(), "a normal quit removes the copies");
+ let _ = std::fs::remove_dir_all(&root);
+}
diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs
index 7e75180..eb09d0c 100644
--- a/apps/figcraft/src/services.rs
+++ b/apps/figcraft/src/services.rs
@@ -15,9 +15,11 @@ pub struct Desktop {
reduce_motion: Arc,
/// The system's interface language (read at startup).
language: Option,
+ /// Recovery copies of unsaved files (written at once after a panic).
+ recovery: crate::recovery::Shared,
}
-fn config_dir() -> Option {
+pub fn config_dir() -> Option {
#[cfg(target_os = "macos")]
let base = std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support"));
#[cfg(target_os = "windows")]
@@ -28,7 +30,7 @@ fn config_dir() -> Option {
}
impl Desktop {
- pub fn load() -> Self {
+ pub fn load(recovery: crate::recovery::Shared) -> Self {
let state: Value = config_dir()
.and_then(|d| std::fs::read_to_string(d.join("state.json")).ok())
.and_then(|s| serde_json::from_str(&s).ok())
@@ -46,7 +48,7 @@ impl Desktop {
let reduce_motion = Arc::new(AtomicBool::new(false));
let flag = reduce_motion.clone();
std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed));
- Self { clipboard: arboard::Clipboard::new().ok(), prefs, reduce_motion, language: system_language() }
+ Self { clipboard: arboard::Clipboard::new().ok(), prefs, reduce_motion, language: system_language(), recovery }
}
}
@@ -125,25 +127,6 @@ pub fn save_state(app: &FigApp) {
}
}
-/// After an unexpected panic: write every dirty document to the recovery folder.
-pub fn emergency_save(s: &Session) {
- let Some(dir) = config_dir().map(|d| d.join("Recovery")) else { return };
- let _ = std::fs::create_dir_all(&dir);
- for d in &s.docs {
- if d.is_dirty()
- && let Ok(bytes) = figcraft_format_save(&d.doc)
- {
- let name = d.doc.name.replace(['/', '\\', ':'], "_");
- let _ = std::fs::write(dir.join(format!("{name}-{}.figcraft", d.uid)), bytes);
- }
- }
-}
-
-fn figcraft_format_save(doc: &figcraft_engine::doc::Document) -> Result, String> {
- let v = serde_json::to_vec(doc).map_err(|e| e.to_string())?;
- Ok(v)
-}
-
impl Services for Desktop {
fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option {
rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file()
@@ -180,7 +163,9 @@ impl Services for Desktop {
let _ = webbrowser::open(url);
}
fn emergency_save(&mut self, session: &Session) {
- emergency_save(session);
+ if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() {
+ r.write(session, true);
+ }
}
fn reduce_motion(&self) -> bool {
self.reduce_motion.load(Ordering::Relaxed)
diff --git a/crates/color/src/gradient_tests.rs b/crates/color/src/gradient_tests.rs
new file mode 100644
index 0000000..8cd9646
--- /dev/null
+++ b/crates/color/src/gradient_tests.rs
@@ -0,0 +1,43 @@
+//! A linear gradient's width handle sits square to its axis on screen: the default one's bands run
+//! across the shape (they were skewed), and moving an end keeps them so (the fill could vanish
+//! when the stale handle ended up on the axis).
+
+use crate::{Color, Gradient, GradientKind};
+
+/// The axis and the width handle's direction in a `w`Γ`h` box, as screen vectors.
+fn axes(g: &Gradient, w: f64, h: f64) -> ((f64, f64), (f64, f64)) {
+ let d = |p: (f64, f64)| ((p.0 - g.from.0) * w, (p.1 - g.from.1) * h);
+ (d(g.to), d(g.width))
+}
+
+fn square(g: &Gradient, w: f64, h: f64) -> bool {
+ let (a, k) = axes(g, w, h);
+ let dot = a.0 * k.0 + a.1 * k.1;
+ let cross = a.0 * k.1 - a.1 * k.0;
+ dot.abs() < 1e-9 && cross.abs() > 1e-9
+}
+
+#[test]
+fn the_default_linear_gradient_runs_top_to_bottom_with_square_bands() {
+ let g = Gradient::default_for(GradientKind::Linear, Color::BLACK);
+ assert_eq!((g.from, g.to), ((0.5, 0.0), (0.5, 1.0)));
+ for (w, h) in [(120.0, 80.0), (80.0, 120.0), (1.0, 1.0)] {
+ assert!(square(&g, w, h), "{w}Γ{h}: {g:?}");
+ }
+}
+
+#[test]
+fn moving_an_end_keeps_the_bands_square_in_the_box() {
+ let mut g = Gradient::default_for(GradientKind::Linear, Color::BLACK);
+ // Dragging the start to the left edge's middle put the old handle on the axis.
+ g.from = (0.0, 0.5);
+ g.to = (1.0, 0.5);
+ g.square_linear_width(120.0, 80.0);
+ assert!(square(&g, 120.0, 80.0), "{g:?}");
+ g.to = (1.0, 1.0);
+ g.square_linear_width(120.0, 80.0);
+ assert!(square(&g, 120.0, 80.0), "a diagonal on a wide box: {g:?}");
+ // A degenerate box falls back to the unit box rather than dividing by zero.
+ g.square_linear_width(0.0, f64::NAN);
+ assert!(g.width.0.is_finite() && g.width.1.is_finite());
+}
diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs
index ece025d..bcd1b1b 100644
--- a/crates/color/src/lib.rs
+++ b/crates/color/src/lib.rs
@@ -250,7 +250,8 @@ impl Gradient {
/// the current colour at 100% β the same at 0% opacity. We use blackβwhite for a visible default.
pub fn default_for(kind: GradientKind, base: Color) -> Self {
let (from, to, width) = match kind {
- GradientKind::Linear => ((0.5, 0.0), (0.5, 1.0), (0.0, 0.5)),
+ // The width handle square to the axis from the start (colour bands across the axis).
+ GradientKind::Linear => ((0.5, 0.0), (0.5, 1.0), (-0.5, 0.0)),
_ => ((0.5, 0.5), (0.5, 1.0), (1.0, 0.5)),
};
Self {
@@ -262,6 +263,15 @@ impl Gradient {
}
}
+ /// Put a linear gradient's width handle square to its axis in a `w`Γ`h` box, so its colour
+ /// bands cross the axis at right angles on screen (the canvas has no handle for it; imported
+ /// files can still skew it). Moving the start or end handle calls this.
+ pub fn square_linear_width(&mut self, w: f64, h: f64) {
+ let (w, h) = if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 { (w, h) } else { (1.0, 1.0) };
+ let (dx, dy) = ((self.to.0 - self.from.0) * w, (self.to.1 - self.from.1) * h);
+ self.width = (self.from.0 - dy / w, self.from.1 + dx / h);
+ }
+
/// Stops sorted by position with NaNs removed; at least one stop.
pub fn sorted_stops(&self) -> Vec {
let mut s: Vec = self
@@ -612,6 +622,8 @@ impl Paint {
}
}
+#[cfg(test)]
+mod gradient_tests;
#[cfg(test)]
mod pattern_tests;
#[cfg(test)]
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index 306054d..fe1ea02 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -22,7 +22,7 @@ pub fn specs() -> Vec {
spec!(
"file.open",
"Open",
- "{path, variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .svg, .png, .jpg β¦); REST imports return `warnings`",
+ "{path, variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg β¦); REST and .fig imports return `warnings`",
false,
open
),
@@ -66,12 +66,26 @@ pub fn specs() -> Vec {
spec!(
"file.import",
"Import",
- "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft and REST API file JSON pages are appended with their variables and flows (REST: with `warnings`)",
+ "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`)",
true,
import
),
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
+ spec!(
+ "file.recover",
+ "Recover file",
+ "{path} open a recovery copy (written while the app runs, read back after a crash) as a document with unsaved changes and no path: saving asks where β {index}",
+ false,
+ |s, p| {
+ let path = req_string("file.recover", p, "path")?;
+ let loaded =
+ read(&path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?;
+ s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved());
+ s.active = s.docs.len() - 1;
+ Ok(json!({"index": s.active}))
+ }
+ ),
spec!("file.rename", "Rename file", "{name}", true, |s, p| {
let name = req_string("file.rename", p, "name")?;
s.doc_mut()?.name = name;
@@ -417,8 +431,8 @@ pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_form
}
/// Load from bytes. Returns the document with its notes (repairs of a native file, or import
-/// warnings) and version history, and whether it was imported from REST API file JSON (such a file
-/// is never saved over). `variables`: the local variables JSON for a REST API file JSON.
+/// warnings) and version history, and whether it was imported from REST API file JSON or a `.fig`
+/// file (such a file is never saved over). `variables`: the local variables JSON for a REST API file JSON.
fn load_with(
bytes: &[u8],
name_hint: &str,
@@ -443,6 +457,11 @@ fn load_with(
Ok((doc.into(), false))
}
_ => {
+ if let Some(r) = figcraft_format::fig::try_import(bytes, &file_stem(name_hint)) {
+ let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
+ link_instances(&mut doc);
+ return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true));
+ }
if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) {
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
link_instances(&mut doc);
@@ -963,7 +982,7 @@ fn import(s: &mut Session, p: &Value) -> Result {
}
Ok(out)
}
- "figcraft" | "json" => {
+ "figcraft" | "json" | "fig" => {
let resolver = path.as_deref().map(rest_images);
let images: figcraft_format::rest::ImageResolver = match &resolver {
Some(r) => r,
diff --git a/crates/engine/src/fig_import_tests.rs b/crates/engine/src/fig_import_tests.rs
new file mode 100644
index 0000000..d624144
--- /dev/null
+++ b/crates/engine/src/fig_import_tests.rs
@@ -0,0 +1,52 @@
+//! `.fig` files through the file commands. The importer's own tests (fixtures built in code) are
+//! in `figcraft_format::fig`; the local check here runs only when `FIGCRAFT_FIG_SAMPLES` names a
+//! folder of `.fig` files with their frames' PNG exports next to them.
+
+use figcraft_doc::{ExportFormat, NodeKind};
+use serde_json::json;
+
+use crate::Session;
+
+#[test]
+fn damaged_fig_files_fail_cleanly() {
+ let dir = std::env::temp_dir().join(format!("figcraft-fig-test-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("broken.fig");
+ std::fs::write(&path, b"fig-kiwi\x65\0\0\0\xff\xff\xff\x7fjunk").unwrap();
+ let mut s = Session::new();
+ let r = s.execute("file.open", &json!({"path": path.to_string_lossy()}));
+ assert!(r.is_err());
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// Local only (`FIGCRAFT_FIG_SAMPLES`): each file opens without warnings, instances link to their
+/// mains, and each top frame's 1Γ PNG export is close to the export saved next to the file.
+#[test]
+fn local_samples_match_their_exports() {
+ let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return };
+ let Ok(dir) = std::fs::read_dir(dir) else { return };
+ for p in dir.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "fig")) {
+ let mut s = Session::new();
+ let r = s.execute("file.open", &json!({"path": p.to_string_lossy()})).unwrap();
+ assert_eq!(r["warnings"], json!([]), "{}", p.display());
+ let doc = s.doc().unwrap();
+ for n in doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })) {
+ assert!(n.children.iter().all(|c| doc.node(*c).is_some_and(|c| c.source.is_some())), "{}: {}", p.display(), n.name);
+ }
+ let page = doc.first_page().unwrap();
+ for f in doc.top_frames(page) {
+ let name = &doc.node(f).unwrap().name;
+ let Ok(want) = image::open(p.with_file_name(format!("{name}.png"))) else { continue };
+ let got = crate::cmd::file::export_bytes(doc, &[f], ExportFormat::Png, 1.0).unwrap();
+ let (got, want) = (image::load_from_memory(&got).unwrap().to_rgb8(), want.to_rgb8());
+ // Exports with effects may be cropped differently; compare the ones of the same size.
+ if got.dimensions() != want.dimensions() {
+ continue;
+ }
+ let diff: u64 = got.as_raw().iter().zip(want.as_raw()).map(|(a, b)| u64::from(a.abs_diff(*b))).sum();
+ let mean = diff as f64 / got.as_raw().len() as f64;
+ // Text may use another font where the sample's isn't installed.
+ assert!(mean < 4.0, "{name}: mean difference {mean:.2}");
+ }
+ }
+}
diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs
index bf56e4c..ca247ab 100644
--- a/crates/engine/src/keymap.rs
+++ b/crates/engine/src/keymap.rs
@@ -349,6 +349,17 @@ impl Mods {
}
}
+/// How far a text editing key moves or deletes on `platform`, as `(word, line)` for
+/// [`crate::textedit::key`]. macOS: Alt by word, Cmd to the line's ends (Up/Down: the text's).
+/// Windows and Linux: Ctrl by word, Ctrl+Home/End to the text's ends.
+pub fn text_motion(platform: Platform, key: &str, m: Mods) -> (bool, bool) {
+ match platform {
+ Platform::Mac => (m.alt, m.cmd),
+ Platform::Other if matches!(key, "Home" | "End") => (false, m.cmd || m.ctrl),
+ Platform::Other => (m.cmd || m.ctrl, false),
+ }
+}
+
/// A key name as canonical (`=` for `Equals`, `ArrowLeft` for `Left`, `Delete` for `Backspace`),
/// and whether the name implies Shift (`?` is Shift+`/`). `None` for names that aren't keys.
pub fn normalize(name: &str) -> Option<(&str, bool)> {
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index c7e842c..625a63e 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -422,6 +422,12 @@ impl DocState {
true
}
+ /// Shown as having unsaved changes until it is saved (a document recovered after a crash).
+ pub fn unsaved(mut self) -> Self {
+ self.saved_doc = Arc::new(Document::new(self.doc.name.clone()));
+ self
+ }
+
pub fn mark_saved(&mut self) {
self.saved_doc = self.doc.clone();
self.versions_changed = false;
@@ -1233,6 +1239,8 @@ mod draw_tools_tests;
#[cfg(test)]
mod effects_tests;
#[cfg(test)]
+mod fig_import_tests;
+#[cfg(test)]
mod frame_guides_tests;
#[cfg(test)]
mod frame_label_tests;
@@ -1321,6 +1329,8 @@ mod tests;
#[cfg(test)]
mod text_fill_tests;
#[cfg(test)]
+mod text_motion_tests;
+#[cfg(test)]
mod text_style_picker_tests;
#[cfg(test)]
mod tiff_tests;
diff --git a/crates/engine/src/text_motion_tests.rs b/crates/engine/src/text_motion_tests.rs
new file mode 100644
index 0000000..48e1da0
--- /dev/null
+++ b/crates/engine/src/text_motion_tests.rs
@@ -0,0 +1,72 @@
+//! Text editing keys per platform (Ctrl moves and deletes by word on Windows and Linux, where it
+//! deleted the whole line), and recovering a crashed run's copy as an unsaved document.
+
+use serde_json::json;
+
+use crate::keymap::{Mods, Platform, text_motion};
+use crate::{Session, params, textedit};
+
+const CTRL: Mods = Mods { shift: false, alt: false, cmd: true, ctrl: false };
+const ALT: Mods = Mods { shift: false, alt: true, cmd: false, ctrl: false };
+
+/// Press `key` with `m` as the app does on `platform`; the text afterwards.
+fn key(s: &mut Session, platform: Platform, key: &str, m: Mods) -> String {
+ let (word, line) = text_motion(platform, key, m);
+ textedit::key(s, key, m.shift, word, line).unwrap();
+ let id = s.ds().unwrap().text_edit.as_ref().unwrap().id;
+ s.doc().unwrap().node(id).unwrap().kind.text().unwrap().characters.clone()
+}
+
+fn editing(text: &str) -> Session {
+ let mut s = Session::new();
+ let t = params::id_of(&s.execute("text.create", &json!({"text": text, "x": 0, "y": 0})).unwrap()["id"]).unwrap();
+ textedit::begin(&mut s, t, Some(text.chars().count()), false).unwrap();
+ s
+}
+
+fn caret(s: &Session) -> usize {
+ s.ds().unwrap().text_edit.as_ref().unwrap().caret
+}
+
+#[test]
+fn ctrl_moves_and_deletes_by_word_on_windows_and_linux() {
+ let mut s = editing("Hello brave world");
+ assert_eq!(key(&mut s, Platform::Other, "Backspace", CTRL), "Hello brave ", "Ctrl+Backspace deletes one word");
+ key(&mut s, Platform::Other, "Left", CTRL);
+ assert_eq!(caret(&s), 6, "Ctrl+Left goes to the word's start");
+ // The control channel and MCP name arrows like egui.
+ key(&mut s, Platform::Other, "ArrowLeft", CTRL);
+ assert_eq!(caret(&s), 0);
+ assert_eq!(key(&mut s, Platform::Other, "Delete", CTRL), " brave ", "Ctrl+Delete deletes the next word");
+ key(&mut s, Platform::Other, "End", CTRL);
+ assert_eq!(caret(&s), 7, "Ctrl+End: the end of the text");
+ key(&mut s, Platform::Other, "Home", CTRL);
+ assert_eq!(caret(&s), 0, "Ctrl+Home: its start");
+}
+
+#[test]
+fn macos_keeps_alt_by_word_and_cmd_to_the_line_end() {
+ assert_eq!(text_motion(Platform::Mac, "Left", ALT), (true, false));
+ assert_eq!(text_motion(Platform::Mac, "Backspace", CTRL), (false, true));
+ assert_eq!(text_motion(Platform::Other, "Left", ALT), (false, false), "Alt+arrows do nothing special on Windows");
+ let mut s = editing("Hello brave world");
+ assert_eq!(key(&mut s, Platform::Mac, "Backspace", ALT), "Hello brave ");
+ assert_eq!(key(&mut s, Platform::Mac, "Backspace", CTRL), "", "Cmd+Backspace: to the line's start");
+}
+
+#[test]
+fn a_recovered_copy_opens_unsaved_without_a_path() {
+ let dir = std::env::temp_dir().join(format!("figcraft-recover-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("1.figcraft");
+ let mut s = Session::new();
+ s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
+ s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap();
+ let r = s.execute("file.recover", &json!({"path": path.to_string_lossy()})).unwrap();
+ let ds = s.docs.get(r["index"].as_u64().unwrap() as usize).unwrap();
+ assert!(ds.path.is_none(), "saving asks where: the copy isn't the file");
+ assert!(ds.is_dirty(), "a closed window or tab asks before dropping it");
+ assert_eq!(s.doc().unwrap().pages.len(), 1);
+ assert!(s.execute("file.recover", &json!({"path": dir.join("missing.figcraft").to_string_lossy()})).is_err());
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs
index 8330406..7313035 100644
--- a/crates/engine/src/textedit.rs
+++ b/crates/engine/src/textedit.rs
@@ -125,8 +125,10 @@ fn word_right(chars: &[char], mut i: usize) -> usize {
}
/// A named key: Backspace, Delete, Left, Right, Up, Down, Home, End, Enter, Escape, SelectAll.
-/// `word` is Alt (word-wise), `line` is Cmd (line-wise).
+/// `word` moves or deletes by word, `line` to the line's ends (Up/Down, Home/End: the text's ends);
+/// [`crate::keymap::text_motion`] gives them for the platform's keys. `ArrowLeft` etc. also work.
pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> Result<()> {
+ let key = key.strip_prefix("Arrow").unwrap_or(key);
let Some(te) = s.ds()?.text_edit.clone() else { return Err(EngineError::Other("not editing text".into())) };
let t = text_of(s, te.id)?;
let chars: Vec = t.characters.chars().collect();
@@ -193,6 +195,8 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R
None if key == "Left" => cur.saturating_sub(1),
None => (cur + 1).min(n),
},
+ "Home" if line => 0,
+ "End" if line => n,
"Home" => line_start(&chars, cur),
"End" => line_end(&chars, cur),
"Up" | "Down" => match &layout {
diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml
index d8627e8..0b60198 100644
--- a/crates/format/Cargo.toml
+++ b/crates/format/Cargo.toml
@@ -3,7 +3,7 @@ name = "figcraft-format"
version.workspace = true
edition.workspace = true
license.workspace = true
-description = "FigCraft native file format (.figcraft: zip of document.json + images), image import helpers and REST API file JSON import"
+description = "FigCraft native file format (.figcraft: zip of document.json + images), image import helpers, REST API file JSON import and .fig import"
[lints]
workspace = true
@@ -13,5 +13,7 @@ figcraft-doc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
zip = { workspace = true }
+flate2 = { workspace = true }
+ruzstd = { workspace = true }
sha2 = { workspace = true }
image = { workspace = true }
diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs
new file mode 100644
index 0000000..0be212e
--- /dev/null
+++ b/crates/format/src/fig/kiwi.rs
@@ -0,0 +1,524 @@
+//! A generic decoder for the kiwi binary schema format (an open, MIT-licensed message format with a
+//! public specification). A binary schema lists definitions (enums, structs and messages, each
+//! with typed fields); a message is then decoded with it into a [`Kv`] tree keyed by field name.
+//!
+//! Everything here reads untrusted bytes: reads are bounds-checked, nesting is capped, array
+//! counts can't exceed the bytes left, and the total number of decoded values is capped.
+
+use std::fmt;
+
+/// Deepest struct/message nesting decoded.
+const MAX_DEPTH: usize = 64;
+/// Most values decoded from one message (every scalar, array element and field counts).
+const MAX_VALUES: usize = 20_000_000;
+/// Most definitions in a schema, and fields in one definition.
+const MAX_DEFS: usize = 10_000;
+const MAX_FIELDS: usize = 10_000;
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct KiwiError(pub String);
+
+impl fmt::Display for KiwiError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+fn err(msg: impl Into) -> Result {
+ Err(KiwiError(msg.into()))
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Kind {
+ Enum,
+ Struct,
+ Message,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Type {
+ Bool,
+ Byte,
+ Int,
+ Uint,
+ Float,
+ Str,
+ Int64,
+ Uint64,
+ /// Index of a definition in the schema.
+ Def(usize),
+}
+
+/// Built-in types in their schema order (encoded as `!index`).
+const BUILTINS: [Type; 8] = [Type::Bool, Type::Byte, Type::Int, Type::Uint, Type::Float, Type::Str, Type::Int64, Type::Uint64];
+
+#[derive(Clone, Debug)]
+pub struct Field {
+ pub name: String,
+ /// `None` for enum values.
+ pub ty: Option,
+ pub array: bool,
+ /// The field id (messages) or the value (enums).
+ pub value: u32,
+}
+
+#[derive(Clone, Debug)]
+pub struct Def {
+ pub name: String,
+ pub kind: Kind,
+ pub fields: Vec,
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct Schema {
+ pub defs: Vec,
+}
+
+/// A decoded value. Field and enum names borrow from the schema.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Kv<'s> {
+ Bool(bool),
+ Int(i64),
+ Uint(u64),
+ Float(f32),
+ Str(String),
+ /// A `byte[]` field.
+ Bytes(Vec),
+ Enum(&'s str),
+ Array(Vec>),
+ /// A struct or message: its fields in the order read.
+ Object(Vec<(&'s str, Kv<'s>)>),
+}
+
+impl<'s> Kv<'s> {
+ /// Field `name` of an object.
+ pub fn get(&self, name: &str) -> Option<&Kv<'s>> {
+ match self {
+ Kv::Object(f) => f.iter().find(|(n, _)| *n == name).map(|(_, v)| v),
+ _ => None,
+ }
+ }
+
+ pub fn f64(&self) -> Option {
+ let v = match self {
+ Kv::Float(f) => f64::from(*f),
+ Kv::Int(i) => *i as f64,
+ Kv::Uint(u) => *u as f64,
+ Kv::Bool(b) => f64::from(u8::from(*b)),
+ _ => return None,
+ };
+ v.is_finite().then_some(v)
+ }
+
+ pub fn bool(&self) -> Option {
+ match self {
+ Kv::Bool(b) => Some(*b),
+ _ => None,
+ }
+ }
+
+ pub fn str(&self) -> Option<&str> {
+ match self {
+ Kv::Str(s) => Some(s),
+ Kv::Enum(s) => Some(s),
+ _ => None,
+ }
+ }
+
+ pub fn list(&self) -> &[Kv<'s>] {
+ match self {
+ Kv::Array(a) => a,
+ _ => &[],
+ }
+ }
+
+ pub fn bytes(&self) -> Option<&[u8]> {
+ match self {
+ Kv::Bytes(b) => Some(b),
+ _ => None,
+ }
+ }
+}
+
+/// A bounds-checked reader over kiwi bytes.
+struct Reader<'b> {
+ data: &'b [u8],
+ pos: usize,
+}
+
+impl<'b> Reader<'b> {
+ fn left(&self) -> usize {
+ self.data.len().saturating_sub(self.pos)
+ }
+
+ fn byte(&mut self) -> Result {
+ let b = *self.data.get(self.pos).ok_or_else(|| KiwiError("unexpected end of data".into()))?;
+ self.pos += 1;
+ Ok(b)
+ }
+
+ fn take(&mut self, n: usize) -> Result<&'b [u8], KiwiError> {
+ let end = self.pos.checked_add(n).filter(|e| *e <= self.data.len()).ok_or_else(|| KiwiError("unexpected end of data".into()))?;
+ let s = self.data.get(self.pos..end).ok_or_else(|| KiwiError("unexpected end of data".into()))?;
+ self.pos = end;
+ Ok(s)
+ }
+
+ fn var_uint(&mut self) -> Result {
+ let mut value = 0u32;
+ let mut shift = 0;
+ loop {
+ let b = self.byte()?;
+ value |= u32::from(b & 127).checked_shl(shift).unwrap_or(0);
+ shift += 7;
+ if b & 128 == 0 || shift >= 35 {
+ return Ok(value);
+ }
+ }
+ }
+
+ fn var_int(&mut self) -> Result {
+ let v = self.var_uint()?;
+ Ok(if v & 1 != 0 { !((v >> 1) as i32) } else { (v >> 1) as i32 })
+ }
+
+ fn var_uint64(&mut self) -> Result {
+ let mut value = 0u64;
+ let mut shift = 0u32;
+ loop {
+ let b = self.byte()?;
+ if shift == 56 {
+ // The ninth byte carries a full eight bits.
+ return Ok(value | (u64::from(b) << 56));
+ }
+ value |= u64::from(b & 127) << shift;
+ shift += 7;
+ if b & 128 == 0 {
+ return Ok(value);
+ }
+ }
+ }
+
+ fn var_int64(&mut self) -> Result {
+ let v = self.var_uint64()?;
+ Ok(if v & 1 != 0 { !((v >> 1) as i64) } else { (v >> 1) as i64 })
+ }
+
+ fn var_float(&mut self) -> Result {
+ if self.data.get(self.pos) == Some(&0) {
+ self.pos += 1;
+ return Ok(0.0);
+ }
+ let b = self.take(4)?;
+ let bits = u32::from_le_bytes([b[0], b[1], b[2], b[3]]);
+ // The exponent is stored first so zero fits one byte; move it back into place.
+ Ok(f32::from_bits(bits.rotate_left(23)))
+ }
+
+ fn string(&mut self) -> Result {
+ let rest = self.data.get(self.pos..).unwrap_or_default();
+ let len = rest.iter().position(|b| *b == 0).ok_or_else(|| KiwiError("unterminated string".into()))?;
+ let s = String::from_utf8_lossy(self.take(len)?).into_owned();
+ self.pos += 1;
+ Ok(s)
+ }
+}
+
+impl Schema {
+ /// Decode a binary schema.
+ pub fn decode(data: &[u8]) -> Result {
+ let mut r = Reader { data, pos: 0 };
+ let count = r.var_uint()? as usize;
+ if count > MAX_DEFS || count > r.left() {
+ return err("schema: too many definitions");
+ }
+ let mut defs = Vec::with_capacity(count);
+ let mut raw_types = Vec::new();
+ for _ in 0..count {
+ let name = r.string()?;
+ let kind = match r.byte()? {
+ 0 => Kind::Enum,
+ 1 => Kind::Struct,
+ 2 => Kind::Message,
+ k => return err(format!("schema: unknown definition kind {k}")),
+ };
+ let n = r.var_uint()? as usize;
+ if n > MAX_FIELDS || n > r.left() {
+ return err("schema: too many fields");
+ }
+ let mut fields = Vec::with_capacity(n);
+ for _ in 0..n {
+ let fname = r.string()?;
+ let ty = r.var_int()?;
+ let array = r.byte()? & 1 != 0;
+ let value = r.var_uint()?;
+ raw_types.push(ty);
+ fields.push(Field { name: fname, ty: None, array, value });
+ }
+ defs.push(Def { name, kind, fields });
+ }
+ let total = defs.len();
+ let mut types = raw_types.into_iter();
+ for d in &mut defs {
+ for f in &mut d.fields {
+ let raw = types.next().unwrap_or(0);
+ if d.kind == Kind::Enum {
+ continue;
+ }
+ f.ty = Some(if raw < 0 {
+ *BUILTINS.get((!raw) as usize).ok_or_else(|| KiwiError(format!("schema: unknown type {raw}")))?
+ } else {
+ let i = raw as usize;
+ if i >= total {
+ return err(format!("schema: field {} names missing definition {i}", f.name));
+ }
+ Type::Def(i)
+ });
+ }
+ }
+ Ok(Schema { defs })
+ }
+
+ pub fn find(&self, name: &str) -> Option {
+ self.defs.iter().position(|d| d.name == name)
+ }
+
+ /// Decode a message of definition `root`.
+ pub fn decode_message<'s>(&'s self, root: usize, data: &[u8]) -> Result, KiwiError> {
+ let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget: MAX_VALUES };
+ d.def(root, 0)
+ }
+}
+
+struct Decoder<'s, 'b> {
+ schema: &'s Schema,
+ r: Reader<'b>,
+ budget: usize,
+}
+
+impl<'s> Decoder<'s, '_> {
+ fn spend(&mut self, n: usize) -> Result<(), KiwiError> {
+ self.budget = self.budget.checked_sub(n).ok_or_else(|| KiwiError("message: too many values".into()))?;
+ Ok(())
+ }
+
+ fn def(&mut self, i: usize, depth: usize) -> Result, KiwiError> {
+ if depth > MAX_DEPTH {
+ return err("message: nested too deeply");
+ }
+ self.spend(1)?;
+ let schema = self.schema;
+ let d = schema.defs.get(i).ok_or_else(|| KiwiError("message: missing definition".into()))?;
+ match d.kind {
+ Kind::Enum => {
+ let v = self.r.var_uint()?;
+ Ok(d.fields.iter().find(|f| f.value == v).map_or(Kv::Uint(u64::from(v)), |f| Kv::Enum(f.name.as_str())))
+ }
+ Kind::Struct => {
+ let mut out = Vec::with_capacity(d.fields.len());
+ for f in &d.fields {
+ out.push((f.name.as_str(), self.field(f, depth)?));
+ }
+ Ok(Kv::Object(out))
+ }
+ Kind::Message => {
+ let mut out = Vec::new();
+ loop {
+ let id = self.r.var_uint()?;
+ if id == 0 {
+ return Ok(Kv::Object(out));
+ }
+ let f = d.fields.iter().find(|f| f.value == id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?;
+ out.push((f.name.as_str(), self.field(f, depth)?));
+ }
+ }
+ }
+ }
+
+ fn field(&mut self, f: &Field, depth: usize) -> Result, KiwiError> {
+ let ty = f.ty.ok_or_else(|| KiwiError("message: untyped field".into()))?;
+ if !f.array {
+ return self.value(ty, depth);
+ }
+ let n = self.r.var_uint()? as usize;
+ // Every element takes at least one byte.
+ if n > self.r.left() {
+ return err("message: array longer than the data");
+ }
+ if ty == Type::Byte {
+ self.spend(1)?;
+ return Ok(Kv::Bytes(self.r.take(n)?.to_vec()));
+ }
+ self.spend(n)?;
+ let mut out = Vec::with_capacity(n);
+ for _ in 0..n {
+ out.push(self.value(ty, depth)?);
+ }
+ Ok(Kv::Array(out))
+ }
+
+ fn value(&mut self, ty: Type, depth: usize) -> Result, KiwiError> {
+ Ok(match ty {
+ Type::Bool => Kv::Bool(self.r.byte()? != 0),
+ Type::Byte => Kv::Uint(u64::from(self.r.byte()?)),
+ Type::Int => Kv::Int(i64::from(self.r.var_int()?)),
+ Type::Uint => Kv::Uint(u64::from(self.r.var_uint()?)),
+ Type::Float => Kv::Float(self.r.var_float()?),
+ Type::Str => Kv::Str(self.r.string()?),
+ Type::Int64 => Kv::Int(self.r.var_int64()?),
+ Type::Uint64 => Kv::Uint(self.r.var_uint64()?),
+ Type::Def(i) => self.def(i, depth + 1)?,
+ })
+ }
+}
+
+/// An encoder for the same format (test fixtures are built with it).
+#[cfg(test)]
+pub mod encode {
+ use super::*;
+
+ #[derive(Default)]
+ pub struct Writer(pub Vec);
+
+ impl Writer {
+ pub fn byte(&mut self, b: u8) {
+ self.0.push(b);
+ }
+
+ pub fn var_uint(&mut self, mut v: u32) {
+ loop {
+ let b = (v & 127) as u8;
+ v >>= 7;
+ if v == 0 {
+ self.0.push(b);
+ return;
+ }
+ self.0.push(b | 128);
+ }
+ }
+
+ pub fn var_int(&mut self, v: i32) {
+ self.var_uint(((v << 1) ^ (v >> 31)) as u32);
+ }
+
+ pub fn var_uint64(&mut self, mut v: u64) {
+ for _ in 0..8 {
+ let b = (v & 127) as u8;
+ v >>= 7;
+ if v == 0 {
+ self.0.push(b);
+ return;
+ }
+ self.0.push(b | 128);
+ }
+ self.0.push(v as u8);
+ }
+
+ pub fn var_int64(&mut self, v: i64) {
+ self.var_uint64(((v << 1) ^ (v >> 63)) as u64);
+ }
+
+ pub fn var_float(&mut self, f: f32) {
+ let bits = f.to_bits().rotate_right(23);
+ if bits & 255 == 0 {
+ self.0.push(0);
+ } else {
+ self.0.extend_from_slice(&bits.to_le_bytes());
+ }
+ }
+
+ pub fn string(&mut self, s: &str) {
+ self.0.extend_from_slice(s.as_bytes());
+ self.0.push(0);
+ }
+ }
+
+ pub fn schema(s: &Schema) -> Vec {
+ let mut w = Writer::default();
+ w.var_uint(s.defs.len() as u32);
+ for d in &s.defs {
+ w.string(&d.name);
+ w.byte(match d.kind {
+ Kind::Enum => 0,
+ Kind::Struct => 1,
+ Kind::Message => 2,
+ });
+ w.var_uint(d.fields.len() as u32);
+ for f in &d.fields {
+ w.string(&f.name);
+ w.var_int(match f.ty {
+ None => 0,
+ Some(Type::Def(i)) => i as i32,
+ Some(t) => !(BUILTINS.iter().position(|b| *b == t).unwrap() as i32),
+ });
+ w.byte(u8::from(f.array));
+ w.var_uint(f.value);
+ }
+ }
+ w.0
+ }
+
+ /// Encode `v` as definition `def` (objects may leave message fields out; struct fields are
+ /// required, in order).
+ pub fn message(s: &Schema, def: usize, v: &Kv) -> Vec {
+ let mut w = Writer::default();
+ encode_def(s, def, v, &mut w);
+ w.0
+ }
+
+ fn encode_def(s: &Schema, i: usize, v: &Kv, w: &mut Writer) {
+ let d = &s.defs[i];
+ match d.kind {
+ Kind::Enum => {
+ let name = v.str().unwrap();
+ w.var_uint(d.fields.iter().find(|f| f.name == name).unwrap().value);
+ }
+ Kind::Struct => {
+ for f in &d.fields {
+ encode_field(s, f, v.get(&f.name).unwrap_or_else(|| panic!("struct field {}", f.name)), w);
+ }
+ }
+ Kind::Message => {
+ let Kv::Object(fields) = v else { panic!("not an object") };
+ for (name, fv) in fields {
+ let f = d.fields.iter().find(|f| f.name == *name).unwrap_or_else(|| panic!("no field {name} in {}", d.name));
+ w.var_uint(f.value);
+ encode_field(s, f, fv, w);
+ }
+ w.var_uint(0);
+ }
+ }
+ }
+
+ fn encode_field(s: &Schema, f: &Field, v: &Kv, w: &mut Writer) {
+ let ty = f.ty.unwrap();
+ if f.array {
+ if let Kv::Bytes(b) = v {
+ w.var_uint(b.len() as u32);
+ w.0.extend_from_slice(b);
+ return;
+ }
+ let items = v.list();
+ w.var_uint(items.len() as u32);
+ for it in items {
+ encode_value(s, ty, it, w);
+ }
+ } else {
+ encode_value(s, ty, v, w);
+ }
+ }
+
+ fn encode_value(s: &Schema, ty: Type, v: &Kv, w: &mut Writer) {
+ match ty {
+ Type::Bool => w.byte(u8::from(v.bool().unwrap())),
+ Type::Byte => w.byte(v.f64().unwrap() as u8),
+ Type::Int => w.var_int(v.f64().unwrap() as i32),
+ Type::Uint => w.var_uint(v.f64().unwrap() as u32),
+ Type::Float => w.var_float(v.f64().unwrap() as f32),
+ Type::Str => w.string(v.str().unwrap()),
+ Type::Int64 => w.var_int64(v.f64().unwrap() as i64),
+ Type::Uint64 => w.var_uint64(v.f64().unwrap() as u64),
+ Type::Def(i) => encode_def(s, i, v, w),
+ }
+ }
+}
diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs
new file mode 100644
index 0000000..8c33d3f
--- /dev/null
+++ b/crates/format/src/fig/map.rs
@@ -0,0 +1,1333 @@
+//! Decoded `.fig` message β REST API file JSON, which [`crate::rest`] then imports.
+//!
+//! The message lists node changes; each names its id (`guid`), its parent and its position among
+//! the siblings (`parentIndex`). Nodes are read by field name, so files written with newer
+//! schemas still import what is understood. Instances carry only their overrides: their layers
+//! are expanded here from the main component (overrides and derived layout matched by id path,
+//! component property values applied) so the import looks as saved.
+
+use std::collections::BTreeMap;
+use std::rc::Rc;
+
+use figcraft_doc::geom::{Affine, Point};
+use serde_json::{Map, Value, json};
+
+use super::kiwi::Kv;
+
+type Obj = Map;
+
+/// Most layers produced (instance expansion included) and deepest nesting followed.
+const MAX_NODES: usize = crate::rest::MAX_NODES;
+const MAX_DEPTH: usize = 256;
+/// Most path commands read from one geometry blob.
+const MAX_COMMANDS: usize = 200_000;
+/// Id of "no style".
+const NO_STYLE: &str = "4294967295:4294967295";
+
+/// The REST API file JSON for message `msg`, with the warnings about what was skipped.
+pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) {
+ let mut m = Mapper::new(msg);
+ let doc = m.document();
+ let mut out = json!({"name": name, "document": doc});
+ if !m.styles.is_empty() {
+ out["styles"] = Value::Object(std::mem::take(&mut m.styles));
+ }
+ if !m.components.is_empty() {
+ out["components"] = Value::Object(std::mem::take(&mut m.components));
+ }
+ if let Some(vars) = m.variables() {
+ out["meta"] = vars;
+ }
+ (out, m.warnings)
+}
+
+/// REST importer warnings reworded for a `.fig` import (`None`: drop it).
+pub(super) fn adapt_warning(w: String) -> Option {
+ if w.contains("geometry=paths") {
+ return None;
+ }
+ if w.contains("without image data") {
+ let count = w.split_whitespace().next().unwrap_or("Some");
+ return Some(format!("{count} image fill(s) whose image isn't in the file show a grey placeholder"));
+ }
+ Some(w.replace("the variables JSON", "the file").replace("the file JSON", "the file"))
+}
+
+// ---- small readers ----
+
+fn num(n: &Kv, k: &str) -> Option {
+ n.get(k).and_then(Kv::f64)
+}
+
+fn guid(v: &Kv) -> Option {
+ Some(format!("{}:{}", num(v, "sessionID")? as u64, num(v, "localID")? as u64))
+}
+
+/// A `GUID`-holding reference (`StyleId`, `VariableID`, β¦): its `guid`.
+fn ref_guid(v: &Kv) -> Option {
+ v.get("guid").and_then(guid).filter(|g| g != NO_STYLE)
+}
+
+fn color(c: &Kv) -> Value {
+ let ch = |k: &str| num(c, k).unwrap_or(0.0);
+ json!({"r": ch("r"), "g": ch("g"), "b": ch("b"), "a": num(c, "a").unwrap_or(1.0)})
+}
+
+fn vec2(v: &Kv) -> Option {
+ Some(json!({"x": num(v, "x")?, "y": num(v, "y")?}))
+}
+
+fn matrix(m: &Kv) -> Option {
+ let c = |k: &str| num(m, k);
+ let a = Affine::new([c("m00")?, c("m10")?, c("m01")?, c("m11")?, c("m02")?, c("m12")?]);
+ a.as_coeffs().iter().all(|x| x.is_finite()).then_some(a)
+}
+
+fn rows(a: Affine) -> Value {
+ let [a, b, c, d, e, f] = a.as_coeffs();
+ json!([[a, c, e], [b, d, f]])
+}
+
+fn copy_num(o: &mut Obj, n: &View, from: &str, to: &str) {
+ if let Some(x) = n.num(from) {
+ o.insert(to.into(), json!(x));
+ }
+}
+
+fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) {
+ if let Some(s) = n.str(from) {
+ o.insert(to.into(), json!(s));
+ }
+}
+
+/// A node seen through override layers: the last layer with a field wins.
+#[derive(Clone)]
+struct View<'a> {
+ layers: Vec<&'a Kv<'a>>,
+}
+
+impl<'a> View<'a> {
+ fn of(n: &'a Kv<'a>) -> Self {
+ Self { layers: vec![n] }
+ }
+
+ fn with(&self, top: impl IntoIterator- >) -> Self {
+ let mut v = self.clone();
+ v.layers.extend(top);
+ v
+ }
+
+ fn get(&self, k: &str) -> Option<&'a Kv<'a>> {
+ self.layers.iter().rev().find_map(|l| l.get(k))
+ }
+
+ fn num(&self, k: &str) -> Option {
+ self.get(k).and_then(Kv::f64)
+ }
+
+ fn str(&self, k: &str) -> Option<&'a str> {
+ self.get(k).and_then(Kv::str)
+ }
+
+ fn flag(&self, k: &str) -> Option {
+ self.get(k).and_then(Kv::bool)
+ }
+
+ fn list(&self, k: &str) -> &'a [Kv<'a>] {
+ self.get(k).map(Kv::list).unwrap_or_default()
+ }
+}
+
+/// Where an instance's expanded layers are: the overrides and derived layout of the outermost
+/// instance (by id path), the path of the instance being expanded, and the property values.
+#[derive(Clone)]
+struct Inst<'a> {
+ root: String,
+ path: Vec,
+ overrides: Rc, Vec<&'a Kv<'a>>>>,
+ props: Rc>>,
+ /// Mains being expanded (an instance inside its own main stops there).
+ symbols: Vec,
+}
+
+struct Mapper<'a> {
+ nodes: BTreeMap>,
+ kids: BTreeMap>,
+ blobs: Vec<&'a [u8]>,
+ /// Component property definitions: id β (REST name "Name#id", type).
+ prop_defs: BTreeMap,
+ styles: Obj,
+ components: Obj,
+ warnings: Vec,
+ count: usize,
+}
+
+impl<'a> Mapper<'a> {
+ fn new(msg: &'a Kv<'a>) -> Self {
+ let mut nodes = BTreeMap::new();
+ let mut kids: BTreeMap> = BTreeMap::new();
+ let mut prop_defs = BTreeMap::new();
+ for n in msg.get("nodeChanges").map(Kv::list).unwrap_or_default() {
+ let Some(id) = n.get("guid").and_then(guid) else { continue };
+ if n.get("phase").and_then(Kv::str) == Some("REMOVED") {
+ continue;
+ }
+ if let Some(p) = n.get("parentIndex")
+ && let Some(parent) = p.get("guid").and_then(guid)
+ {
+ kids.entry(parent).or_default().push((p.get("position").and_then(Kv::str).unwrap_or(""), id.clone()));
+ }
+ for d in n.get("componentPropDefs").map(Kv::list).unwrap_or_default() {
+ if let (Some(did), Some(name), Some(ty)) =
+ (d.get("id").and_then(guid), d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str))
+ {
+ prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty));
+ }
+ }
+ nodes.insert(id, n);
+ }
+ for list in kids.values_mut() {
+ list.sort();
+ list.dedup_by(|a, b| a.1 == b.1);
+ }
+ let blobs =
+ msg.get("blobs").map(Kv::list).unwrap_or_default().iter().map(|b| b.get("bytes").and_then(Kv::bytes).unwrap_or_default()).collect();
+ Self { nodes, kids, blobs, prop_defs, styles: Obj::new(), components: Obj::new(), warnings: Vec::new(), count: 0 }
+ }
+
+ fn warn(&mut self, w: impl Into) {
+ let w = w.into();
+ if !self.warnings.contains(&w) {
+ self.warnings.push(w);
+ }
+ }
+
+ fn children(&self, id: &str) -> Vec {
+ self.kids.get(id).map(|k| k.iter().map(|(_, c)| c.clone()).collect()).unwrap_or_default()
+ }
+
+ /// The DOCUMENT with its pages.
+ fn document(&mut self) -> Value {
+ let doc_id = self.nodes.iter().find(|(_, n)| n.get("type").and_then(Kv::str) == Some("DOCUMENT")).map(|(id, _)| id.clone());
+ let pages: Vec = match &doc_id {
+ Some(d) => self.children(d),
+ None => self.nodes.iter().filter(|(_, n)| n.get("type").and_then(Kv::str) == Some("CANVAS")).map(|(id, _)| id.clone()).collect(),
+ };
+ let mut out = Vec::new();
+ for p in pages {
+ let Some(&n) = self.nodes.get(&p) else { continue };
+ if n.get("type").and_then(Kv::str) != Some("CANVAS") {
+ self.warn("document children other than pages were skipped");
+ continue;
+ }
+ // The hidden page that holds styles, variables and library copies.
+ if n.get("internalOnly").and_then(Kv::bool) == Some(true) {
+ continue;
+ }
+ out.push(self.page(&p, n));
+ }
+ if out.is_empty() {
+ self.warn("the file has no pages");
+ }
+ json!({"id": doc_id.unwrap_or_else(|| "0:0".into()), "type": "DOCUMENT", "children": out})
+ }
+
+ fn page(&mut self, id: &str, n: &'a Kv<'a>) -> Value {
+ let v = View::of(n);
+ let mut o = Obj::new();
+ o.insert("id".into(), json!(id));
+ o.insert("type".into(), json!("CANVAS"));
+ copy_str(&mut o, &v, "name", "name");
+ if let Some(c) = v.get("backgroundColor") {
+ o.insert("backgroundColor".into(), color(c));
+ }
+ if let Some(c) = v.get("prototypeBackgroundColor") {
+ o.insert("prototypeBackgrounds".into(), json!([color(c)]));
+ }
+ let mut flows: Vec<(&str, Value)> = Vec::new();
+ let mut kids = Vec::new();
+ for c in self.children(id) {
+ if let Some(sp) = self.nodes.get(&c).and_then(|k| k.get("prototypeStartingPoint")) {
+ let name = sp.get("name").and_then(Kv::str).filter(|s| !s.is_empty()).unwrap_or("Flow");
+ flows.push((sp.get("position").and_then(Kv::str).unwrap_or(""), json!({"nodeId": c, "name": name})));
+ }
+ kids.extend(self.layer(&c, None, Affine::IDENTITY, None, 0));
+ }
+ flows.sort_by(|a, b| a.0.cmp(b.0));
+ if !flows.is_empty() {
+ o.insert("flowStartingPoints".into(), Value::Array(flows.into_iter().map(|f| f.1).collect()));
+ }
+ o.insert("children".into(), Value::Array(kids));
+ Value::Object(o)
+ }
+
+ /// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the
+ /// parent's coordinates to the enclosing frame's (groups and booleans place their children in
+ /// the frame's space, like the REST JSON). `parent`: the parent's view (grid tracks).
+ fn layer(&mut self, id: &str, inst: Option<&Inst<'a>>, space: Affine, parent: Option<&View<'a>>, depth: usize) -> Option {
+ if depth > MAX_DEPTH {
+ self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
+ return None;
+ }
+ if self.count >= MAX_NODES {
+ self.warn(format!("the file has more than {MAX_NODES} layers; the rest were skipped"));
+ return None;
+ }
+ let base = *self.nodes.get(id)?;
+ let path = inst.map(|i| [i.path.as_slice(), &[id.to_string()]].concat());
+ let view = match (inst, &path) {
+ (Some(i), Some(p)) => View::of(base).with(i.overrides.get(p).into_iter().flatten().copied()),
+ _ => View::of(base),
+ };
+ let rest_id = match (inst, &path) {
+ (Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")),
+ _ => id.to_string(),
+ };
+ let ty = match view.str("type").unwrap_or("") {
+ "ROUNDED_RECTANGLE" => "RECTANGLE",
+ "SYMBOL" => "COMPONENT",
+ "FRAME" if view.flag("isStateGroup") == Some(true) => "COMPONENT_SET",
+ "FRAME" if view.flag("resizeToFit") == Some(true) => "GROUP",
+ "VARIABLE" | "VARIABLE_SET" => return None,
+ t => t,
+ };
+ self.count += 1;
+ let mut o = Obj::new();
+ o.insert("id".into(), json!(rest_id));
+ o.insert("type".into(), json!(ty));
+ copy_str(&mut o, &view, "name", "name");
+ self.common(&mut o, &view, ty, space, parent);
+ match ty {
+ "TEXT" => self.text(&mut o, &view),
+ "VECTOR" | "STAR" | "REGULAR_POLYGON" => self.geometry(&mut o, &view),
+ _ => {}
+ }
+ if ty == "COMPONENT" || ty == "COMPONENT_SET" {
+ self.component(&mut o, &view, &rest_id);
+ }
+ self.prop_refs(&mut o, &view, inst);
+ let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") {
+ space * view.get("transform").and_then(matrix).unwrap_or_default()
+ } else {
+ Affine::IDENTITY
+ };
+ let mut kids = Vec::new();
+ if ty == "INSTANCE" {
+ if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) {
+ for c in self.children(&sym) {
+ kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), depth + 1));
+ }
+ }
+ } else {
+ for c in self.children(id) {
+ kids.extend(self.layer(&c, inst, child_space, Some(&view), depth + 1));
+ }
+ }
+ if !kids.is_empty() {
+ o.insert("children".into(), Value::Array(kids));
+ }
+ Some(Value::Object(o))
+ }
+
+ /// Fields every layer type shares.
+ fn common(&mut self, o: &mut Obj, v: &View<'a>, ty: &str, space: Affine, parent: Option<&View<'a>>) {
+ for (from, to) in [("visible", "visible"), ("locked", "locked"), ("mask", "isMask")] {
+ if let Some(b) = v.flag(from) {
+ o.insert(to.into(), json!(b));
+ }
+ }
+ copy_num(o, v, "opacity", "opacity");
+ if let Some(b) = v.str("blendMode").filter(|b| *b != "UNSET") {
+ o.insert("blendMode".into(), json!(b));
+ }
+ if let Some(s) = v.get("size").and_then(vec2) {
+ o.insert("size".into(), s);
+ }
+ let t = v.get("transform").and_then(matrix).unwrap_or_default();
+ o.insert("relativeTransform".into(), rows(space * t));
+ // Paints, from the shared style when one is linked (the layer's own copy can be stale).
+ let mut styles = Obj::new();
+ let fill_style = self.style(v, "styleIdForFill", "inheritFillStyleID", "fill", &mut styles);
+ let fills = fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default());
+ o.insert("fills".into(), Value::Array(fills.iter().filter_map(paint).collect()));
+ let stroke_style = self.style(v, "styleIdForStrokeFill", "inheritFillStyleIDForStroke", "stroke", &mut styles);
+ let strokes = stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default());
+ o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(paint).collect()));
+ let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles);
+ let effects = effect_style.map_or_else(|| v.list("effects"), |s| s.get("effects").map(Kv::list).unwrap_or_default());
+ if !effects.is_empty() {
+ o.insert("effects".into(), Value::Array(effects.iter().filter_map(effect).collect()));
+ }
+ let grid_style = self.style(v, "styleIdForGrid", "inheritGridStyleID", "grid", &mut styles);
+ let grids = grid_style.map_or_else(|| v.list("layoutGrids"), |s| s.get("layoutGrids").map(Kv::list).unwrap_or_default());
+ if !grids.is_empty() {
+ o.insert("layoutGrids".into(), Value::Array(grids.iter().map(layout_grid).collect()));
+ }
+ if ty == "TEXT" {
+ self.style(v, "styleIdForText", "inheritTextStyleID", "text", &mut styles);
+ }
+ if !styles.is_empty() {
+ o.insert("styles".into(), Value::Object(styles));
+ }
+ stroke(o, v);
+ copy_num(o, v, "cornerRadius", "cornerRadius");
+ copy_num(o, v, "cornerSmoothing", "cornerSmoothing");
+ if v.flag("rectangleCornerRadiiIndependent") == Some(true) {
+ let r = |k: &str| v.num(k).unwrap_or(0.0);
+ o.insert(
+ "rectangleCornerRadii".into(),
+ json!([
+ r("rectangleTopLeftCornerRadius"),
+ r("rectangleTopRightCornerRadius"),
+ r("rectangleBottomRightCornerRadius"),
+ r("rectangleBottomLeftCornerRadius")
+ ]),
+ );
+ }
+ let constraint = |k: &str, min: &'static str, max: &'static str, both: &'static str| match v.str(k) {
+ Some("CENTER") => "CENTER",
+ Some("MAX" | "FIXED_MAX") => max,
+ Some("STRETCH") => both,
+ Some("SCALE") => "SCALE",
+ _ => min,
+ };
+ o.insert(
+ "constraints".into(),
+ json!({
+ "horizontal": constraint("horizontalConstraint", "LEFT", "RIGHT", "LEFT_RIGHT"),
+ "vertical": constraint("verticalConstraint", "TOP", "BOTTOM", "TOP_BOTTOM"),
+ }),
+ );
+ if matches!(ty, "FRAME" | "COMPONENT" | "COMPONENT_SET" | "INSTANCE" | "SECTION") {
+ o.insert("clipsContent".into(), json!(!v.flag("frameMaskDisabled").unwrap_or(false)));
+ self.auto_layout(o, v);
+ }
+ child_layout(o, v, parent);
+ let exports: Vec = v.list("exportSettings").iter().map(export_setting).collect();
+ if !exports.is_empty() {
+ o.insert("exportSettings".into(), Value::Array(exports));
+ }
+ if let Some(b) = v.flag("sectionContentsHidden") {
+ o.insert("sectionContentsHidden".into(), json!(b));
+ }
+ self.prototype(o, v);
+ self.variable_bindings(o, v);
+ }
+
+ /// The style node a layer links in `key` (or the older `legacy` field), recorded for the REST
+ /// `styles` map under `slot`.
+ fn style(&mut self, v: &View<'a>, key: &str, legacy: &str, slot: &str, out: &mut Obj) -> Option<&'a Kv<'a>> {
+ let id = v.get(key).and_then(ref_guid).or_else(|| v.get(legacy).and_then(guid).filter(|g| g != NO_STYLE))?;
+ let node = *self.nodes.get(&id)?;
+ node.get("styleType").and_then(Kv::str)?;
+ out.insert(slot.into(), json!(id));
+ if !self.styles.contains_key(&id) {
+ let name = node.get("name").and_then(Kv::str).unwrap_or("Style");
+ let desc = node.get("styleDescription").or_else(|| node.get("description")).and_then(Kv::str).unwrap_or("");
+ self.styles.insert(id, json!({"name": name, "description": desc}));
+ }
+ Some(node)
+ }
+
+ fn auto_layout(&mut self, o: &mut Obj, v: &View<'a>) {
+ let Some(mode) = v.str("stackMode").filter(|m| matches!(*m, "HORIZONTAL" | "VERTICAL" | "GRID")) else { return };
+ o.insert("layoutMode".into(), json!(mode));
+ copy_num(o, v, "stackSpacing", "itemSpacing");
+ copy_num(o, v, "stackCounterSpacing", "counterAxisSpacing");
+ if v.str("stackWrap") == Some("WRAP") {
+ o.insert("layoutWrap".into(), json!("WRAP"));
+ }
+ let (h, vert) = (v.num("stackHorizontalPadding").unwrap_or(0.0), v.num("stackVerticalPadding").unwrap_or(0.0));
+ o.insert("paddingLeft".into(), json!(h));
+ o.insert("paddingTop".into(), json!(vert));
+ o.insert("paddingRight".into(), json!(v.num("stackPaddingRight").unwrap_or(h)));
+ o.insert("paddingBottom".into(), json!(v.num("stackPaddingBottom").unwrap_or(vert)));
+ let main = match v.str("stackPrimaryAlignItems").or_else(|| v.str("stackJustify")) {
+ Some("CENTER") => "CENTER",
+ Some("MAX") => "MAX",
+ Some("SPACE_BETWEEN" | "SPACE_EVENLY" | "SPACE_AROUND" | "SPACE_EVENLY_CSS") => "SPACE_BETWEEN",
+ _ => "MIN",
+ };
+ o.insert("primaryAxisAlignItems".into(), json!(main));
+ let cross = match v.str("stackCounterAlignItems").or_else(|| v.str("stackAlign")) {
+ Some("CENTER") => "CENTER",
+ Some("MAX") => "MAX",
+ Some("BASELINE") => "BASELINE",
+ _ => "MIN",
+ };
+ o.insert("counterAxisAlignItems".into(), json!(cross));
+ let sizing = |k: &str| if v.str(k).is_some_and(|s| s.starts_with("RESIZE_TO_FIT")) { "AUTO" } else { "FIXED" };
+ o.insert("primaryAxisSizingMode".into(), json!(sizing("stackPrimarySizing")));
+ o.insert("counterAxisSizingMode".into(), json!(sizing("stackCounterSizing")));
+ if let Some(b) = v.flag("bordersTakeSpace") {
+ o.insert("strokesIncludedInLayout".into(), json!(b));
+ }
+ if let Some(b) = v.flag("stackReverseZIndex") {
+ o.insert("itemReverseZIndex".into(), json!(b));
+ }
+ if mode == "GRID" {
+ copy_num(o, v, "gridColumnGap", "gridColumnGap");
+ copy_num(o, v, "gridRowGap", "gridRowGap");
+ o.insert("gridColumnCount".into(), json!(tracks(v, "gridColumns").len()));
+ o.insert("gridRowCount".into(), json!(tracks(v, "gridRows").len()));
+ let sizes = ["gridColumnsSizing", "gridRowsSizing"]
+ .iter()
+ .flat_map(|k| v.get(k).and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default());
+ let flexible = sizes.into_iter().all(|e| {
+ let t = e.get("trackSize");
+ ["minSizing", "maxSizing"]
+ .iter()
+ .all(|s| t.and_then(|t| t.get(s)).and_then(|s| s.get("type")).and_then(Kv::str).is_none_or(|x| x == "FLEX"))
+ });
+ if !flexible {
+ self.warn("grid tracks of fixed or content size import as equal tracks");
+ }
+ }
+ }
+
+ fn text(&mut self, o: &mut Obj, v: &View<'a>) {
+ let style_node = v
+ .get("styleIdForText")
+ .and_then(ref_guid)
+ .or_else(|| v.get("inheritTextStyleID").and_then(guid))
+ .and_then(|s| self.nodes.get(&s).copied());
+ let sv = v.with(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")));
+ let td = v.get("textData");
+ let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or("");
+ o.insert("characters".into(), json!(chars));
+ let mut style = text_style(&sv, None);
+ for k in ["textAlignHorizontal", "textAlignVertical", "textAutoResize", "textTruncation"] {
+ copy_str(&mut style, v, k, k);
+ }
+ if let Some(n) = v.num("maxLines").filter(|n| *n >= 1.0) {
+ style.insert("maxLines".into(), json!(n));
+ }
+ let size = sv.num("fontSize");
+ o.insert("style".into(), Value::Object(style));
+ let Some(td) = td else { return };
+ let ids: Vec = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect();
+ if ids.iter().any(|i| *i != 0) {
+ let mut table = Obj::new();
+ for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() {
+ let Some(sid) = num(e, "styleID") else { continue };
+ let mut s = text_style(&View::of(e), size);
+ if let Some(fills) = e.get("fillPaints") {
+ s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(paint).collect()));
+ }
+ table.insert((sid as u64).to_string(), Value::Object(s));
+ }
+ o.insert("characterStyleOverrides".into(), json!(ids));
+ o.insert("styleOverrideTable".into(), Value::Object(table));
+ }
+ let lines: Vec<&str> = td
+ .get("lines")
+ .map(Kv::list)
+ .unwrap_or_default()
+ .iter()
+ .map(|l| match l.get("lineType").and_then(Kv::str) {
+ Some("ORDERED_LIST") => "ORDERED",
+ Some("UNORDERED_LIST") => "UNORDERED",
+ _ => "NONE",
+ })
+ .collect();
+ if lines.iter().any(|l| *l != "NONE") {
+ o.insert("lineTypes".into(), json!(lines));
+ }
+ }
+
+ /// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters.
+ fn geometry(&mut self, o: &mut Obj, v: &View<'a>) {
+ for key in ["fillGeometry", "strokeGeometry"] {
+ let mut out = Vec::new();
+ for p in v.list(key) {
+ let Some(blob) = num(p, "commandsBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { continue };
+ let Some(d) = svg_path(blob) else {
+ self.warn("damaged vector geometry was skipped");
+ continue;
+ };
+ if d.is_empty() {
+ continue;
+ }
+ let winding = if p.get("windingRule").and_then(Kv::str) == Some("ODD") { "EVENODD" } else { "NONZERO" };
+ out.push(json!({"path": d, "windingRule": winding}));
+ }
+ if !out.is_empty() {
+ o.insert(key.into(), Value::Array(out));
+ }
+ }
+ let vector = o.get("type").and_then(Value::as_str) == Some("VECTOR");
+ if vector && !o.contains_key("fillGeometry") && !o.contains_key("strokeGeometry") {
+ self.warn("vectors without geometry are empty");
+ }
+ if !vector {
+ copy_num(o, v, "count", "pointCount");
+ copy_num(o, v, "starInnerScale", "innerRadius");
+ }
+ }
+
+ /// Component property definitions and description.
+ fn component(&mut self, o: &mut Obj, v: &View<'a>, id: &str) {
+ let mut defs = Obj::new();
+ for d in v.list("componentPropDefs") {
+ if d.get("isDeleted").and_then(Kv::bool) == Some(true) {
+ continue;
+ }
+ let Some((name, ty)) = d.get("id").and_then(guid).and_then(|i| self.prop_defs.get(&i).cloned()) else { continue };
+ let init = d.get("initialValue");
+ let def = match ty {
+ "BOOL" => json!({"type": "BOOLEAN", "defaultValue": init.and_then(|i| i.get("boolValue")).and_then(Kv::bool).unwrap_or(true)}),
+ "TEXT" => json!({"type": "TEXT", "defaultValue": init.and_then(prop_text).unwrap_or("")}),
+ "INSTANCE_SWAP" => match init.and_then(|i| i.get("guidValue")).and_then(guid) {
+ Some(g) => json!({"type": "INSTANCE_SWAP", "defaultValue": g}),
+ None => continue,
+ },
+ "VARIANT" => json!({"type": "VARIANT"}),
+ _ => {
+ self.warn(format!("{} component properties are not supported and were skipped", ty.to_ascii_lowercase()));
+ continue;
+ }
+ };
+ defs.insert(name, def);
+ }
+ if !defs.is_empty() {
+ o.insert("componentPropertyDefinitions".into(), Value::Object(defs));
+ }
+ if let Some(d) = v.str("symbolDescription").or_else(|| v.str("description")).filter(|d| !d.is_empty()) {
+ self.components.insert(id.to_string(), json!({"description": d}));
+ }
+ }
+
+ /// A layer's component property references, and inside an instance the values they take.
+ fn prop_refs(&mut self, o: &mut Obj, v: &View<'a>, inst: Option<&Inst<'a>>) {
+ let mut refs = Obj::new();
+ for e in v.get("parameterConsumptionMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() {
+ let field = match e.get("variableField").and_then(Kv::str) {
+ Some("VISIBLE") => "visible",
+ Some("TEXT_DATA") => "characters",
+ Some("OVERRIDDEN_SYMBOL_ID") => "mainComponent",
+ _ => continue,
+ };
+ let Some(def) =
+ e.get("variableData").and_then(|d| d.get("value")).and_then(|x| x.get("propRefValue")).and_then(|p| p.get("defId")).and_then(guid)
+ else {
+ continue;
+ };
+ if let Some((name, _)) = self.prop_defs.get(&def) {
+ refs.insert(field.into(), json!(name));
+ }
+ let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue };
+ match field {
+ "visible" => {
+ if let Some(b) = val.get("boolValue").and_then(Kv::bool) {
+ o.insert("visible".into(), json!(b));
+ }
+ }
+ "characters" => {
+ if let Some(t) = prop_text(val) {
+ o.insert("characters".into(), json!(t));
+ }
+ }
+ _ => {}
+ }
+ }
+ if !refs.is_empty() {
+ o.insert("componentPropertyReferences".into(), Value::Object(refs));
+ }
+ }
+
+ /// An instance's main and properties; the context its expanded layers are built in.
+ fn instance(&mut self, o: &mut Obj, v: &View<'a>, id: &str, outer: Option<&Inst<'a>>, rest_id: &str) -> Option<(Inst<'a>, String)> {
+ let sd = v.get("symbolData");
+ let mut sym = v.get("overriddenSymbolID").and_then(guid).or_else(|| sd.and_then(|s| s.get("symbolID")).and_then(guid));
+ // An instance swap property value picks the main.
+ if let Some(i) = outer
+ && let Some(def) = prop_ref(v, "OVERRIDDEN_SYMBOL_ID")
+ && let Some(g) = i.props.get(&def).and_then(|p| p.get("guidValue")).and_then(guid)
+ {
+ sym = Some(g);
+ }
+ let Some(sym) = sym else {
+ self.warn("instances without a main component were imported as empty frames");
+ return None;
+ };
+ o.insert("componentId".into(), json!(sym));
+ let assignments = v.list("componentPropAssignments");
+ let mut props = Obj::new();
+ for a in assignments {
+ let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_defs.get(&d).cloned()) else { continue };
+ let val = a.get("value");
+ let p = match ty {
+ "BOOL" => val.and_then(|x| x.get("boolValue")).and_then(Kv::bool).map(|b| json!({"type": "BOOLEAN", "value": b})),
+ "TEXT" => val.and_then(prop_text).map(|t| json!({"type": "TEXT", "value": t})),
+ "INSTANCE_SWAP" => val.and_then(|x| x.get("guidValue")).and_then(guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})),
+ _ => None,
+ };
+ if let Some(p) = p {
+ props.insert(name, p);
+ }
+ }
+ if !props.is_empty() {
+ o.insert("componentProperties".into(), Value::Object(props));
+ }
+ if !self.nodes.contains_key(&sym) {
+ self.warn("instances of components that aren't in the file show no layers");
+ return None;
+ }
+ if outer.is_some_and(|i| i.symbols.contains(&sym)) {
+ return None;
+ }
+ // Overrides by id path below this instance (the instance itself is addressed by its main's id).
+ let own_path: Vec = outer.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()).unwrap_or_default();
+ let mut overrides: BTreeMap, Vec<&'a Kv<'a>>> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default();
+ let lists = [sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), v.list("derivedSymbolData")];
+ for (layer, list) in lists.into_iter().enumerate() {
+ for ov in list {
+ let rel: Vec =
+ ov.get("guidPath").and_then(|g| g.get("guids")).map(Kv::list).unwrap_or_default().iter().filter_map(guid).collect();
+ if rel.is_empty() || rel == [sym.clone()] {
+ continue;
+ }
+ let e = overrides.entry([own_path.as_slice(), &rel].concat()).or_default();
+ // The outer instance's overrides win over this one's own; derived layout comes last.
+ if layer == 0 && outer.is_some() {
+ e.insert(0, ov);
+ } else {
+ e.push(ov);
+ }
+ }
+ }
+ let mut all_props: BTreeMap> = outer.map(|i| (*i.props).clone()).unwrap_or_default();
+ for a in assignments {
+ if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) {
+ all_props.entry(d).or_insert(val);
+ }
+ }
+ let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default();
+ symbols.push(sym.clone());
+ let root = outer.map_or_else(|| rest_id.to_string(), |i| i.root.clone());
+ Some((Inst { root, path: own_path, overrides: Rc::new(overrides), props: Rc::new(all_props), symbols }, sym))
+ }
+
+ fn prototype(&mut self, o: &mut Obj, v: &View<'a>) {
+ let mut list = Vec::new();
+ for it in v.list("prototypeInteractions") {
+ if it.get("isDeleted").and_then(Kv::bool) == Some(true) {
+ continue;
+ }
+ if let Some(i) = self.interaction(it) {
+ list.push(i);
+ }
+ }
+ if !list.is_empty() {
+ o.insert("interactions".into(), Value::Array(list));
+ }
+ let overflow = match v.str("scrollDirection") {
+ Some("HORIZONTAL") => Some("HORIZONTAL_SCROLLING"),
+ Some("VERTICAL") => Some("VERTICAL_SCROLLING"),
+ Some("BOTH") => Some("HORIZONTAL_AND_VERTICAL_SCROLLING"),
+ _ => None,
+ };
+ if let Some(d) = overflow {
+ o.insert("overflowDirection".into(), json!(d));
+ }
+ let behavior = match v.str("scrollBehavior") {
+ Some("FIXED_WHEN_CHILD_OF_SCROLLING_FRAME") => Some("FIXED"),
+ Some("STICKY_SCROLLS") => Some("STICKY_SCROLLS"),
+ _ => None,
+ };
+ if let Some(b) = behavior {
+ o.insert("scrollBehavior".into(), json!(b));
+ }
+ copy_str(o, v, "overlayPositionType", "overlayPositionType");
+ copy_str(o, v, "overlayBackgroundInteraction", "overlayBackgroundInteraction");
+ if let Some(a) = v.get("overlayBackgroundAppearance") {
+ let ty = a.get("backgroundType").and_then(Kv::str).unwrap_or("NONE");
+ o.insert("overlayBackground".into(), json!({"type": ty, "color": a.get("backgroundColor").map_or(Value::Null, color)}));
+ }
+ }
+
+ fn interaction(&mut self, it: &Kv) -> Option {
+ let ev = it.get("event");
+ let ty = match ev.and_then(|e| e.get("interactionType")).and_then(Kv::str).unwrap_or("ON_CLICK") {
+ "DRAG" => "ON_DRAG",
+ "MOUSE_IN" => "MOUSE_ENTER",
+ "MOUSE_OUT" => "MOUSE_LEAVE",
+ t => t,
+ };
+ let mut trigger = json!({"type": ty});
+ if let Some(t) = ev.and_then(|e| num(e, "transitionTimeout")) {
+ trigger["timeout"] = json!(t * 1000.0);
+ }
+ if let Some(k) = ev.and_then(|e| e.get("keyTrigger")) {
+ let codes: Vec = k.get("keyCodes").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).collect();
+ trigger["keyCodes"] = json!(codes);
+ if let Some(d) = k.get("triggerDevice").and_then(Kv::str) {
+ trigger["device"] = json!(if d.contains("KEYBOARD") { "KEYBOARD" } else { d });
+ }
+ }
+ let mut actions = Vec::new();
+ for a in it.get("actions").map(Kv::list).unwrap_or_default() {
+ match a.get("connectionType").and_then(Kv::str) {
+ Some("INTERNAL_NODE") | None => {
+ let Some(dest) = a.get("transitionNodeID").and_then(guid) else { continue };
+ let nav = match a.get("navigationType").and_then(Kv::str) {
+ Some("SWAP_STATE") => "CHANGE_TO",
+ Some(n) => n,
+ None => "NAVIGATE",
+ };
+ let mut act = json!({"type": "NODE", "destinationId": dest, "navigation": nav});
+ if let Some(t) = transition(a) {
+ act["transition"] = t;
+ }
+ if let Some(b) = a.get("transitionPreserveScroll").and_then(Kv::bool) {
+ act["preserveScrollPosition"] = json!(b);
+ }
+ actions.push(act);
+ }
+ Some("URL") => {
+ if let Some(u) = a.get("connectionURL").and_then(Kv::str) {
+ actions.push(json!({"type": "URL", "url": u}));
+ }
+ }
+ Some("BACK") => actions.push(json!({"type": "BACK"})),
+ Some("CLOSE") => actions.push(json!({"type": "CLOSE"})),
+ Some("NONE") => {}
+ Some(other) => {
+ self.warn(format!("{} prototype actions are not imported from .fig files yet", other.to_ascii_lowercase().replace('_', " ")))
+ }
+ }
+ }
+ (!actions.is_empty()).then(|| json!({"trigger": trigger, "actions": actions}))
+ }
+
+ fn variable_bindings(&mut self, o: &mut Obj, v: &View<'a>) {
+ let mut bound = Obj::new();
+ let mut text_bound = Obj::new();
+ for e in v.get("variableConsumptionMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() {
+ let Some(var) = alias(e.get("variableData")) else { continue };
+ let a = json!({"type": "VARIABLE_ALIAS", "id": var});
+ let field = e.get("variableField").and_then(Kv::str).unwrap_or("");
+ match (binding_key(field), text_binding_key(field)) {
+ (Some(k), _) => {
+ bound.insert(k.into(), a);
+ }
+ (_, Some(k)) => {
+ text_bound.insert(k.into(), a);
+ }
+ _ if field == "CORNER_RADIUS" => {
+ for k in ["topLeftRadius", "topRightRadius", "bottomLeftRadius", "bottomRightRadius"] {
+ bound.insert(k.into(), a.clone());
+ }
+ }
+ _ if field == "WIDTH" || field == "HEIGHT" => {
+ let axis = if field == "WIDTH" { "x" } else { "y" };
+ let size = bound.entry("size").or_insert_with(|| json!({}));
+ size[axis] = a;
+ }
+ _ => self.warn(format!("variables bound to {} are not imported from .fig files yet", field.to_ascii_lowercase().replace('_', " "))),
+ }
+ }
+ if !bound.is_empty() {
+ o.insert("boundVariables".into(), Value::Object(bound));
+ }
+ if !text_bound.is_empty()
+ && let Some(Value::Object(s)) = o.get_mut("style")
+ {
+ s.insert("boundVariables".into(), Value::Object(text_bound));
+ }
+ let mut modes = Obj::new();
+ for e in v.get("variableModeBySetMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() {
+ if let (Some(set), Some(mode)) = (e.get("variableSetID").and_then(ref_guid), e.get("variableModeID").and_then(guid)) {
+ modes.insert(set, json!(mode));
+ }
+ }
+ if !modes.is_empty() {
+ o.insert("explicitVariableModes".into(), Value::Object(modes));
+ }
+ }
+
+ /// Collections and variables, as the local variables response's `meta`.
+ fn variables(&self) -> Option {
+ let of_type = |t: &'static str| {
+ self.nodes
+ .iter()
+ .filter(move |(_, n)| n.get("type").and_then(Kv::str) == Some(t) && n.get("isSoftDeleted").and_then(Kv::bool) != Some(true))
+ };
+ let mut collections = Obj::new();
+ for (id, n) in of_type("VARIABLE_SET") {
+ let mut modes: Vec<(&str, Value)> = n
+ .get("variableSetModes")
+ .map(Kv::list)
+ .unwrap_or_default()
+ .iter()
+ .filter_map(|m| {
+ let mode = json!({"modeId": m.get("id").and_then(guid)?, "name": m.get("name").and_then(Kv::str).unwrap_or("Mode")});
+ Some((m.get("sortPosition").and_then(Kv::str).unwrap_or(""), mode))
+ })
+ .collect();
+ modes.sort_by(|a, b| a.0.cmp(b.0));
+ let default = modes.first().map_or(Value::Null, |m| m.1["modeId"].clone());
+ let name = n.get("name").and_then(Kv::str).unwrap_or("Collection");
+ let modes: Vec = modes.into_iter().map(|m| m.1).collect();
+ collections.insert(id.clone(), json!({"id": id, "name": name, "modes": modes, "defaultModeId": default}));
+ }
+ let mut variables = Obj::new();
+ for (id, n) in of_type("VARIABLE") {
+ let Some(set) = n.get("variableSetID").and_then(ref_guid).filter(|s| collections.contains_key(s)) else { continue };
+ let ty = n.get("variableResolvedType").and_then(Kv::str).unwrap_or("");
+ if !matches!(ty, "BOOLEAN" | "FLOAT" | "STRING" | "COLOR") {
+ continue;
+ }
+ let mut values = Obj::new();
+ for e in n.get("variableDataValues").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() {
+ if let (Some(mode), Some(val)) = (e.get("modeID").and_then(guid), e.get("variableData").and_then(variable_value)) {
+ values.insert(mode, val);
+ }
+ }
+ let name = n.get("name").and_then(Kv::str).unwrap_or("Variable");
+ let desc = n.get("description").and_then(Kv::str).unwrap_or("");
+ variables.insert(
+ id.clone(),
+ json!({"id": id, "name": name, "variableCollectionId": set, "resolvedType": ty, "valuesByMode": values, "description": desc}),
+ );
+ }
+ (!collections.is_empty()).then(|| json!({"variableCollections": collections, "variables": variables}))
+ }
+}
+
+/// The REST `boundVariables` key of a bound layer field.
+fn binding_key(field: &str) -> Option<&'static str> {
+ Some(match field {
+ "STROKE_WEIGHT" => "strokeWeight",
+ "STACK_SPACING" => "itemSpacing",
+ "STACK_COUNTER_SPACING" => "counterAxisSpacing",
+ "STACK_PADDING_LEFT" => "paddingLeft",
+ "STACK_PADDING_TOP" => "paddingTop",
+ "STACK_PADDING_RIGHT" => "paddingRight",
+ "STACK_PADDING_BOTTOM" => "paddingBottom",
+ "VISIBLE" => "visible",
+ "TEXT_DATA" => "characters",
+ "OPACITY" => "opacity",
+ "MIN_WIDTH" => "minWidth",
+ "MAX_WIDTH" => "maxWidth",
+ "MIN_HEIGHT" => "minHeight",
+ "MAX_HEIGHT" => "maxHeight",
+ "RECTANGLE_TOP_LEFT_CORNER_RADIUS" => "topLeftRadius",
+ "RECTANGLE_TOP_RIGHT_CORNER_RADIUS" => "topRightRadius",
+ "RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS" => "bottomLeftRadius",
+ "RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS" => "bottomRightRadius",
+ _ => return None,
+ })
+}
+
+/// The REST text style `boundVariables` key of a bound text field.
+fn text_binding_key(field: &str) -> Option<&'static str> {
+ Some(match field {
+ "FONT_SIZE" => "fontSize",
+ "FONT_FAMILY" => "fontFamily",
+ "FONT_STYLE" => "fontStyle",
+ "LETTER_SPACING" => "letterSpacing",
+ "LINE_HEIGHT" => "lineHeight",
+ "PARAGRAPH_SPACING" => "paragraphSpacing",
+ "PARAGRAPH_INDENT" => "paragraphIndent",
+ _ => return None,
+ })
+}
+
+fn stroke(o: &mut Obj, v: &View) {
+ copy_num(o, v, "strokeWeight", "strokeWeight");
+ if let Some(a) = v.str("strokeAlign").filter(|a| *a != "OFFSET") {
+ o.insert("strokeAlign".into(), json!(a));
+ }
+ copy_str(o, v, "strokeJoin", "strokeJoin");
+ if let Some(cap) = v.str("strokeCap") {
+ let cap = match cap {
+ "ARROW_LINES" => "LINE_ARROW",
+ "ARROW_EQUILATERAL" => "TRIANGLE_ARROW",
+ c => c,
+ };
+ o.insert("strokeCap".into(), json!(cap));
+ }
+ let dashes: Vec = v.list("dashPattern").iter().filter_map(Kv::f64).collect();
+ if !dashes.is_empty() {
+ o.insert("strokeDashes".into(), json!(dashes));
+ }
+ // A miter limit L bevels joins sharper than 2Β·asin(1/L).
+ if let Some(l) = v.num("miterLimit").filter(|l| *l > 1.0) {
+ o.insert("strokeMiterAngle".into(), json!((2.0 * (1.0 / l).asin()).to_degrees()));
+ }
+ if v.flag("borderStrokeWeightsIndependent") == Some(true) {
+ let w = v.num("strokeWeight").unwrap_or(0.0);
+ let side = |k: &str| v.num(k).unwrap_or(w);
+ o.insert(
+ "individualStrokeWeights".into(),
+ json!({"top": side("borderTopWeight"), "right": side("borderRightWeight"), "bottom": side("borderBottomWeight"), "left": side("borderLeftWeight")}),
+ );
+ }
+}
+
+/// How a layer sizes and sits in an auto layout parent.
+fn child_layout(o: &mut Obj, v: &View, parent: Option<&View>) {
+ copy_num(o, v, "stackChildPrimaryGrow", "layoutGrow");
+ if v.str("stackChildAlignSelf") == Some("STRETCH") {
+ o.insert("layoutAlign".into(), json!("STRETCH"));
+ }
+ if v.str("stackPositioning") == Some("ABSOLUTE") {
+ o.insert("layoutPositioning".into(), json!("ABSOLUTE"));
+ }
+ for (k, w, h) in [("minSize", "minWidth", "minHeight"), ("maxSize", "maxWidth", "maxHeight")] {
+ if let Some(val) = v.get(k).and_then(|s| s.get("value")) {
+ for (axis, to) in [("x", w), ("y", h)] {
+ if let Some(x) = num(val, axis).filter(|x| *x > 0.0 && *x < 1e9) {
+ o.insert(to.into(), json!(x));
+ }
+ }
+ }
+ }
+ if let Some(p) = parent.filter(|p| p.str("stackMode") == Some("GRID")) {
+ for (anchor, list, to) in [("gridColumnAnchor", "gridColumns", "gridColumnAnchorIndex"), ("gridRowAnchor", "gridRows", "gridRowAnchorIndex")]
+ {
+ if let Some(i) = v.get(anchor).and_then(guid).and_then(|g| tracks(p, list).iter().position(|t| *t == g)) {
+ o.insert(to.into(), json!(i));
+ }
+ }
+ copy_num(o, v, "gridColumnSpan", "gridColumnSpan");
+ copy_num(o, v, "gridRowSpan", "gridRowSpan");
+ }
+}
+
+fn paint(p: &Kv) -> Option {
+ let ty = p.get("type").and_then(Kv::str)?;
+ let mut o = Obj::new();
+ o.insert("type".into(), json!(ty));
+ if let Some(x) = num(p, "opacity") {
+ o.insert("opacity".into(), json!(x));
+ }
+ if let Some(b) = p.get("visible").and_then(Kv::bool) {
+ o.insert("visible".into(), json!(b));
+ }
+ if let Some(b) = p.get("blendMode").and_then(Kv::str) {
+ o.insert("blendMode".into(), json!(b));
+ }
+ let t = p.get("transform").and_then(matrix);
+ match ty {
+ "SOLID" => {
+ if let Some(c) = p.get("color") {
+ o.insert("color".into(), color(c));
+ }
+ if let Some(var) = alias(p.get("colorVar")) {
+ o.insert("boundVariables".into(), json!({"color": {"type": "VARIABLE_ALIAS", "id": var}}));
+ }
+ }
+ _ if ty.starts_with("GRADIENT_") => {
+ // The paint transform maps the layer's unit box to gradient space, where a linear
+ // gradient runs from (0, Β½) to (1, Β½) and the others are centred on (Β½, Β½).
+ let inv = t.filter(|t| t.determinant().abs() > 1e-12).map_or(Affine::IDENTITY, Affine::inverse);
+ let pts = if ty == "GRADIENT_LINEAR" { [(0.0, 0.5), (1.0, 0.5), (0.0, 1.0)] } else { [(0.5, 0.5), (1.0, 0.5), (0.5, 1.0)] };
+ let handles: Vec = pts
+ .iter()
+ .map(|(x, y)| {
+ let q = inv * Point::new(*x, *y);
+ json!({"x": q.x, "y": q.y})
+ })
+ .collect();
+ o.insert("gradientHandlePositions".into(), Value::Array(handles));
+ let stops: Vec = p
+ .get("stops")
+ .map(Kv::list)
+ .unwrap_or_default()
+ .iter()
+ .map(|s| json!({"position": num(s, "position").unwrap_or(0.0), "color": s.get("color").map_or(Value::Null, color)}))
+ .collect();
+ o.insert("gradientStops".into(), Value::Array(stops));
+ }
+ "IMAGE" => {
+ let hash: String =
+ p.get("image").and_then(|i| i.get("hash")).and_then(Kv::bytes).unwrap_or_default().iter().map(|b| format!("{b:02x}")).collect();
+ o.insert("imageRef".into(), json!(hash));
+ let mode = p.get("imageScaleMode").and_then(Kv::str).unwrap_or("FILL");
+ o.insert("scaleMode".into(), json!(mode));
+ if let (Some(t), "STRETCH") = (t, mode) {
+ o.insert("imageTransform".into(), rows(t));
+ }
+ if let Some(s) = num(p, "scale") {
+ o.insert("scalingFactor".into(), json!(s));
+ }
+ if let Some(r) = num(p, "rotation") {
+ o.insert("rotation".into(), json!(r));
+ }
+ }
+ _ => {}
+ }
+ Some(Value::Object(o))
+}
+
+/// The track ids of a grid's columns or rows, in order.
+fn tracks(v: &View, key: &str) -> Vec {
+ let mut t: Vec<(&str, String)> = v
+ .get(key)
+ .and_then(|m| m.get("entries"))
+ .map(Kv::list)
+ .unwrap_or_default()
+ .iter()
+ .filter_map(|e| Some((e.get("position").and_then(Kv::str).unwrap_or(""), e.get("id").and_then(guid)?)))
+ .collect();
+ t.sort();
+ t.into_iter().map(|e| e.1).collect()
+}
+
+/// The variable an `ALIAS` variable data points at.
+fn alias(d: Option<&Kv>) -> Option {
+ let d = d?;
+ if d.get("dataType").and_then(Kv::str) != Some("ALIAS") {
+ return None;
+ }
+ d.get("value").and_then(|v| v.get("alias")).and_then(ref_guid)
+}
+
+fn variable_value(d: &Kv) -> Option {
+ if let Some(a) = alias(Some(d)) {
+ return Some(json!({"type": "VARIABLE_ALIAS", "id": a}));
+ }
+ let v = d.get("value")?;
+ if let Some(c) = v.get("colorValue") {
+ return Some(color(c));
+ }
+ if let Some(b) = v.get("boolValue").and_then(Kv::bool) {
+ return Some(json!(b));
+ }
+ if let Some(x) = num(v, "floatValue") {
+ return Some(json!(x));
+ }
+ v.get("textValue").and_then(Kv::str).map(|s| json!(s))
+}
+
+/// The text of a component property value.
+fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> {
+ v.get("textValue").and_then(|t| t.get("characters")).and_then(Kv::str)
+}
+
+/// The property a layer's `field` is bound to (`parameterConsumptionMap`).
+fn prop_ref(v: &View, field: &str) -> Option {
+ v.get("parameterConsumptionMap")?.get("entries")?.list().iter().find_map(|e| {
+ if e.get("variableField").and_then(Kv::str) != Some(field) {
+ return None;
+ }
+ e.get("variableData")?.get("value")?.get("propRefValue")?.get("defId").and_then(guid)
+ })
+}
+
+/// The REST TypeStyle fields a node (or text override entry) sets. `size`: the font size that
+/// letter spacing in percent refers to when the entry doesn't set one.
+fn text_style(v: &View, size: Option) -> Obj {
+ let mut s = Obj::new();
+ if let Some(f) = v.get("fontName") {
+ for (from, to) in [("family", "fontFamily"), ("style", "fontStyle"), ("postscript", "fontPostScriptName")] {
+ if let Some(x) = f.get(from).and_then(Kv::str).filter(|x| !x.is_empty()) {
+ s.insert(to.into(), json!(x));
+ }
+ }
+ }
+ copy_num(&mut s, v, "fontSize", "fontSize");
+ copy_num(&mut s, v, "paragraphSpacing", "paragraphSpacing");
+ copy_num(&mut s, v, "paragraphIndent", "paragraphIndent");
+ copy_str(&mut s, v, "textCase", "textCase");
+ copy_str(&mut s, v, "textDecoration", "textDecoration");
+ let font_size = v.num("fontSize").or(size).unwrap_or(12.0);
+ if let Some(ls) = v.get("letterSpacing") {
+ let val = num(ls, "value").unwrap_or(0.0);
+ let px = match ls.get("units").and_then(Kv::str) {
+ Some("PIXELS") => val,
+ Some("PERCENT") => val / 100.0 * font_size,
+ _ => val * font_size,
+ };
+ s.insert("letterSpacing".into(), json!(px));
+ }
+ if let Some(lh) = v.get("lineHeight") {
+ let val = num(lh, "value").unwrap_or(100.0);
+ match lh.get("units").and_then(Kv::str) {
+ Some("PIXELS") => {
+ s.insert("lineHeightUnit".into(), json!("PIXELS"));
+ s.insert("lineHeightPx".into(), json!(val));
+ }
+ // shortcut: 100 % reads as the font's own line height (new text saves "auto" so) and
+ // other percentages as % of the font size; revisit with files that set 100 % explicitly.
+ Some("PERCENT") if (val - 100.0).abs() < 1e-6 => {
+ s.insert("lineHeightUnit".into(), json!("INTRINSIC_%"));
+ }
+ Some("PERCENT") => {
+ s.insert("lineHeightUnit".into(), json!("FONT_SIZE_%"));
+ s.insert("lineHeightPercentFontSize".into(), json!(val));
+ }
+ _ => {
+ s.insert("lineHeightUnit".into(), json!("FONT_SIZE_%"));
+ s.insert("lineHeightPercentFontSize".into(), json!(val * 100.0));
+ }
+ }
+ }
+ if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str).filter(|u| !u.is_empty()) {
+ s.insert("hyperlink".into(), json!({"type": "URL", "url": u}));
+ }
+ s
+}
+
+fn effect(e: &Kv) -> Option {
+ let ty = match e.get("type").and_then(Kv::str)? {
+ "FOREGROUND_BLUR" => "LAYER_BLUR",
+ t => t,
+ };
+ let mut o = Obj::new();
+ o.insert("type".into(), json!(ty));
+ for k in ["radius", "spread", "startRadius", "density", "opacity"] {
+ if let Some(x) = num(e, k) {
+ o.insert(k.into(), json!(x));
+ }
+ }
+ for k in ["visible", "showShadowBehindNode", "clipToShape"] {
+ if let Some(b) = e.get(k).and_then(Kv::bool) {
+ o.insert(k.into(), json!(b));
+ }
+ }
+ for k in ["blendMode", "noiseType"] {
+ if let Some(s) = e.get(k).and_then(Kv::str) {
+ o.insert(k.into(), json!(s));
+ }
+ }
+ for k in ["color", "secondaryColor"] {
+ if let Some(c) = e.get(k) {
+ o.insert(k.into(), color(c));
+ }
+ }
+ for (from, to) in [("offset", "offset"), ("startOffset", "startOffset"), ("endOffset", "endOffset"), ("noiseSize", "noiseSizeVector")] {
+ if let Some(p) = e.get(from).and_then(vec2) {
+ o.insert(to.into(), p);
+ }
+ }
+ if e.get("blurOpType").and_then(Kv::str) == Some("PROGRESSIVE") {
+ o.insert("blurType".into(), json!("PROGRESSIVE"));
+ }
+ Some(Value::Object(o))
+}
+
+fn layout_grid(g: &Kv) -> Value {
+ let pattern = match (g.get("pattern").and_then(Kv::str), g.get("axis").and_then(Kv::str)) {
+ (Some("GRID"), _) => "GRID",
+ (_, Some("Y")) => "ROWS",
+ _ => "COLUMNS",
+ };
+ let mut o = json!({"pattern": pattern, "alignment": g.get("type").and_then(Kv::str).unwrap_or("STRETCH")});
+ for (from, to) in [("sectionSize", "sectionSize"), ("numSections", "count"), ("gutterSize", "gutterSize"), ("offset", "offset")] {
+ if let Some(x) = num(g, from) {
+ o[to] = json!(x);
+ }
+ }
+ if let Some(c) = g.get("color") {
+ o["color"] = color(c);
+ }
+ if let Some(b) = g.get("visible").and_then(Kv::bool) {
+ o["visible"] = json!(b);
+ }
+ o
+}
+
+fn export_setting(e: &Kv) -> Value {
+ let c = e.get("constraint");
+ let ty = match c.and_then(|c| c.get("type")).and_then(Kv::str) {
+ Some("CONTENT_WIDTH") => "WIDTH",
+ Some("CONTENT_HEIGHT") => "HEIGHT",
+ _ => "SCALE",
+ };
+ json!({
+ "format": e.get("imageType").and_then(Kv::str).unwrap_or("PNG"),
+ "suffix": e.get("suffix").and_then(Kv::str).unwrap_or(""),
+ "constraint": {"type": ty, "value": c.and_then(|c| num(c, "value")).unwrap_or(1.0)},
+ })
+}
+
+/// A prototype action's transition (`None`: instant).
+fn transition(a: &Kv) -> Option {
+ let ty = a.get("transitionType").and_then(Kv::str)?;
+ // Directions name where the incoming screen moves (SLIDE_FROM_LEFT moves right).
+ let (kind, dir) = match ty {
+ "INSTANT_TRANSITION" => return None,
+ "DISSOLVE" | "FADE" => ("DISSOLVE", None),
+ "SMART_ANIMATE" | "MAGIC_MOVE" => ("SMART_ANIMATE", None),
+ "SCROLL_ANIMATE" => ("SCROLL_ANIMATE", None),
+ t => {
+ let prefixes = [
+ ("SLIDE_FROM_", "SLIDE_IN"),
+ ("PUSH_FROM_", "PUSH"),
+ ("MOVE_FROM_", "MOVE_IN"),
+ ("SLIDE_OUT_TO_", "SLIDE_OUT"),
+ ("MOVE_OUT_TO_", "MOVE_OUT"),
+ ];
+ let (kind, side) = prefixes.iter().find_map(|(p, k)| t.strip_prefix(p).map(|r| (*k, r)))?;
+ let incoming = matches!(kind, "SLIDE_IN" | "PUSH" | "MOVE_IN");
+ let dir = match (side, incoming) {
+ ("LEFT", true) | ("RIGHT", false) => "RIGHT",
+ ("RIGHT", true) | ("LEFT", false) => "LEFT",
+ ("TOP", true) | ("BOTTOM", false) => "BOTTOM",
+ _ => "TOP",
+ };
+ (kind, Some(dir))
+ }
+ };
+ let easing = match a.get("easingType").and_then(Kv::str) {
+ Some("IN_CUBIC" | "EASE_IN") => "EASE_IN",
+ Some("INOUT_CUBIC") => "EASE_IN_AND_OUT",
+ Some("LINEAR") => "LINEAR",
+ Some("IN_BACK_CUBIC") => "EASE_IN_BACK",
+ Some("OUT_BACK_CUBIC") => "EASE_OUT_BACK",
+ Some("INOUT_BACK_CUBIC") => "EASE_IN_AND_OUT_BACK",
+ Some("GENTLE_SPRING") => "GENTLE",
+ Some("CUSTOM_CUBIC") => "CUSTOM_CUBIC_BEZIER",
+ _ => "EASE_OUT",
+ };
+ let mut e = json!({"type": easing});
+ let f: Vec = a.get("easingFunction").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).collect();
+ if let (true, [x1, y1, x2, y2, ..]) = (easing == "CUSTOM_CUBIC_BEZIER", f.as_slice()) {
+ e["easingFunctionCubicBezier"] = json!({"x1": x1, "y1": y1, "x2": x2, "y2": y2});
+ }
+ let mut t = json!({"type": kind, "duration": num(a, "transitionDuration").unwrap_or(0.3) * 1000.0, "easing": e});
+ if let Some(d) = dir {
+ t["direction"] = json!(d);
+ }
+ Some(t)
+}
+
+/// SVG path data of a geometry blob: commands `0` close, `1` move, `2` line, `3` quadratic and
+/// `4` cubic, each followed by its points as little-endian `f32` pairs. `None` when damaged.
+fn svg_path(b: &[u8]) -> Option {
+ let mut out = String::new();
+ let mut i = 0usize;
+ let mut n = 0usize;
+ while i < b.len() {
+ n += 1;
+ if n > MAX_COMMANDS {
+ return None;
+ }
+ let (letter, points) = match *b.get(i)? {
+ 0 => ('Z', 0),
+ 1 => ('M', 1),
+ 2 => ('L', 1),
+ 3 => ('Q', 2),
+ 4 => ('C', 3),
+ _ => return None,
+ };
+ i += 1;
+ if !out.is_empty() {
+ out.push(' ');
+ }
+ out.push(letter);
+ for _ in 0..points * 2 {
+ let bytes: [u8; 4] = b.get(i..i + 4)?.try_into().ok()?;
+ let x = f32::from_le_bytes(bytes);
+ if !x.is_finite() {
+ return None;
+ }
+ out.push_str(&format!(" {x}"));
+ i += 4;
+ }
+ }
+ Some(out)
+}
diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs
new file mode 100644
index 0000000..a52efc0
--- /dev/null
+++ b/crates/format/src/fig/mod.rs
@@ -0,0 +1,160 @@
+//! Import of `.fig` files (best effort), written from public format knowledge only.
+//!
+//! A `.fig` file is a zip archive holding `canvas.fig` (the document), `meta.json`, a thumbnail
+//! and `images/` files. `canvas.fig` (also accepted on its own) is an 8-byte `fig-β¦` magic,
+//! a little-endian `u32` version and length-prefixed chunks, each compressed with deflate or
+//! zstd: a binary schema in the kiwi format ([`kiwi`]), then a message encoded with it. Every file
+//! carries its own schema, so the message is decoded generically and read by field name: a list
+//! of node changes (each with its id, parent id and position among its siblings) that make up the
+//! layer tree, plus blobs (vector geometry). [`map`] turns that into the REST API file JSON shape
+//! and the REST importer builds the document, so both imports share one mapping. Anything not
+//! understood is skipped with a warning; malformed input never panics.
+
+pub mod kiwi;
+mod map;
+#[cfg(test)]
+mod tests;
+
+use std::io::Read;
+
+use figcraft_doc::Document;
+
+use crate::FormatError;
+use kiwi::Schema;
+
+/// Largest `canvas.fig` member read, and largest decompressed chunk.
+const MAX_CANVAS: u64 = 256 * 1024 * 1024;
+const MAX_CHUNK: u64 = 512 * 1024 * 1024;
+/// Largest image member read.
+const MAX_IMAGE: u64 = 256 * 1024 * 1024;
+/// Most chunks read from `canvas.fig`.
+const MAX_CHUNKS: usize = 16;
+
+/// Whether `bytes` look like a `.fig` file: a zip holding `canvas.fig`, or a bare canvas.
+pub fn is_fig(bytes: &[u8]) -> bool {
+ bytes.starts_with(b"fig-") || (bytes.starts_with(b"PK") && zip_has_canvas(bytes))
+}
+
+fn zip_has_canvas(bytes: &[u8]) -> bool {
+ zip::ZipArchive::new(std::io::Cursor::new(bytes)).is_ok_and(|z| z.file_names().any(|n| n == "canvas.fig"))
+}
+
+/// Import `bytes` when they are a `.fig` file; `None` when they are something else.
+pub fn try_import(bytes: &[u8], name: &str) -> Option), FormatError>> {
+ is_fig(bytes).then(|| import(bytes, name))
+}
+
+/// Build a document from a `.fig` file. Returns it with the warnings about what was skipped or
+/// approximated. `name`: the document name when the file doesn't carry one.
+pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), FormatError> {
+ let archive = Archive::open(bytes)?;
+ let (schema, message) = decode_canvas(&archive.canvas)?;
+ let root = schema.find("Message").ok_or_else(|| corrupt("the schema has no Message definition"))?;
+ let msg = schema.decode_message(root, &message).map_err(|e| corrupt(e.to_string()))?;
+ let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
+ let (json, mut warnings) = map::to_rest(&msg, file_name);
+ let images = |r: &str| archive.image(r);
+ let (doc, more) = crate::rest::import(&json, &images)?;
+ warnings.extend(more.into_iter().filter_map(map::adapt_warning));
+ Ok((doc, warnings))
+}
+
+fn corrupt(msg: impl Into) -> FormatError {
+ FormatError::Corrupt(format!(".fig file: {}", msg.into()))
+}
+
+/// The parts of a `.fig` archive the import reads.
+struct Archive<'b> {
+ canvas: Vec,
+ file_name: Option,
+ /// `images/` members (or nothing for a bare canvas).
+ zip: Option>>,
+}
+
+impl<'b> Archive<'b> {
+ fn open(bytes: &'b [u8]) -> Result {
+ if bytes.starts_with(b"fig-") {
+ return Ok(Archive { canvas: bytes.to_vec(), file_name: None, zip: None });
+ }
+ let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| corrupt(e.to_string()))?;
+ let canvas = crate::read_member(&mut z, "canvas.fig", MAX_CANVAS)?;
+ let file_name = crate::read_member(&mut z, "meta.json", 1 << 20)
+ .ok()
+ .and_then(|m| serde_json::from_slice::(&m).ok())
+ .and_then(|m| m.get("file_name").and_then(serde_json::Value::as_str).map(str::to_string));
+ Ok(Archive { canvas, file_name, zip: Some(z) })
+ }
+
+ /// Bytes of image `hash` (lowercase hex) from `images/`.
+ fn image(&self, hash: &str) -> Option> {
+ if hash.is_empty() || hash.len() > 128 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
+ return None;
+ }
+ // A clone shares the parsed directory and the bytes; the resolver only has shared access.
+ let mut z = self.zip.clone()?;
+ crate::read_member(&mut z, &format!("images/{hash}"), MAX_IMAGE).ok()
+ }
+}
+
+/// The schema and the raw message of a `canvas.fig`.
+fn decode_canvas(canvas: &[u8]) -> Result<(Schema, Vec), FormatError> {
+ let chunks = chunks(canvas)?;
+ let mut it = chunks.into_iter();
+ let (Some(schema), Some(message)) = (it.next(), it.next()) else {
+ return Err(corrupt("canvas.fig needs a schema and a message chunk"));
+ };
+ let schema = Schema::decode(&inflate(schema)?).map_err(|e| corrupt(e.to_string()))?;
+ Ok((schema, inflate(message)?))
+}
+
+/// The length-prefixed chunks after the 12-byte header.
+fn chunks(canvas: &[u8]) -> Result, FormatError> {
+ if canvas.len() < 12 || !canvas.starts_with(b"fig-") {
+ return Err(corrupt("canvas.fig has no header"));
+ }
+ let mut out = Vec::new();
+ let mut pos = 12usize;
+ while pos < canvas.len() && out.len() < MAX_CHUNKS {
+ let len = canvas.get(pos..pos + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes).ok_or_else(|| corrupt("truncated chunk"))?;
+ let start = pos + 4;
+ let end = start.checked_add(len as usize).filter(|e| *e <= canvas.len()).ok_or_else(|| corrupt("truncated chunk"))?;
+ out.push(canvas.get(start..end).unwrap_or_default());
+ pos = end;
+ }
+ Ok(out)
+}
+
+/// Decompress a chunk: zstd (by its magic number), else raw deflate, else zlib.
+fn inflate(data: &[u8]) -> Result, FormatError> {
+ fn capped(r: impl Read) -> Option> {
+ let mut out = Vec::new();
+ r.take(MAX_CHUNK + 1).read_to_end(&mut out).ok()?;
+ (out.len() as u64 <= MAX_CHUNK).then_some(out)
+ }
+ if data.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) {
+ let mut src = data;
+ let dec = ruzstd::decoding::StreamingDecoder::new(&mut src).map_err(|e| corrupt(format!("zstd: {e}")))?;
+ return capped(dec).ok_or_else(|| corrupt("damaged or oversized zstd chunk"));
+ }
+ capped(flate2::read::DeflateDecoder::new(data))
+ .or_else(|| capped(flate2::read::ZlibDecoder::new(data)))
+ .ok_or_else(|| corrupt("damaged or oversized deflate chunk"))
+}
+
+/// Decoded message `msg` as JSON (byte arrays as their length), for looking at files.
+#[cfg(test)]
+pub(crate) fn debug_json(v: &kiwi::Kv) -> serde_json::Value {
+ use kiwi::Kv;
+ use serde_json::{Value, json};
+ match v {
+ Kv::Bool(b) => Value::Bool(*b),
+ Kv::Int(i) => json!(i),
+ Kv::Uint(u) => json!(u),
+ Kv::Float(f) => json!(f),
+ Kv::Str(s) => json!(s),
+ Kv::Bytes(b) => json!(format!("<{} bytes>", b.len())),
+ Kv::Enum(e) => json!(e),
+ Kv::Array(a) => Value::Array(a.iter().map(debug_json).collect()),
+ Kv::Object(f) => Value::Object(f.iter().map(|(k, v)| (k.to_string(), debug_json(v))).collect()),
+ }
+}
diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs
new file mode 100644
index 0000000..6824852
--- /dev/null
+++ b/crates/format/src/fig/tests.rs
@@ -0,0 +1,741 @@
+//! `.fig` import tests on fixtures built in code: a small schema written in a compact text form,
+//! a message encoded with our own kiwi encoder, compressed (deflate and zstd), framed and zipped.
+
+use std::io::Write;
+
+use figcraft_doc::{NodeKind, VariableType};
+use serde_json::Value;
+
+use super::kiwi::{Def, Field, Kind, Kv, Schema, Type, encode};
+use super::*;
+
+/// The fixture schema: `enum Name A B β¦`, `struct|message Name type field, type field[] β¦`.
+/// Message field ids count from 1; enum values from 0.
+const SCHEMA: &str = "
+enum NodeType NONE DOCUMENT CANVAS GROUP FRAME BOOLEAN_OPERATION VECTOR STAR LINE ELLIPSE RECTANGLE REGULAR_POLYGON ROUNDED_RECTANGLE TEXT SLICE SYMBOL INSTANCE STICKY VARIABLE VARIABLE_SET
+enum PaintType SOLID GRADIENT_LINEAR IMAGE
+enum WindingRule NONZERO ODD
+enum StyleType NONE FILL TEXT
+enum StackMode NONE HORIZONTAL VERTICAL
+enum PropType BOOL TEXT
+enum VariableField MISSING VISIBLE TEXT_DATA
+enum DataType BOOLEAN FLOAT STRING ALIAS COLOR PROP_REF
+enum ResolvedType BOOLEAN FLOAT STRING COLOR
+enum InteractionType ON_CLICK ON_HOVER
+enum ConnectionType NONE INTERNAL_NODE URL
+enum NavigationType NAVIGATE OVERLAY
+enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE
+enum EasingType IN_CUBIC OUT_CUBIC
+struct GUID uint sessionID, uint localID
+struct Color float r, float g, float b, float a
+struct Vector float x, float y
+struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
+struct ColorStop Color color, float position
+message Image byte[] hash, string name
+message VariableID GUID guid
+message PropRefValue GUID defId
+message AnyValue bool boolValue, float floatValue, VariableID alias, Color colorValue, PropRefValue propRefValue, TextData textDataValue
+message VariableData AnyValue value, DataType dataType
+message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar
+message ParentIndex GUID guid, string position
+message FontName string family, string style, string postscript
+message TextData string characters, uint[] characterStyleIDs, NodeChange[] styleOverrideTable
+message Path WindingRule windingRule, uint commandsBlob
+message GUIDPath GUID[] guids
+message SymbolData GUID symbolID, NodeChange[] symbolOverrides
+message StyleId GUID guid
+message PropValue bool boolValue, TextData textValue
+message PropDef GUID id, string name, PropValue initialValue, PropType type
+message PropAssignment GUID defID, PropValue value
+message MapEntry VariableData variableData, VariableField variableField
+message VariableDataMap MapEntry[] entries
+message SetMode GUID id, string name, string sortPosition
+message ValuesEntry GUID modeID, VariableData variableData
+message Values ValuesEntry[] entries
+message ModeEntry VariableID variableSetID, GUID variableModeID
+message ModeMap ModeEntry[] entries
+message Event InteractionType interactionType
+message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType
+message Interaction Event event, Action[] actions
+message StartingPoint string name, string position
+message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale
+message Blob byte[] bytes
+message Message NodeChange[] nodeChanges, Blob[] blobs
+";
+
+fn schema() -> Schema {
+ let lines: Vec> = SCHEMA.lines().map(|l| l.split_whitespace().collect()).filter(|l: &Vec<&str>| !l.is_empty()).collect();
+ let index = |name: &str| lines.iter().position(|l| l[1] == name);
+ let builtin = |t: &str| match t {
+ "bool" => Some(Type::Bool),
+ "byte" => Some(Type::Byte),
+ "int" => Some(Type::Int),
+ "uint" => Some(Type::Uint),
+ "float" => Some(Type::Float),
+ "string" => Some(Type::Str),
+ "int64" => Some(Type::Int64),
+ "uint64" => Some(Type::Uint64),
+ _ => None,
+ };
+ let defs = lines
+ .iter()
+ .map(|l| {
+ let kind = match l[0] {
+ "enum" => Kind::Enum,
+ "struct" => Kind::Struct,
+ _ => Kind::Message,
+ };
+ let fields = if kind == Kind::Enum {
+ l[2..].iter().enumerate().map(|(i, n)| Field { name: n.to_string(), ty: None, array: false, value: i as u32 }).collect()
+ } else {
+ l[2..]
+ .join(" ")
+ .split(',')
+ .enumerate()
+ .map(|(i, f)| {
+ let (t, n) = f.trim().split_once(' ').unwrap();
+ let array = t.ends_with("[]");
+ let t = t.trim_end_matches("[]");
+ let ty = builtin(t).unwrap_or_else(|| Type::Def(index(t).unwrap_or_else(|| panic!("type {t}"))));
+ Field { name: n.to_string(), ty: Some(ty), array, value: i as u32 + 1 }
+ })
+ .collect()
+ };
+ Def { name: l[1].to_string(), kind, fields }
+ })
+ .collect();
+ Schema { defs }
+}
+
+fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
+ Kv::Object(fields)
+}
+fn f(x: f64) -> Kv<'static> {
+ Kv::Float(x as f32)
+}
+fn u(x: u64) -> Kv<'static> {
+ Kv::Uint(x)
+}
+fn e(n: &'static str) -> Kv<'static> {
+ Kv::Enum(n)
+}
+fn s(x: &str) -> Kv<'static> {
+ Kv::Str(x.into())
+}
+fn g(l: u64) -> Kv<'static> {
+ o(vec![("sessionID", u(1)), ("localID", u(l))])
+}
+fn arr(v: Vec>) -> Kv<'static> {
+ Kv::Array(v)
+}
+fn xy(x: f64, y: f64) -> Kv<'static> {
+ o(vec![("x", f(x)), ("y", f(y))])
+}
+fn at(x: f64, y: f64) -> Kv<'static> {
+ o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(x)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(y))])
+}
+fn rgb(r: f64, gr: f64, b: f64) -> Kv<'static> {
+ o(vec![("r", f(r)), ("g", f(gr)), ("b", f(b)), ("a", f(1.0))])
+}
+fn solid(r: f64, gr: f64, b: f64) -> Kv<'static> {
+ o(vec![("type", e("SOLID")), ("color", rgb(r, gr, b)), ("opacity", f(1.0)), ("visible", Kv::Bool(true))])
+}
+fn root_guid() -> Kv<'static> {
+ o(vec![("sessionID", u(0)), ("localID", u(0))])
+}
+fn alias_data(l: u64) -> Kv<'static> {
+ o(vec![("value", o(vec![("alias", o(vec![("guid", g(l))]))])), ("dataType", e("ALIAS"))])
+}
+fn prop_ref(def: u64, field: &'static str) -> Kv<'static> {
+ let data = o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(def))]))])), ("dataType", e("PROP_REF"))]);
+ o(vec![("entries", arr(vec![o(vec![("variableData", data), ("variableField", e(field))])]))])
+}
+
+/// A node: id (0: the document), parent (id, position), type, name and further fields.
+fn node(id: u64, par: Option<(u64, &str)>, ty: &'static str, name: &str, mut more: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
+ let mut v = vec![("guid", if id == 0 { root_guid() } else { g(id) })];
+ if let Some((p, pos)) = par {
+ v.push(("parentIndex", o(vec![("guid", if p == 0 { root_guid() } else { g(p) }), ("position", s(pos))])));
+ }
+ v.push(("type", e(ty)));
+ v.push(("name", s(name)));
+ v.append(&mut more);
+ o(v)
+}
+
+/// A path blob: a closed triangle.
+fn triangle_blob() -> Vec {
+ let mut b = Vec::new();
+ for (cmd, pts) in [(1u8, vec![0.0f32, 0.0]), (2, vec![10.0, 0.0]), (2, vec![5.0, 8.0]), (0, vec![])] {
+ b.push(cmd);
+ for p in pts {
+ b.extend_from_slice(&p.to_le_bytes());
+ }
+ }
+ b
+}
+
+/// A 2Γ1 PNG.
+fn png() -> Vec {
+ let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255]));
+ let mut out = Vec::new();
+ img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
+ out
+}
+
+const HASH: [u8; 20] = [0xab; 20];
+
+/// The fixture message: a page with a frame holding a rectangle (image fill, click interaction),
+/// a two-style text, a group with a triangle vector and a star, a layer with a linked fill style,
+/// one with a variable-bound fill, and an instance of a component with a boolean property
+/// (property off, child renamed); a flow and an explicit variable mode; an unsupported sticky
+/// note; styles and variables on the internal page.
+fn message() -> Kv<'static> {
+ let modes = arr(vec![
+ o(vec![("id", g(90)), ("name", s("Light")), ("sortPosition", s("!"))]),
+ o(vec![("id", g(91)), ("name", s("Dark")), ("sortPosition", s("\""))]),
+ ]);
+ let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]);
+ let values = o(vec![(
+ "entries",
+ arr(vec![
+ o(vec![("modeID", g(90)), ("variableData", color_value(rgb(1.0, 1.0, 1.0)))]),
+ o(vec![("modeID", g(91)), ("variableData", color_value(rgb(0.0, 0.0, 0.0)))]),
+ ]),
+ )]);
+ let click = o(vec![
+ ("event", o(vec![("interactionType", e("ON_CLICK"))])),
+ (
+ "actions",
+ arr(vec![o(vec![
+ ("transitionNodeID", g(20)),
+ ("transitionType", e("SMART_ANIMATE")),
+ ("transitionDuration", f(0.25)),
+ ("easingType", e("OUT_CUBIC")),
+ ("connectionType", e("INTERNAL_NODE")),
+ ("navigationType", e("NAVIGATE")),
+ ])]),
+ ),
+ ]);
+ let font = |style: &str| o(vec![("family", s("Inter")), ("style", s(style)), ("postscript", s(""))]);
+ let text = o(vec![
+ ("characters", s("Hi there")),
+ ("characterStyleIDs", arr(vec![u(0), u(0), u(0), u(5), u(5), u(5)])),
+ ("styleOverrideTable", arr(vec![o(vec![("fontName", font("Bold")), ("styleID", u(5))])])),
+ ]);
+ let nodes = vec![
+ node(0, None, "DOCUMENT", "Document", vec![]),
+ node(1, Some((0, "!")), "CANVAS", "Page A", vec![]),
+ node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
+ node(3, Some((2, "!")), "RECTANGLE", "Brand", vec![("styleType", e("FILL")), ("fillPaints", arr(vec![solid(0.0, 0.0, 1.0)]))]),
+ node(4, Some((2, "\"")), "VARIABLE_SET", "Theme", vec![("variableSetModes", modes)]),
+ node(
+ 5,
+ Some((2, "#")),
+ "VARIABLE",
+ "surface",
+ vec![("variableSetID", o(vec![("guid", g(4))])), ("variableResolvedType", e("COLOR")), ("variableDataValues", values)],
+ ),
+ // Listed before the frame, but its position puts it second.
+ node(
+ 20,
+ Some((1, "\"")),
+ "SYMBOL",
+ "Button",
+ vec![
+ ("size", xy(100.0, 40.0)),
+ ("transform", at(500.0, 0.0)),
+ ("fillPaints", arr(vec![solid(0.0, 1.0, 0.0)])),
+ (
+ "componentPropDefs",
+ arr(vec![o(vec![
+ ("id", g(80)),
+ ("name", s("Show icon")),
+ ("initialValue", o(vec![("boolValue", Kv::Bool(true))])),
+ ("type", e("BOOL")),
+ ])]),
+ ),
+ ("stackMode", e("HORIZONTAL")),
+ ("stackSpacing", f(8.0)),
+ ("stackHorizontalPadding", f(12.0)),
+ ("stackVerticalPadding", f(4.0)),
+ ],
+ ),
+ node(
+ 21,
+ Some((20, "!")),
+ "ELLIPSE",
+ "Icon",
+ vec![("size", xy(16.0, 16.0)), ("transform", at(12.0, 12.0)), ("parameterConsumptionMap", prop_ref(80, "VISIBLE"))],
+ ),
+ node(
+ 10,
+ Some((1, "!")),
+ "FRAME",
+ "Screen",
+ vec![
+ ("size", xy(400.0, 300.0)),
+ ("fillPaints", arr(vec![solid(1.0, 1.0, 1.0)])),
+ ("prototypeStartingPoint", o(vec![("name", s("Main flow")), ("position", s("!"))])),
+ (
+ "variableModeBySetMap",
+ o(vec![("entries", arr(vec![o(vec![("variableSetID", o(vec![("guid", g(4))])), ("variableModeID", g(91))])]))]),
+ ),
+ ],
+ ),
+ node(
+ 11,
+ Some((10, "!")),
+ "ROUNDED_RECTANGLE",
+ "Photo",
+ vec![
+ ("size", xy(50.0, 40.0)),
+ ("transform", at(10.0, 20.0)),
+ ("cornerRadius", f(6.0)),
+ ("fillPaints", arr(vec![o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))]))])])),
+ ("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])),
+ ("strokeWeight", f(2.0)),
+ ("prototypeInteractions", arr(vec![click])),
+ ],
+ ),
+ node(
+ 12,
+ Some((10, "\"")),
+ "TEXT",
+ "Title",
+ vec![
+ ("size", xy(120.0, 20.0)),
+ ("transform", at(10.0, 80.0)),
+ ("fillPaints", arr(vec![solid(0.0, 0.0, 0.0)])),
+ ("fontSize", f(16.0)),
+ ("fontName", font("Regular")),
+ ("textData", text),
+ ],
+ ),
+ node(13, Some((10, "#")), "FRAME", "Group", vec![("size", xy(30.0, 30.0)), ("transform", at(100.0, 100.0)), ("resizeToFit", Kv::Bool(true))]),
+ node(
+ 14,
+ Some((13, "!")),
+ "VECTOR",
+ "Tri",
+ vec![
+ ("size", xy(10.0, 8.0)),
+ ("transform", at(5.0, 6.0)),
+ ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])),
+ ("fillGeometry", arr(vec![o(vec![("windingRule", e("NONZERO")), ("commandsBlob", u(0))])])),
+ ],
+ ),
+ node(
+ 15,
+ Some((13, "\"")),
+ "STAR",
+ "Star",
+ vec![("size", xy(20.0, 20.0)), ("transform", at(10.0, 10.0)), ("count", u(6)), ("starInnerScale", f(0.5))],
+ ),
+ node(
+ 16,
+ Some((10, "$")),
+ "ROUNDED_RECTANGLE",
+ "Styled",
+ vec![
+ ("size", xy(10.0, 10.0)),
+ ("transform", at(0.0, 200.0)),
+ // A stale copy of the style's paint: the style wins.
+ ("fillPaints", arr(vec![solid(0.5, 0.5, 0.5)])),
+ ("styleIdForFill", o(vec![("guid", g(3))])),
+ ],
+ ),
+ node(
+ 17,
+ Some((10, "%")),
+ "ROUNDED_RECTANGLE",
+ "Card",
+ vec![
+ ("size", xy(10.0, 10.0)),
+ ("transform", at(20.0, 200.0)),
+ ("fillPaints", arr(vec![o(vec![("type", e("SOLID")), ("color", rgb(0.0, 0.0, 0.0)), ("colorVar", alias_data(5))])])),
+ ],
+ ),
+ node(
+ 18,
+ Some((10, "&")),
+ "INSTANCE",
+ "Button copy",
+ vec![
+ ("size", xy(100.0, 40.0)),
+ ("transform", at(200.0, 200.0)),
+ ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])),
+ ("stackMode", e("HORIZONTAL")),
+ (
+ "symbolData",
+ o(vec![
+ ("symbolID", g(20)),
+ ("symbolOverrides", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(21)]))])), ("name", s("Renamed icon"))])])),
+ ]),
+ ),
+ ("componentPropAssignments", arr(vec![o(vec![("defID", g(80)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])),
+ ],
+ ),
+ node(19, Some((10, "'")), "STICKY", "Note", vec![]),
+ ];
+ o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![o(vec![("bytes", Kv::Bytes(triangle_blob()))])]))])
+}
+
+/// `canvas.fig`: magic, version, deflated schema, zstd message.
+fn canvas(schema: &Schema, msg: &Kv) -> Vec {
+ let root = schema.find("Message").unwrap();
+ let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
+ z.write_all(&encode::schema(schema)).unwrap();
+ let schema_chunk = z.finish().unwrap();
+ let msg_chunk = ruzstd::encoding::compress_to_vec(&encode::message(schema, root, msg)[..], ruzstd::encoding::CompressionLevel::Fastest);
+ let mut out = b"fig-kiwi".to_vec();
+ out.extend_from_slice(&101u32.to_le_bytes());
+ for c in [schema_chunk, msg_chunk] {
+ out.extend_from_slice(&(c.len() as u32).to_le_bytes());
+ out.extend_from_slice(&c);
+ }
+ out
+}
+
+fn zip_file(canvas: &[u8]) -> Vec {
+ let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
+ let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
+ let image_name = format!("images/{}", HASH.iter().map(|b| format!("{b:02x}")).collect::());
+ for (name, bytes) in [("canvas.fig", canvas.to_vec()), ("meta.json", br#"{"file_name":"Fixture"}"#.to_vec()), (image_name.as_str(), png())] {
+ z.start_file(name, stored).unwrap();
+ z.write_all(&bytes).unwrap();
+ }
+ z.finish().unwrap().into_inner()
+}
+
+fn fixture() -> Vec {
+ zip_file(&canvas(&schema(), &message()))
+}
+
+fn named<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::Node {
+ doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}"))
+}
+
+#[test]
+fn kiwi_round_trip() {
+ let sc = schema();
+ let back = Schema::decode(&encode::schema(&sc)).unwrap();
+ assert_eq!(back.defs.len(), sc.defs.len());
+ for (a, b) in sc.defs.iter().zip(&back.defs) {
+ assert_eq!((a.name.as_str(), a.kind), (b.name.as_str(), b.kind));
+ let fields = |d: &Def| d.fields.iter().map(|f| (f.name.clone(), f.ty, f.array, f.value)).collect::>();
+ assert_eq!(fields(a), fields(b));
+ }
+ let msg = message();
+ let root = sc.find("Message").unwrap();
+ assert_eq!(back.decode_message(root, &encode::message(&sc, root, &msg)).unwrap(), msg);
+}
+
+#[test]
+fn kiwi_scalars() {
+ let names = ["f", "i", "u", "i64", "u64", "s", "b"];
+ let types = [Type::Float, Type::Int, Type::Uint, Type::Int64, Type::Uint64, Type::Str, Type::Bool];
+ let fields = names
+ .iter()
+ .zip(types)
+ .enumerate()
+ .map(|(i, (n, t))| Field { name: n.to_string(), ty: Some(t), array: false, value: i as u32 + 1 })
+ .collect();
+ let sc = Schema { defs: vec![Def { name: "M".into(), kind: Kind::Message, fields }] };
+ let cases = [
+ (0.0f32, 0i32, 0u32, 0i64, 0u64),
+ (-1.5, -7, 300, i64::MIN, u64::MAX),
+ (3.25e7, i32::MAX, u32::MAX, i64::MAX, 1 << 60),
+ (1e-40, i32::MIN, 1, -1, 127),
+ ];
+ for (fv, iv, uv, i64v, u64v) in cases {
+ // Written by hand: the generic encoder goes through f64, which can't hold every 64-bit value.
+ let mut w = encode::Writer::default();
+ w.var_uint(1);
+ w.var_float(fv);
+ w.var_uint(2);
+ w.var_int(iv);
+ w.var_uint(3);
+ w.var_uint(uv);
+ w.var_uint(4);
+ w.var_int64(i64v);
+ w.var_uint(5);
+ w.var_uint64(u64v);
+ w.var_uint(6);
+ w.string("hΓ©llo");
+ w.var_uint(7);
+ w.byte(1);
+ w.var_uint(0);
+ let got = sc.decode_message(0, &w.0).unwrap();
+ // Denormals are stored as zero.
+ let want_f = if fv.abs() < f32::MIN_POSITIVE { 0.0 } else { fv };
+ assert_eq!(got.get("f"), Some(&Kv::Float(want_f)));
+ assert_eq!(got.get("i"), Some(&Kv::Int(i64::from(iv))));
+ assert_eq!(got.get("u"), Some(&Kv::Uint(u64::from(uv))));
+ assert_eq!(got.get("i64"), Some(&Kv::Int(i64v)));
+ assert_eq!(got.get("u64"), Some(&Kv::Uint(u64v)));
+ assert_eq!(got.get("s"), Some(&s("hΓ©llo")));
+ assert_eq!(got.get("b"), Some(&Kv::Bool(true)));
+ }
+}
+
+#[test]
+fn imports_the_fixture() {
+ let bytes = fixture();
+ assert!(is_fig(&bytes));
+ let (doc, warnings) = import(&bytes, "fallback").unwrap();
+ assert_eq!(doc.name, "Fixture");
+ // The internal page is skipped.
+ assert_eq!(doc.pages.len(), 1);
+ let page = doc.node(doc.pages[0]).unwrap();
+ assert_eq!(page.name, "Page A");
+ // Siblings by position: the frame comes first even though it's listed after the component.
+ assert_eq!(doc.node(page.children[0]).unwrap().name, "Screen");
+ let screen = named(&doc, "Screen");
+ let names: Vec<&str> = screen.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();
+ assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy"]);
+ // Image fill from the archive.
+ let photo = named(&doc, "Photo");
+ let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") };
+ assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1)));
+ assert_eq!((photo.corners.radii[0], photo.stroke.weight), (6.0, 2.0));
+ // Text with a bold run over "the".
+ let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") };
+ assert_eq!(t.characters, "Hi there");
+ assert_eq!(t.style.font_size, 16.0);
+ assert_eq!(t.runs.len(), 1);
+ assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold"));
+ // The group's children keep their place on the page (group at 100, 100).
+ assert!(matches!(named(&doc, "Group").kind, NodeKind::Group));
+ let tri = named(&doc, "Tri");
+ assert!(matches!(&tri.kind, NodeKind::Vector { network } if !network.regions.is_empty()));
+ let abs = doc.world_transform(tri.id).translation();
+ assert!((abs.x - 105.0).abs() < 1e-6 && (abs.y - 106.0).abs() < 1e-6, "{abs:?}");
+ assert!(matches!(named(&doc, "Star").kind, NodeKind::Star { count: 6, .. }));
+ // The linked style supplies the paint.
+ let styled = named(&doc, "Styled");
+ assert_eq!(styled.styles.fill.as_deref(), Some("1:3"));
+ assert!(doc.styles.iter().any(|s| s.name == "Brand"));
+ let figcraft_doc::color::PaintKind::Solid { color } = styled.fills[0].kind else { panic!("not solid") };
+ assert_eq!((color.r, color.g, color.b), (0.0, 0.0, 1.0));
+ // Variables, the fill binding and the explicit mode.
+ assert_eq!(doc.variables.collections.len(), 1);
+ assert_eq!(doc.variables.variables[0].kind, VariableType::Color);
+ assert_eq!(named(&doc, "Card").bound.get("fill").map(String::as_str), Some("1:5"));
+ assert_eq!(screen.var_modes.get("1:4").map(String::as_str), Some("1:91"));
+ // The component keeps its property; the instance is expanded with the value applied.
+ let button = named(&doc, "Button");
+ assert!(matches!(&button.kind, NodeKind::Component { props, .. } if props.contains_key("Show icon")));
+ let inst = named(&doc, "Button copy");
+ assert!(
+ matches!(&inst.kind, NodeKind::Instance { component, props } if *component == button.id && props.get("Show icon") == Some(&Value::Bool(false)))
+ );
+ let icon = doc.node(inst.children[0]).unwrap();
+ assert_eq!(icon.name, "Renamed icon");
+ assert!(!icon.visible);
+ // Prototype: the click navigates to the component; the flow starts at the screen.
+ let it = &photo.interactions[0];
+ assert!(matches!(it.actions[0], figcraft_doc::proto::Action::Navigate { dest, .. } if dest == button.id));
+ assert_eq!(it.duration_ms, 250);
+ let Some(NodeKind::Page { flows, .. }) = doc.node(doc.pages[0]).map(|p| &p.kind) else { panic!("no page") };
+ assert_eq!((flows[0].name.as_str(), flows[0].start), ("Main flow", screen.id));
+ // The sticky note isn't supported: a warning.
+ assert!(warnings.iter().any(|w| w.contains("STICKY")), "{warnings:?}");
+}
+
+#[test]
+fn bare_canvas_and_zlib_chunks_open() {
+ let sc = schema();
+ let c = canvas(&sc, &message());
+ let (doc, w) = import(&c, "Bare").unwrap();
+ assert_eq!(doc.name, "Bare");
+ // Without the archive the image is a placeholder, reported.
+ assert!(w.iter().any(|w| w.contains("isn't in the file")), "{w:?}");
+ // A zlib-wrapped schema chunk reads too.
+ let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
+ z.write_all(&encode::schema(&sc)).unwrap();
+ let zl = z.finish().unwrap();
+ let parts = chunks(&c).unwrap();
+ let mut out = c[..12].to_vec();
+ for ch in [zl.as_slice(), parts[1]] {
+ out.extend_from_slice(&(ch.len() as u32).to_le_bytes());
+ out.extend_from_slice(ch);
+ }
+ assert!(import(&out, "Z").is_ok());
+}
+
+#[test]
+fn other_files_are_not_fig() {
+ assert!(try_import(b"{\"document\": {}}", "x").is_none());
+ assert!(try_import(b"", "x").is_none());
+ let native = crate::save_bytes(&figcraft_doc::Document::new("N"), None).unwrap();
+ assert!(try_import(&native, "x").is_none());
+}
+
+/// A cheap deterministic generator for the mangle tests.
+struct Lcg(u64);
+impl Lcg {
+ fn next(&mut self) -> u64 {
+ self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
+ self.0 >> 33
+ }
+}
+
+#[test]
+fn mangled_files_never_panic() {
+ let bytes = fixture();
+ let c = canvas(&schema(), &message());
+ let mut r = Lcg(7);
+ for src in [&bytes, &c] {
+ for cut in (0..src.len()).step_by(37) {
+ let _ = import(&src[..cut], "x");
+ }
+ for _ in 0..300 {
+ let mut m = src.clone();
+ for _ in 0..1 + r.next() % 8 {
+ let i = (r.next() as usize) % m.len();
+ m[i] ^= 1 << (r.next() % 8);
+ }
+ let _ = import(&m, "x");
+ }
+ }
+}
+
+#[test]
+fn mangled_kiwi_never_panics() {
+ let sc = schema();
+ let root = sc.find("Message").unwrap();
+ let schema_bytes = encode::schema(&sc);
+ let msg = encode::message(&sc, root, &message());
+ let mut r = Lcg(11);
+ for cut in 0..schema_bytes.len() {
+ let _ = Schema::decode(&schema_bytes[..cut]);
+ }
+ for cut in (0..msg.len()).step_by(3) {
+ if let Ok(kv) = sc.decode_message(root, &msg[..cut]) {
+ let _ = map::to_rest(&kv, "x");
+ }
+ }
+ for _ in 0..2000 {
+ let mut m = msg.clone();
+ for _ in 0..1 + r.next() % 4 {
+ let i = (r.next() as usize) % m.len();
+ m[i] = r.next() as u8;
+ }
+ if let Ok(kv) = sc.decode_message(root, &m) {
+ let (json, _) = map::to_rest(&kv, "x");
+ let _ = crate::rest::import(&json, &crate::rest::no_images);
+ }
+ let mut sbytes = schema_bytes.clone();
+ let i = (r.next() as usize) % sbytes.len();
+ sbytes[i] = r.next() as u8;
+ if let Ok(s2) = Schema::decode(&sbytes)
+ && let Some(rt) = s2.find("Message")
+ {
+ let _ = s2.decode_message(rt, &msg);
+ }
+ }
+ // Pure junk, bare and framed.
+ for _ in 0..500 {
+ let junk: Vec = (0..(r.next() % 200)).map(|_| r.next() as u8).collect();
+ let _ = Schema::decode(&junk);
+ let _ = sc.decode_message(root, &junk);
+ let mut framed = b"fig-kiwi\0\0\0\0".to_vec();
+ framed.extend_from_slice(&junk);
+ let _ = import(&framed, "x");
+ }
+}
+
+#[test]
+fn hostile_input_is_refused() {
+ // A message that contains itself, nested past the depth cap.
+ let field = |ty, array| vec![Field { name: "m".into(), ty: Some(ty), array, value: 1 }];
+ let nested = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields: field(Type::Def(0), false) }] };
+ assert!(nested.decode_message(0, &[1u8; 10_000]).is_err());
+ // An array count larger than the data.
+ let array = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields: field(Type::Uint, true) }] };
+ assert!(array.decode_message(0, &[1, 0xff, 0xff, 0xff, 0xff, 0x0f]).is_err());
+ // A field type naming a missing definition.
+ let mut w = encode::Writer::default();
+ w.var_uint(1);
+ w.string("Message");
+ w.byte(2);
+ w.var_uint(1);
+ w.string("x");
+ w.var_int(5);
+ w.byte(0);
+ w.var_uint(1);
+ assert!(Schema::decode(&w.0).is_err());
+ // A schema without a Message definition.
+ let c = canvas(&Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields: vec![] }] }, &Kv::Object(vec![]));
+ let renamed: Vec = {
+ let sc = Schema { defs: vec![Def { name: "Other".into(), kind: Kind::Message, fields: vec![] }] };
+ let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
+ z.write_all(&encode::schema(&sc)).unwrap();
+ let sch = z.finish().unwrap();
+ let mut out = c[..12].to_vec();
+ out.extend_from_slice(&(sch.len() as u32).to_le_bytes());
+ out.extend_from_slice(&sch);
+ out.extend_from_slice(&c[12 + 4 + chunks(&c).unwrap()[0].len()..]);
+ out
+ };
+ assert!(import(&c, "x").is_ok());
+ assert!(import(&renamed, "x").is_err());
+}
+
+#[test]
+fn instances_inside_their_own_main_stop() {
+ let sym = node(5, Some((1, "!")), "SYMBOL", "Loop", vec![("size", xy(10.0, 10.0))]);
+ let inner = node(6, Some((5, "!")), "INSTANCE", "Self", vec![("symbolData", o(vec![("symbolID", g(5))]))]);
+ let doc_nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![]), sym, inner];
+ let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(doc_nodes))])), "x").unwrap();
+ assert!(doc.nodes().any(|n| n.name == "Self"));
+}
+
+/// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset).
+fn samples() -> Vec {
+ let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() };
+ let dir = std::fs::read_dir(dir).into_iter().flatten().flatten();
+ dir.map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "fig")).collect()
+}
+
+/// Local only (`FIGCRAFT_FIG_SAMPLES`): every file imports without warnings.
+#[test]
+fn local_samples_import() {
+ for p in samples() {
+ let bytes = std::fs::read(&p).unwrap();
+ let (doc, warnings) = import(&bytes, "sample").unwrap_or_else(|e| panic!("{}: {e}", p.display()));
+ assert!(warnings.is_empty(), "{}: {warnings:?}", p.display());
+ assert!(doc.node_count() > 5, "{}", p.display());
+ }
+}
+
+/// Local only: dumps each sample's schema, decoded message and REST JSON when `FIG_DUMP` names a
+/// folder (for working on the importer).
+#[test]
+fn dump_samples() {
+ let Ok(out) = std::env::var("FIG_DUMP") else { return };
+ for p in samples() {
+ let bytes = std::fs::read(&p).unwrap();
+ let a = Archive::open(&bytes).unwrap();
+ let (schema, message) = decode_canvas(&a.canvas).unwrap();
+ let stem = p.file_stem().unwrap().to_string_lossy().to_string();
+ let mut txt = String::new();
+ for d in &schema.defs {
+ txt += &format!("{:?} {}\n", d.kind, d.name);
+ for f in &d.fields {
+ let ty = match f.ty {
+ Some(Type::Def(i)) => schema.defs[i].name.clone(),
+ Some(t) => format!("{t:?}"),
+ None => String::new(),
+ };
+ txt += &format!(" {} {}{} = {}\n", f.name, ty, if f.array { "[]" } else { "" }, f.value);
+ }
+ }
+ std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap();
+ let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
+ std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap();
+ let (rest, _) = map::to_rest(&msg, &stem);
+ std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap();
+ }
+}
diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs
index 23df53c..48d8bca 100644
--- a/crates/format/src/lib.rs
+++ b/crates/format/src/lib.rs
@@ -13,9 +13,10 @@
//! JSON (the `document.json` alone) also opens. Reading is lenient: structural problems are
//! repaired (see [`Document::repair`]) and reported, damaged versions and libraries are skipped.
//!
-//! [`rest`] imports the public REST API file JSON.
+//! [`rest`] imports the public REST API file JSON; [`fig`] imports `.fig` files (best effort).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+pub mod fig;
pub mod library;
pub mod rest;
@@ -421,7 +422,7 @@ pub fn load_bytes(bytes: &[u8]) -> Result<(Document, Vec), FormatError>
}
/// Read archive member `name`, refusing members larger than `limit` bytes.
-fn read_member(z: &mut zip::ZipArchive, name: &str, limit: u64) -> Result, FormatError> {
+pub(crate) fn read_member(z: &mut zip::ZipArchive, name: &str, limit: u64) -> Result, FormatError> {
let f = z.by_name(name).map_err(|e| FormatError::Corrupt(format!("{name}: {e}")))?;
let too_large = || FormatError::Corrupt(format!("{name} is too large"));
if f.size() > limit {
diff --git a/crates/mcp/src/gradient_tests.rs b/crates/mcp/src/gradient_tests.rs
new file mode 100644
index 0000000..0c4a584
--- /dev/null
+++ b/crates/mcp/src/gradient_tests.rs
@@ -0,0 +1,21 @@
+//! Gradient paints from agents get a linear width handle square to their axis (a missing one was
+//! put on the side's middle: the bands skewed, and vanished for a left-to-right gradient).
+
+use serde_json::{Map, Value, json};
+
+use crate::tools::normalize_props;
+
+fn width_of(paint: Value) -> Value {
+ let mut props: Map = json!({"fills": [paint]}).as_object().cloned().unwrap();
+ normalize_props(&mut props);
+ props["fills"][0]["width"].clone()
+}
+
+#[test]
+fn a_linear_gradient_without_a_width_handle_gets_a_square_one() {
+ assert_eq!(width_of(json!({"type": "linearGradient", "stops": ["#000000", "#FFFFFF"]})), json!([-0.5, 0.0]));
+ assert_eq!(width_of(json!({"type": "gradient", "from": [0.0, 0.5], "to": [1.0, 0.5], "stops": ["#000000", "#FFFFFF"]})), json!([0.0, 1.5]));
+ // Given handles and other kinds are left alone.
+ assert_eq!(width_of(json!({"type": "gradient", "from": [0.0, 0.5], "to": [1.0, 0.5], "width": [0.0, 1.0]})), json!([0.0, 1.0]));
+ assert_eq!(width_of(json!({"type": "radialGradient"})), json!([1.0, 0.5]));
+}
diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs
index 73575f6..2f3e265 100644
--- a/crates/mcp/src/headless.rs
+++ b/crates/mcp/src/headless.rs
@@ -138,7 +138,8 @@ impl Headless {
return Ok(json!({"command": bd.id}));
}
if !(m.cmd && key.len() == 1) {
- textedit::key(&mut self.session, key, m.shift, m.alt, m.cmd).map_err(|e| e.to_string())?;
+ let (word, line) = figcraft_engine::keymap::text_motion(platform, key, m);
+ textedit::key(&mut self.session, key, m.shift, word, line).map_err(|e| e.to_string())?;
return Ok(json!({"ok": true, "textEdit": true}));
}
}
diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs
index 63a7548..8b850a1 100644
--- a/crates/mcp/src/lib.rs
+++ b/crates/mcp/src/lib.rs
@@ -20,6 +20,9 @@ mod resources;
mod server;
mod tools;
+#[cfg(test)]
+mod gradient_tests;
+
pub use backend::{Attached, Backend, decode_data, page_children};
pub use headless::Headless;
pub use prompts::{PROMPTS, PromptArg, PromptDef};
diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs
index a0294f1..7a822c1 100644
--- a/crates/mcp/src/tools.rs
+++ b/crates/mcp/src/tools.rs
@@ -231,7 +231,7 @@ pub fn tool_definitions() -> Vec {
tool(
"open_file",
"Open file",
- "Open a .figcraft or .json document (FigCraft or REST API file JSON), an .svg (becomes vectors) or an image (becomes an image layer) as the active document. Returns the new document's summary.",
+ "Open a .figcraft or .json document (FigCraft or REST API file JSON), a .fig file (best effort, with `warnings` for what wasn't imported), an .svg (becomes vectors) or an image (becomes an image layer) as the active document. Returns the new document's summary.",
obj(
json!({
"path": string("File path"),
@@ -402,9 +402,18 @@ fn normalize_paintish(v: &mut Value, depth: usize) {
}
o.entry("kind").or_insert(json!("linear"));
let linear = o.get("kind").and_then(Value::as_str) == Some("linear");
- let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [0.0, 0.5]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) };
+ let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) };
o.entry("from").or_insert(json!(from));
o.entry("to").or_insert(json!(to));
+ // A linear gradient's width handle goes square to its axis (else its bands skew,
+ // or vanish when the handle lies on the axis).
+ let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
+ if linear
+ && !o.contains_key("width")
+ && let (Some(f), Some(t)) = (pt("from"), pt("to"))
+ {
+ o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)]));
+ }
o.entry("width").or_insert(json!(width));
if let Some(Value::Array(stops)) = o.get_mut("stops") {
let n = stops.len();
diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml
index 9c21838..6515fda 100644
--- a/crates/ui-egui/locales/en.toml
+++ b/crates/ui-egui/locales/en.toml
@@ -205,6 +205,7 @@ name = "English"
"Close tab" = "Close tab"
"Close version history" = "Close version history"
"Close when clicking outside" = "Close when clicking outside"
+"Close without saving" = "Close without saving"
"Code" = "Code"
"Collapse" = "Collapse"
"Collapse panel" = "Collapse panel"
@@ -755,6 +756,7 @@ name = "English"
"Open file" = "Open file"
"Open files" = "Open files"
"Open in focus view" = "Open in focus view"
+"Open in new window" = "Open in new window"
"Open invite links in FigCraft" = "Open invite links in FigCraft"
"Open link" = "Open link"
"Open link {url}" = "Open link {url}"
@@ -858,6 +860,7 @@ name = "English"
"Recent" = "Recent"
"Recent files" = "Recent files"
"Recents" = "Recents"
+"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Recovered an unsaved file after the app quit unexpectedly: save it to keep it", other = "Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" }
"Rectangle" = "Rectangle"
"Rectangles" = "Rectangles"
"Red" = "Red"
@@ -1141,6 +1144,8 @@ name = "English"
"Unions" = "Unions"
"Unread" = "Unread"
"Unresolve" = "Unresolve"
+"Unsaved changes" = "Unsaved changes"
+"Unsaved changes in {names}. Close the window and lose them?" = "Unsaved changes in {names}. Close the window and lose them?"
"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard"
"Unsaved changes: save first, or close again with {key} to discard" = "Unsaved changes: save first, or close again with {key} to discard"
"Up" = "Up"
diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml
index b3cbcc3..555632d 100644
--- a/crates/ui-egui/locales/es.toml
+++ b/crates/ui-egui/locales/es.toml
@@ -206,6 +206,7 @@ status = "draft β needs native review"
"Close tab" = "Cerrar pestaΓ±a"
"Close version history" = "Cerrar historial de versiones"
"Close when clicking outside" = "Cerrar al hacer clic fuera"
+"Close without saving" = "Cerrar sin guardar"
"Code" = "CΓ³digo"
"Collapse" = "Contraer"
"Collapse panel" = "Contraer panel"
@@ -756,6 +757,7 @@ status = "draft β needs native review"
"Open file" = "Abrir archivo"
"Open files" = "Archivos abiertos"
"Open in focus view" = "Abrir en vista de enfoque"
+"Open in new window" = "Abrir en una ventana nueva"
"Open invite links in FigCraft" = "Abrir enlaces de invitaciΓ³n en FigCraft"
"Open link" = "Abrir enlace"
"Open link {url}" = "Abrir enlace {url}"
@@ -859,6 +861,7 @@ status = "draft β needs native review"
"Recent" = "Recientes"
"Recent files" = "Archivos recientes"
"Recents" = "Recientes"
+"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Se recuperΓ³ un archivo sin guardar tras un cierre inesperado de la app: guΓ‘rdalo para conservarlo", other = "Se recuperaron {n} archivos sin guardar tras un cierre inesperado de la app: guΓ‘rdalos para conservarlos" }
"Rectangle" = "RectΓ‘ngulo"
"Rectangles" = "RectΓ‘ngulos"
"Red" = "Rojo"
@@ -1142,6 +1145,8 @@ status = "draft β needs native review"
"Unions" = "Uniones"
"Unread" = "No leΓdos"
"Unresolve" = "Reabrir"
+"Unsaved changes" = "Cambios sin guardar"
+"Unsaved changes in {names}. Close the window and lose them?" = "Hay cambios sin guardar en {names}. ΒΏCerrar la ventana y perderlos?"
"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Cambios sin guardar: guarda primero ({save}) o cierra la pestaΓ±a con {alt}+clic para descartarlos"
"Unsaved changes: save first, or close again with {key} to discard" = "Cambios sin guardar: guarda primero o vuelve a cerrar con {key} para descartarlos"
"Up" = "Arriba"
@@ -1442,6 +1447,7 @@ status = "draft β needs native review"
"file.open" = "Abrir"
"file.openRecent" = "Abrir reciente"
"file.placeImage" = "Colocar imagen"
+"file.recover" = "Recuperar archivo"
"file.rename" = "Cambiar nombre del archivo"
"file.revert" = "Volver a lo guardado"
"file.save" = "Guardar"
diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs
index 0052a59..23f3774 100644
--- a/crates/ui-egui/src/component_panel.rs
+++ b/crates/ui-egui/src/component_panel.rs
@@ -230,7 +230,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
}
});
if defs.is_empty() && variants.is_empty() {
- row(ui, 28.0, |ui| widgets::label(ui, tr("Add properties to make instances configurable"), t.text_tertiary));
+ widgets::note(ui, tr("Add properties to make instances configurable"), t.text_tertiary);
}
// Variant properties: name, values (the matrix), add value, delete.
for (k, vals) in &variants {
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index 24acb19..f9ff4ec 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -191,7 +191,8 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
crate::menus::invoke(app, bd.id);
return Ok(json!({"command": bd.id}));
}
- figcraft_engine::textedit::key(&mut app.session, key, m.shift, m.alt, m.cmd).map_err(|e| e.to_string())?;
+ let (word, line) = figcraft_engine::keymap::text_motion(platform, key, m);
+ figcraft_engine::textedit::key(&mut app.session, key, m.shift, word, line).map_err(|e| e.to_string())?;
return Ok(json!({"ok": true}));
}
// Vector edit mode and shape builder keys (tools, nudging points, delete, join, Enter).
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 0cebc35..453aeaf 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -98,6 +98,24 @@ fn about_body(ui: &mut egui::Ui, t: &Tokens) {
);
}
+/// Escape closes the topmost simple dialog (one without menus of its own), before the canvas's
+/// shortcuts see it (it doesn't deselect), like a system dialog: from one of its fields too.
+pub fn keys(app: &mut FigApp, ctx: &Context) {
+ let u = &mut app.ui;
+ let open = u.color_profile.is_some() || u.find.is_some() || u.confirm_close || u.export_preview || u.shortcuts_panel || u.preferences || u.about;
+ if !open || !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
+ return;
+ }
+ if u.color_profile.take().is_some() || u.find.take().is_some() {
+ return;
+ }
+ for flag in [&mut u.confirm_close, &mut u.export_preview, &mut u.shortcuts_panel, &mut u.preferences, &mut u.about] {
+ if std::mem::take(flag) {
+ return;
+ }
+ }
+}
+
pub fn show(app: &mut FigApp, ctx: &Context) {
let t = Tokens::get(ctx);
if app.ui.about {
diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs
index fad2cf9..165d292 100644
--- a/crates/ui-egui/src/export_panel.rs
+++ b/crates/ui-egui/src/export_panel.rs
@@ -38,7 +38,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
}
}
if mixed {
- row(ui, 28.0, |ui| widgets::label(ui, tr("Click + to replace mixed content"), t.text_secondary));
+ widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary);
}
for (i, e) in list.iter().enumerate() {
setting_row(app, ui, &ids, i, e);
diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs
index b4be04c..32a8933 100644
--- a/crates/ui-egui/src/handles.rs
+++ b/crates/ui-egui/src/handles.rs
@@ -512,6 +512,10 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) {
}
}
}
+ // An end of a linear gradient moved: its bands stay square to it.
+ if *which < 2 && g.kind == figcraft_color::GradientKind::Linear {
+ g.square_linear_width(n.width, n.height);
+ }
}
let cmd = if *fills { "fill.set" } else { "stroke.set" };
let _ = app.session.execute(cmd, &json!({"ids": [id], "index": index, "paint": paint}));
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 893b340..fa6cce6 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -115,6 +115,8 @@ mod titlebar_tests;
#[cfg(test)]
mod vector_tail_ui_tests;
#[cfg(test)]
+mod windows_qa_tests;
+#[cfg(test)]
mod windows_tests;
use std::collections::BTreeSet;
@@ -492,6 +494,13 @@ impl FigApp {
self.ui.toast = Some((msg.into(), None));
}
+ /// Say that `n` documents left unsaved by a crash were opened again from recovery copies.
+ pub fn note_recovered(&mut self, n: usize) {
+ if n > 0 {
+ self.toast(i18n::trn("Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them", n, &[]));
+ }
+ }
+
/// Act on UI requests queued by commands (dialogs, panels).
pub fn handle_ui_requests(&mut self) {
let reqs = std::mem::take(&mut self.session.ui_requests);
@@ -615,7 +624,9 @@ impl FigApp {
}
pub fn open_dialog(&mut self) {
- if let Some(p) = self.services.pick_open(i18n::tr("Open"), &["figcraft", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]) {
+ if let Some(p) =
+ self.services.pick_open(i18n::tr("Open"), &["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"])
+ {
let ps = p.to_string_lossy().to_string();
if self.run("file.open", json!({"path": ps})).is_some() {
self.ui.home = false;
@@ -627,7 +638,7 @@ impl FigApp {
let exts: &[&str] = if place {
&["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]
} else {
- &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft"]
+ &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"]
};
if let Some(p) = self.services.pick_open(if place { i18n::tr("Place image") } else { i18n::tr("Import") }, exts) {
let ps = p.to_string_lossy().to_string();
@@ -750,6 +761,7 @@ impl FigApp {
if !self.has_files() || self.ui.home {
tabs::tab_bar(self, ui);
home::show(self, ui);
+ dialogs::keys(self, &ctx);
dialogs::show(self, &ctx);
windows::requests(self, &ctx);
return;
@@ -817,6 +829,7 @@ impl FigApp {
dev_compare::keys(self, &ctx);
branch_ui::keys(self, &ctx);
live::dialog_keys(self, &ctx);
+ dialogs::keys(self, &ctx);
shortcuts::handle(self, &ctx, typing);
menus::popups(self, &ctx);
variable_ui::picker(self, &ctx);
diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs
index 975d049..b1d0800 100644
--- a/crates/ui-egui/src/present.rs
+++ b/crates/ui-egui/src/present.rs
@@ -1564,7 +1564,7 @@ fn inspect_panel(app: &mut FigApp, ui: &mut Ui, pres: &Presenter) {
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
let Some((id, doc, name)) = found else {
widgets::section_header(ui, tr("Inspect"), &[], false);
- crate::right::row(ui, 32.0, |ui| widgets::label(ui, tr("Hover a layer to see its properties"), t.text_secondary));
+ widgets::note(ui, tr("Hover a layer to see its properties"), t.text_secondary);
return;
};
widgets::section_header(ui, &name, &[], false);
diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs
index c3e2dde..0b688c7 100644
--- a/crates/ui-egui/src/right.rs
+++ b/crates/ui-egui/src/right.rs
@@ -1018,7 +1018,7 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills:
}
style_picker::chip(app, ui, doc, &ids, slot);
match &list {
- None => row(ui, 32.0, |ui| widgets::label(ui, tr("Click + to replace mixed content"), t.text_secondary)),
+ None => widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary),
Some(l) => {
// The last paint is on top: shown first. Rows drag to reorder.
let mut drag = ListDrag::new(ui, if fills { "fills" } else { "strokes" });
diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs
index 9894e4b..397cf69 100644
--- a/crates/ui-egui/src/shortcuts.rs
+++ b/crates/ui-egui/src/shortcuts.rs
@@ -350,7 +350,8 @@ fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event]) {
let _ = figcraft_engine::textedit::end(&mut app.session);
continue;
}
- if let Err(e) = figcraft_engine::textedit::key(&mut app.session, name, m.shift, m.alt, m.command) {
+ let (word, line) = keymap::text_motion(platform(), name, mods(*m));
+ if let Err(e) = figcraft_engine::textedit::key(&mut app.session, name, m.shift, word, line) {
app.toast(e.to_string());
}
}
diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs
index a7e430b..307c96b 100644
--- a/crates/ui-egui/src/tabs.rs
+++ b/crates/ui-egui/src/tabs.rs
@@ -66,7 +66,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
if several {
resp.context_menu(|ui| {
ui.set_width(200.0);
- if crate::menus::item(ui, "Open in new window", "", true, false, &t) {
+ if crate::menus::item(ui, tr("Open in new window"), "", true, false, &t) {
detach = Some((i, None));
ui.close();
}
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index 79dbf7f..65073e6 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -139,24 +139,37 @@ fn number_field_impl(
let mut out = None;
// Scrub by dragging the label.
if let Some(v) = value {
+ let dx_id = id.with("scrub_dx");
if lead_resp.drag_started() {
- ui.data_mut(|d| d.insert_temp(id.with("scrub0"), v));
+ ui.data_mut(|d| {
+ d.insert_temp(id.with("scrub0"), v);
+ d.insert_temp(dx_id, 0.0_f64);
+ });
}
if lead_resp.dragged() || lead_resp.drag_stopped() {
let v0: f64 = ui.data(|d| d.get_temp(id.with("scrub0"))).unwrap_or(v);
- let total = lead_resp.total_drag_delta().map(|d| d.x).unwrap_or(0.0) as f64;
+ // The release frame reports no drag any more: the scrub ends where the last frame left it.
+ let total = match lead_resp.total_drag_delta() {
+ Some(d) => {
+ ui.data_mut(|m| m.insert_temp(dx_id, f64::from(d.x)));
+ f64::from(d.x)
+ }
+ None => ui.data(|m| m.get_temp(dx_id)).unwrap_or(0.0),
+ };
let shift = ui.input(|i| i.modifiers.shift);
let k = if shift { step * 10.0 } else { step };
let nv = v0 + (total * k).round();
out = Some(FieldEdit::Scrub(nv, lead_resp.drag_stopped()));
}
}
- let started = editing.is_none() && field_resp.clicked();
+ // Typing on a field the keyboard reached (Tab) edits it: the editor takes the typed text.
+ let typed = editing.is_none() && field_resp.has_focus() && ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_))));
+ let started = editing.is_none() && (field_resp.clicked() || typed);
if started {
- editing = Some(value.map(fmt_num).unwrap_or_default());
+ editing = Some(if typed { String::new() } else { value.map(fmt_num).unwrap_or_default() });
ui.data_mut(|d| d.insert_temp(editing_id, editing.clone().unwrap_or_default()));
ui.memory_mut(|m| m.request_focus(id.with("te")));
- ui.data_mut(|d| d.insert_temp(id.with("selectall"), true));
+ ui.data_mut(|d| d.insert_temp(id.with("selectall"), !typed));
}
match editing {
Some(mut text) => {
diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs
index 8e24150..cf754b8 100644
--- a/crates/ui-egui/src/windows.rs
+++ b/crates/ui-egui/src/windows.rs
@@ -15,6 +15,7 @@ use figcraft_engine::DocState;
use serde_json::{Value, json};
use crate::canvas::CanvasState;
+use crate::i18n::{tr, trf};
use crate::render_worker::Worker;
use crate::{FigApp, TitleBar, UiState};
@@ -310,13 +311,13 @@ pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) {
}
let t = crate::theme::Tokens::get(ctx);
let (mut open, mut discard, mut cancel) = (true, false, false);
- crate::dialogs::window("Unsaved changes", ctx).open(&mut open).show(ctx, |ui| {
+ crate::dialogs::window(tr("Unsaved changes"), ctx).open(&mut open).show(ctx, |ui| {
ui.set_width(340.0);
- crate::widgets::label(ui, &format!("Unsaved changes in {}. Close the window and lose them?", names.join(", ")), t.text);
+ crate::widgets::label(ui, &trf("Unsaved changes in {names}. Close the window and lose them?", &[("names", &names.join(", "))]), t.text);
ui.add_space(12.0);
ui.horizontal(|ui| {
- discard = crate::widgets::button(ui, "Close without saving", true, true).clicked();
- cancel = crate::widgets::button(ui, "Cancel", false, true).clicked();
+ discard = crate::widgets::button(ui, tr("Close without saving"), true, true).clicked();
+ cancel = crate::widgets::button(ui, tr("Cancel"), false, true).clicked();
});
});
app.ui.confirm_close = open && !discard && !cancel;
diff --git a/crates/ui-egui/src/windows_qa_tests.rs b/crates/ui-egui/src/windows_qa_tests.rs
new file mode 100644
index 0000000..f4b1fd7
--- /dev/null
+++ b/crates/ui-egui/src/windows_qa_tests.rs
@@ -0,0 +1,92 @@
+//! Everyday-workflow bugs found in a Windows QA pass, driven through egui headless: a scrubbed
+//! field keeps its value when the pointer lets go, typing on a field Tab reached edits it, and
+//! Escape closes a dialog without deselecting.
+
+use egui::{Event, Key, Modifiers, Pos2, Rect, vec2};
+use figcraft_doc::NodeId;
+use figcraft_engine::Session;
+use serde_json::json;
+
+use crate::a11y::focused_name;
+use crate::layers_drag_tests::Ui;
+
+fn press(u: &mut Ui, key: Key, modifiers: Modifiers) {
+ u.frame(vec![
+ Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
+ Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
+ ]);
+}
+
+/// A 40Γ30 rectangle, selected.
+fn setup() -> (Ui, NodeId) {
+ let mut s = Session::new();
+ let a = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap());
+ s.execute("select.set", &json!({"ids": [a]})).unwrap();
+ let mut u = Ui::new(s);
+ u.frame(vec![]);
+ (u, a)
+}
+
+fn width(u: &Ui, a: NodeId) -> (f64, f64) {
+ let n = u.app.session.doc().unwrap().node(a).unwrap();
+ (n.width, n.height)
+}
+
+/// Give the Design panel the keyboard (F6) and Tab to the field named `name`; its area on screen.
+fn tab_to(u: &mut Ui, name: &str) -> Rect {
+ press(u, Key::F6, Modifiers::NONE);
+ for _ in 0..200 {
+ if focused_name(&u.ctx) == name {
+ let id = u.ctx.memory(|m| m.focused()).unwrap();
+ return u.ctx.viewport(|v| v.prev_pass.widgets.get(id).map(|w| w.rect)).unwrap();
+ }
+ press(u, Key::Tab, Modifiers::NONE);
+ }
+ panic!("{name} not reached by Tab");
+}
+
+#[test]
+fn typing_on_a_field_tab_reached_edits_it() {
+ let (mut u, a) = setup();
+ tab_to(&mut u, "Width");
+ // No Enter first: the typed text replaces the value.
+ u.frame(vec![Event::Text("64".into())]);
+ press(&mut u, Key::Tab, Modifiers::NONE);
+ u.frame(vec![]);
+ assert_eq!(focused_name(&u.ctx), "Height");
+ u.frame(vec![Event::Text("50".into())]);
+ press(&mut u, Key::Enter, Modifiers::NONE);
+ u.frame(vec![]);
+ assert_eq!(width(&u, a), (64.0, 50.0), "W 64, Tab, H 50, Enter");
+}
+
+#[test]
+fn a_scrubbed_field_keeps_its_value_when_the_pointer_lets_go() {
+ let (mut u, a) = setup();
+ let field = tab_to(&mut u, "Width");
+ // The label left of the value scrubs.
+ let from = Pos2::new(field.min.x - 8.0, field.center().y);
+ u.button(from, true);
+ for k in 1..=10 {
+ u.frame(vec![Event::PointerMoved(from + vec2(4.0 * k as f32, 0.0))]);
+ }
+ assert_eq!(width(&u, a).0, 80.0, "40 points to the right while dragging");
+ u.button(from + vec2(40.0, 0.0), false);
+ u.frame(vec![]);
+ assert_eq!(width(&u, a).0, 80.0, "kept after the release");
+ assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().height, 30.0);
+}
+
+#[test]
+fn escape_closes_preferences_without_deselecting() {
+ let (mut u, a) = setup();
+ // A panel field kept the keyboard when the dialog opened (from the menu or a shortcut).
+ tab_to(&mut u, "Width");
+ u.app.ui.preferences = true;
+ u.frame(vec![]);
+ press(&mut u, Key::Escape, Modifiers::NONE);
+ assert!(!u.app.ui.preferences, "Escape closes the dialog");
+ assert_eq!(u.app.session.selection(), vec![a], "and leaves the selection alone");
+ press(&mut u, Key::Escape, Modifiers::NONE);
+ assert!(u.app.session.selection().is_empty(), "the next Escape reaches the canvas");
+}
diff --git a/docs/architecture.md b/docs/architecture.md
index 5238b4c..766d2a6 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -64,8 +64,9 @@ express an effect.
## File I/O
-`.figcraft` is the native format (atomic saves, autosave checkpoints, recovery copies after a
-crash). Imports: REST API file JSON with variables and images, SVG, raster images, clipboard
+`.figcraft` is the native format (atomic saves, autosave checkpoints; the desktop app keeps
+recovery copies of unsaved documents that reopen after a crash or a forced quit). Imports: REST API
+file JSON with variables and images, SVG, raster images, clipboard
content. Exports: PNG, JPG, WebP, SVG, PDF, code. There is no `.fig` reader yet
([`file-format-parity.md`](file-format-parity.md)).
diff --git a/docs/cli.md b/docs/cli.md
index 3d56d06..c0aee31 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -20,7 +20,8 @@ of a damaged file and import warnings go to stderr as `note:` lines (`notes` wit
Every subcommand that reads a file also takes the public REST API file JSON (the response of
`GET /v1/files/:key` or of its nodes endpoint, written to a `.json` file): it is imported on the fly.
Image fills come from `images/.png` (or `.jpg`, β¦) next to the JSON when present, else a
-grey placeholder.
+grey placeholder. `.fig` files are read the same way (best effort, images from the file itself);
+`info` lists what wasn't imported as `note:` lines.
```text
$ figcraft-cli info app.figcraft
@@ -59,7 +60,7 @@ figcraft-cli export app.figcraft -o poster.pdf --node "Poster" --bleed 3mm --cro
### `convert `
-By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.svg` (an editable frame: groups,
+By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.fig` (best effort), `.svg` (an editable frame: groups,
shapes, text, gradients, masks and effects; `info` lists what it left out as `note:` lines), `.png/.jpg/.webp/.gif/.tif/.bmp`
(an image layer). Outputs: `.figcraft`, `.json` (FigCraft JSON, pretty-printed), `.svg`,
`.png/.jpg/.webp` (the current page rendered at 1x with its background), `.pdf` (the current page's
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index f09e986..549d9f8 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,20 +1,20 @@
# File format parity
-> **Last reviewed:** 2026-10-10 Β· **Last updated:** 2026-10-11 Β· **Change:** minor (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-11 Β· **Change:** minor (`.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
help center (image and video types, export formats), and FigCraft's source.
-**Summary (estimated):** ~40% ready. The core interchange formats (SVG, PNG, JPG, PDF) are solid
-and tested; Figma's own document, `.fig`, can't be read or written, which alone keeps FigCraft at
-alpha.
+**Summary (estimated):** ~55% ready. The core interchange formats (SVG, PNG, JPG, PDF) are solid
+and tested; Figma's own document, `.fig`, opens (best effort, not yet checked against real-world
+files) but can't be written.
## Documents
| Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours |
|---|---|---|---|---|---|
-| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **no** | **no** | none | read 30β60, write 15β30 |
+| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **no** | `.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 outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Vectors are outlines, not editable networks; sticky notes, connectors, tables and other non-design layers, set-variable and conditional actions and video fills are skipped with warnings. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, fuzzed); no real-world files yet | read tail 8β15, write 15β30 |
| `.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, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; 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`) | β |
@@ -61,5 +61,6 @@ alpha.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.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 outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Summary ~40% β ~55%; `.fig` read 30β60 h β 8β15 h (write 15β30 h unchanged) |
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% β 87%, 5β9 h β 4β7 h; gap 12 6β12 h β 5β10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
| 2026-10-10 | major | Created from the 2026-10-10 re-measure |
diff --git a/docs/gaps.md b/docs/gaps.md
index ea24426..477e970 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
-> **Last reviewed:** 2026-10-10 Β· **Last updated:** 2026-10-11 Β· **Change:** minor (Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) Β· **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 Β· **Last updated:** 2026-10-11 Β· **Change:** minor (`.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) Β· **Target:** Figma Design (desktop app 126.10.7)
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
@@ -14,14 +14,14 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| # | Gap | Kind | Hours | Doc |
|---|---|---|---|---|
-| 1 | `.fig` files can't be opened (**beta blocker**) | File format | 30β60 | [file-format-parity](file-format-parity.md) |
+| 1 | `.fig` files: best-effort import landed; editable vector networks, more layer kinds, real-file checks and export left (**beta blocker** until checked on real files, gap 3) | File format | 8β15 | [file-format-parity](file-format-parity.md) |
| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 45β90 | [collaboration-parity](collaboration-parity.md) |
| 3 | Nothing checked against real Figma files | Fidelity | 15β30 | [target-app-parity](target-app-parity.md) |
| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40β60 | [ui-parity](ui-parity.md) |
| 5 | Localization: English and a Spanish draft only (the layer landed 2026-10-11) | Localization | 45β75 | [localization-parity](localization-parity.md) |
| 6 | Plugins: no distribution or widgets; Figma's plugins don't run | Ecosystem | 15β40 | [target-app-parity](target-app-parity.md) |
| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30β60 | [hardware-parity](hardware-parity.md) |
-| 8 | Platform QA is mostly macOS | Stability/platforms | 10β20 | [hardware-parity](hardware-parity.md) |
+| 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10β20 | [hardware-parity](hardware-parity.md) |
| 9 | Figma Draw: multi-edit, pen pressure on macOS/Linux tablets (brushes, pressure, text on a path and pattern fills landed) | Feature | 2β5 | [target-app-parity](target-app-parity.md) |
| 10 | Team libraries: no team-wide lists or accounts; analytics are counts | Feature | 4β8 | [components-parity](components-parity.md) |
| 11 | Spell check | Feature | 4β8 | [target-app-parity](target-app-parity.md) |
@@ -40,19 +40,21 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
## Entries
-### 1. `.fig` files can't be opened
-- **Missing:** a reader for Figma's native document (`com.figma.document`, extensions `fig`, `jam`,
- `deck` in the installed app's `Info.plist`). Figma's files live in the cloud; "Save local copy"
- writes a `.fig`. Today the only way in is the REST API file JSON (`file.open` / `file.import`),
- which needs an access token and a separate download of images and variables.
-- **Evidence:** no `.fig` code anywhere in `crates/format` (searched 2026-10-10); ROADMAP listed
- ".fig import (best effort)" as left.
-- **Impact:** blocks beta by definition: a designer can't bring existing work across. The single
- biggest gap.
-- **Estimate:** 30β60 h for a best-effort reader written from public format knowledge (a
- compressed binary schema plus a message per node; mapping onto the existing REST importer's
- model), with a corpus of synthetic `.fig` files saved from the installed Figma. Export to `.fig`
- is a further 15β30 h and lower priority.
+### 1. `.fig` files: the import's tail
+- **Landed (2026-10-11):** `.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 outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). `file.open`,
+ `file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved
+ over.
+- **Missing:** vectors come in as their outlines (fill or stroke geometry), not editable vector
+ networks with per-vertex caps; sticky notes, connectors, tables, slides and other non-design layer
+ kinds are skipped; set-variable and conditional prototype actions, variables bound to fields other
+ than the common ones, non-flexible grid tracks and video fills aren't read; text lays out again with
+ local fonts. Nothing has been checked against real-world files yet (gap 3). Export to `.fig` is
+ not started.
+- **Evidence:** fixtures generated in code (`crates/format/src/fig/tests.rs`: kiwi round trips,
+ every mapped feature, truncation, bit flips and junk never panic); `crates/engine/src/fig_import_tests.rs`.
+- **Impact:** existing work can be brought across, but only real files will show how much of it.
+- **Estimate:** 8β15 h for the read tail (vector networks, the skipped layer kinds that matter,
+ real-file fixes); export to `.fig` a further 15β30 h and lower priority.
### 2. Multiplayer and sharing are a preview, without accounts
- **Missing:** accounts, teams and projects, per-person permissions, spotlight, cursor chat, audio,
@@ -134,10 +136,15 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
- **Estimate:** 30β60 h for a GPU path (vello or wgpu shaders) held to the CPU renderer as the
oracle.
-### 8. Platform QA is mostly macOS
-- **Missing:** regular runs on Windows, Linux and the browser; real screen readers.
+### 8. Platform QA: Linux and the browser left
+- **Missing:** regular runs on Linux and the browser; real screen readers; a repeat of the Windows
+ pass on other machines (scale factors above 100%, pen tablets, file drops from Explorer).
- **Evidence:** CI tests on Linux only (`.github/workflows/ci.yml`); release builds exist for every
- platform (v0.2.0) but no issue has been filed yet and the builders checked on macOS.
+ platform (v0.2.0) but no issue has been filed yet. Windows: QA pass done 2026-10-11, findings in
+ PR (Windows 11, the desktop app driven over the control channel, the same workflow through MCP):
+ eight bugs fixed, among them unsaved changes lost when the app was killed or crashed,
+ Ctrl+arrows and Ctrl+Backspace acting on the whole line in text, scrubbed fields snapping back
+ and skewed linear gradients.
- **Impact:** unknown bugs on the platforms where FigCraft is most attractive (Linux has no Figma
desktop app).
- **Estimate:** 10β20 h, partly human (hardware).
@@ -284,6 +291,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
+| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.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 outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Gap 1 30β60 h β 8β15 h (vector networks, other layer kinds, real-file checks; export 15β30 h on top, as before) |
| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View βΈ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13β27 h β 12β26 h; gap 18 6β12 h β 5β11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363β697 h β ~362β696 h |
| 2026-10-11 | minor | Several windows (gap 18): File βΈ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% β 60%, 13β27 h β 10β21 h; gap 18 6β12 h β 4β8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363β697 h β ~360β691 h |
| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences βΈ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% β 20%, 60β100 h β 45β75 h; ready for real work unchanged (~47%); full parity ~363β697 h β ~348β672 h |
diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md
index fbdfcf0..8995db2 100644
--- a/docs/hardware-parity.md
+++ b/docs/hardware-parity.md
@@ -1,6 +1,6 @@
# Hardware and platform parity
-> **Last reviewed:** 2026-10-10 Β· **Last updated:** 2026-10-11 Β· **Change:** minor (Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) Β· **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 Β· **Last updated:** 2026-10-11 Β· **Change:** minor (Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) Β· **Target:** Figma Design (desktop app 126.10.7)
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
@@ -40,7 +40,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Platform | Figma | FigCraft | Notes |
|---|---|---|---|
| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens |
-| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; little hands-on QA |
+| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR |
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here |
| FreeBSD | none | tarball | |
| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser |
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 through the desktop app and MCP; fixed: unsaved changes lost when the app was killed or crashed (recovery copies every few seconds, reopened at the next start), Ctrl+arrows/Backspace/Delete acting on the whole line in text (now by word; Ctrl+Home/End to the text's ends), scrubbed fields snapping back on release, typing on a field Tab reached, Escape not closing dialogs, skewed or vanishing linear gradients, hints cut off at the panel edge, the window-close question in English in the Spanish interface. Percentages and hours unchanged |
| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View βΈ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13β27 h β 12β26 h; gap 18 6β12 h β 5β11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363β697 h β ~362β696 h |
| 2026-10-11 | minor | Several windows (gap 18): File βΈ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% β 60%, 13β27 h β 10β21 h; gap 18 6β12 h β 4β8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363β697 h β ~360β691 h |
| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% β 65%, ready 30% β 45%, 10β18 h β 5β9 h; Strokes 2β4 h β 1β3 h; Hardware 50% β 55%, 15β30 h β 13β27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380β725 h β ~375β715 h |
diff --git a/docs/mcp.md b/docs/mcp.md
index f84ea72..b2c02a7 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -152,7 +152,7 @@ Or in a project's `.mcp.json`:
| `select {ids}` | Replace the selection (`[]` deselects). |
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?}` | png, jpg, svg, webp, pdf; `size` is `2x`, `512w` or `512h`. PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?}` (every layer once per setting, named `.`). |
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. |
-| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. |
+| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. |
| `undo` / `redo` | Step through history. |
| `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. A dragged layer drops into the frame or auto layout slot under the pointer, except one inside a group, a boolean group or an instance (outside its slots), which only moves. `pressure` (0β1) is pen pressure; a drag goes from `pressure` to `pressureTo` (see Brushes and pen pressure). |
| `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. |
@@ -532,6 +532,18 @@ and `viewBox`/`preserveAspectRatio` are resolved. The result's `warnings` list w
Linked files are never read, only data URIs; documents nested more than 512 elements deep are
refused.
+### Importing `.fig` files
+
+`open_file {path}` (a new, untitled document; the file is never saved over) and `file.import`
+(pages appended) read `.fig` files, best effort. Each file carries the schema its data is encoded
+with, so the data is decoded generically and mapped by field name onto the REST import below: the
+same layers, paints, text, auto layout, components, styles, variables and prototyping come across,
+with image fills from the file's own `images/` folder. Instances are rebuilt from their main
+component with the overrides and property values the file records. Vectors come in as their
+outlines (not editable vector networks); sticky notes, connectors, tables and other non-design
+layers, set-variable and conditional actions and video fills are skipped. The result's `warnings`
+list what was skipped.
+
### Importing REST API file JSON
The JSON returned by the public REST API's file endpoint (`GET /v1/files/:key`, best with
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 918af49..383450b 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
-> **Last reviewed:** 2026-10-10 Β· **Last updated:** 2026-10-11 Β· **Change:** minor (Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) Β· **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 Β· **Last updated:** 2026-10-11 Β· **Change:** minor (`.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) Β· **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@@ -9,8 +9,8 @@ are one Opus 5.5 agent; most milestones split across 4β6 agents on disjoint cr
## Current focus
-1. **`.fig` import** (best effort) with a synthetic `.fig` corpus saved from the installed Figma:
- 30β60 h. Unblocks beta and every real-file measurement.
+1. **`.fig` import's tail**: editable vector networks, the skipped layer kinds that matter and
+ fixes from real files (the best-effort import landed 2026-10-11): 8β15 h.
2. **Real-file corpus and fidelity measurement**: import real files, compare against Figma's own
exports, turn estimated percentages into measured ones: 15β30 h.
3. **Tool-by-tool interaction pass** against the running Figma ([`ui-parity.md`](ui-parity.md)):
@@ -32,7 +32,7 @@ builders' side-by-side checks with synthetic files; nothing has been run on real
| Prototype and present: interactions, flows, overlays, smart animate, variables | yes | `proto_*_tests.rs`, `present_*_tests.rs` | β |
| Hand off to developers: Dev Mode inspect, code, measurements, asset export, ready for dev | yes | `devinspect_tests.rs`, `component_codegen_tests.rs`, `devcompare_tests.rs`, `annotations_tests.rs` | β |
| Review with others: comments, versions, sharing the work | partial (not blocking) | comments with threads, mentions and resolve plus version history work inside a file (`comments_tests.rs`, `version_tests.rs`); the file is shared by passing it along; live sessions (preview: hosted from the desktop app or a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, undo per person); no accounts or teams ([collaboration-parity](collaboration-parity.md)) | beta gap: 45β90 |
-| Continue existing Figma work (open `.fig`) | no (beta gap) | no `.fig` reader; REST JSON import works for files fetched through Figma's API ([file-format-parity](file-format-parity.md)) | beta gap: 30β60 |
+| Continue existing Figma work (open `.fig`) | partial (beta gap) | best-effort `.fig` import (`crates/format/src/fig`, tested on generated fixtures, not yet on real files); REST JSON import works for files fetched through Figma's API ([file-format-parity](file-format-parity.md)) | beta gap: 8β15 |
**Passes, so FigCraft stays alpha:** every daily design workflow works end to end on FigCraft's
own files, which save and reopen, and ready for real work (~47%) is above the ~40% bar. The two
@@ -52,7 +52,7 @@ blockers.
| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7β14 |
| M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11) | spell check, Figma Draw multi-edit | 7β18 |
| M5 Prototype & present | ~85% | media, gamepad, separate window | 5β9 |
-| M6 Interop & Dev Mode | ~55% (code connect and Dev Mode plugins landed 2026-10-11) | `.fig` import, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 37β75 |
+| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15β30 |
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40β60 |
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60β110 |
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45β90 |
@@ -72,6 +72,7 @@ blockers.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-11 | minor | `.fig` import, best effort: current focus 1 and the alpha gate's `.fig` row move to the import's tail (30β60 h β 8β15 h); M6 ~55% β ~65%, 37β75 h β 15β30 h |
| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View βΈ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13β27 h β 12β26 h; gap 18 6β12 h β 5β11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363β697 h β ~362β696 h |
| 2026-10-11 | minor | Several windows (gap 18): File βΈ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% β 60%, 13β27 h β 10β21 h; gap 18 6β12 h β 4β8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363β697 h β ~360β691 h |
| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences βΈ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% β 20%, 60β100 h β 45β75 h; ready for real work unchanged (~47%); full parity ~363β697 h β ~348β672 h; beta ~200β365 h β ~195β355 h |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index d5e157a..c2459ba 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# FigCraft parity with Figma
-> **Last reviewed:** 2026-10-10 Β· **Last updated:** 2026-10-11 Β· **Change:** minor (layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) Β· **Target:** Figma Design (desktop app 126.10.7, macOS)
+> **Last reviewed:** 2026-10-10 Β· **Last updated:** 2026-10-11 Β· **Change:** minor (layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) Β· **Target:** Figma Design (desktop app 126.10.7, macOS)
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@@ -16,13 +16,13 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
| Number | Value | Kind |
|---|---|---|
| Menu/command breadth | **100%** (204 / 204 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-11) |
-| Feature breadth (does each Figma Design feature exist, weighted) | **~79%** (core editor areas 1β16: ~90%) | estimated from the area table below |
-| Ready for real work | **~47%** | estimated (dimension table below: written weights Γ written per-dimension scores) |
-| Essentials user | **~53%** | estimated ([below](#essentials-user): core features 77.9% Γ discounts 0.92 Γ 0.92 Γ 0.8) |
-| Mainstream practitioner | **~43%** | estimated ([below](#mainstream-practitioner): weekly areas 67.1% Γ discounts 0.9 Γ 0.9 Γ 0.8) |
-| Remaining to beta | **~195β355 h** one Opus 5.5 agent (~40β80 h wall-clock with 5 parallel agents) | estimated |
-| Remaining to full parity | **~344β663 h** one agent (~92β160 h wall-clock with 5 parallel agents) | estimated |
-| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); `.fig` import and multiplayer are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | |
+| Feature breadth (does each Figma Design feature exist, weighted) | **~80%** (core editor areas 1β16: ~91%) | estimated from the area table below |
+| Ready for real work | **~49%** | estimated (dimension table below: written weights Γ written per-dimension scores) |
+| Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% Γ discounts 0.92 Γ 0.92 Γ 0.88) |
+| Mainstream practitioner | **~46%** | estimated ([below](#mainstream-practitioner): weekly areas 68.1% Γ discounts 0.9 Γ 0.9 Γ 0.85) |
+| Remaining to beta | **~173β310 h** one Opus 5.5 agent (~35β70 h wall-clock with 5 parallel agents) | estimated |
+| Remaining to full parity | **~322β618 h** one agent (~86β150 h wall-clock with 5 parallel agents) | estimated |
+| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | |
The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115β195 h") were
self-graded against a 16-area rubric that left out, by design, multiplayer, plugins, cloud
@@ -35,9 +35,9 @@ tool; presence was being graded as behaviour in places.
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
-| Full target (ready for real work) | ~47% | ~327β629 h | multiplayer (45β90), localization (45β75), plugins (15β40), the interaction pass (40β60), `.fig` import (30β60), GPU canvas (30β60), AI (30β60) |
-| Mainstream practitioner | ~43% | ~162β304 h | core editor depth incl. `.fig` import (80β157), the interaction pass (40β60), real-file corpus and platform QA (25β50), `.fig` export for handing work back (15β30) |
-| Essentials user | ~53% | ~85β160 h | `.fig` import so files people send open (30β60), core tool polish (25β45), interaction pass on the core tools (15β25), stability QA (10β20), onboarding and starter templates (5β10) |
+| Full target (ready for real work) | ~49% | ~305β584 h | multiplayer (45β90), localization (45β75), plugins (15β40), the interaction pass (40β60), GPU canvas (30β60), AI (30β60), the `.fig` import's tail (8β15) |
+| Mainstream practitioner | ~46% | ~140β259 h | core editor depth incl. the `.fig` import's tail (58β112), the interaction pass (40β60), real-file corpus and platform QA (25β50), `.fig` export for handing work back (15β30) |
+| Essentials user | ~57% | ~63β115 h | the `.fig` import's tail so the files people send open well (8β15), core tool polish (25β45), interaction pass on the core tools (15β25), stability QA (10β20), onboarding and starter templates (5β10) |
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
per 1,000 added lines, 1β2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see
@@ -107,7 +107,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 11 | Components & variants | 9 | 95% | 68% | Real (not synthetic) libraries, `.fig` libraries, component playground ([components-parity](components-parity.md)) | 6β12 |
| 12 | Styles & variables | 5 | 92% | 72% | New library variables reaching extended collections on their own, extending an extended collection, real-library QA (extended collections, variables import/export, gradient stops and variant properties bound landed) | 1β2 |
| 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5β9 |
-| 14 | Import/export | 6 | 65% | 35% | **`.fig` import** (the blocking gap), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 30β60 |
+| 14 | Import/export | 6 | 80% | 50% | `.fig` import's tail (best-effort import landed: vectors as outlines, non-design layers skipped, no real-file checks yet), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 8β15 |
| 15 | Dev Mode / inspect | 2 | 90% | 64% | Dev resources links, import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (code connect and plugins in Dev Mode landed) ([dev-mode-parity](dev-mode-parity.md)) | 1β3 |
| 16 | Comments, versions, find, misc | 3 | 85% | 60% | Comments are local only; a branch's own version history (branches with review and merge landed) | 2β5 |
| 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat ([collaboration-parity](collaboration-parity.md)) | 45β90 |
@@ -115,8 +115,8 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4β8 |
| 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2β5 |
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30β60 |
-| | **Weighted (Γ·113)** | 113 | **~79%** | **~57%** | | **186β377** |
-| | Core editor only (1β16, Γ·94) | 94 | ~90% | ~67% | | 81β158 |
+| | **Weighted (Γ·113)** | 113 | **~80%** | **~58%** | | **164β332** |
+| | Core editor only (1β16, Γ·94) | 94 | ~91% | ~68% | | 59β113 |
## By dimension (ready for real work)
@@ -124,7 +124,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|---|---|---|---|---|---|
| Features (depth) | 35 | 57% | 59β112 (areas 1β13, 15, 16, 20) | The table above; core editor ~67% | this file |
| UI/UX fidelity | 15 | 58% | 40β60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
-| File formats | 12 | 40% | 30β60 | No `.fig` read or write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) |
+| File formats | 12 | 55% | 8β15 | `.fig` read, best effort (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) |
| Collaboration | 10 | 13% | 55β110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
| Stability | 8 | 55% | 20β40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) |
| Performance | 6 | 60% | 30β60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4β64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) |
@@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| Platforms | 3 | 75% | 10β20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) |
| Ecosystem/plugins | 3 | 28% | 15β40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
| AI features | 2 | 0% | 30β60 | None | [gaps](gaps.md) |
-| **Ready for real work** | 100 | **~47%** | **~344β663** | | |
+| **Ready for real work** | 100 | **~49%** | **~322β618** | | |
Dimension hours add up to the full-parity total without double counting: the feature-area rows
for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are
@@ -155,12 +155,12 @@ graphic or a simple mockup. Same method, with the core features a casual user to
| Rectangle, ellipse, line, arrow, corner radius | 8 | 80% | |
| Text: type, font, size, weight, colour, alignment | 12 | 70% | no spell check |
| Colour picker, fills and strokes; place and crop images | 10 | 72% | no HEIC images |
-| Layers panel: rename, hide, lock, reorder | 7 | 82% | F2 rename, keyboard context menu |
+| Layers panel: rename, hide, lock, reorder | 7 | 80% | |
| Basic auto layout (add, gap, padding) | 6 | 70% | |
| Undo / redo | 5 | 90% | unlimited, one step per gesture |
| Export PNG, JPG, SVG, PDF | 6 | 85% | |
| Copy and paste, incl. images from other apps | 3 | 75% | |
-| **Weighted** | 100 | **77.9%** | |
+| **Weighted** | 100 | **77.8%** | |
Left out: components and variables beyond using one, prototyping, Dev Mode, advanced vector and
type, and everything the mainstream number leaves out.
@@ -171,9 +171,9 @@ type, and everything the mainstream number leaves out.
|---|---|---|
| Launch and stability | Γ0.92 | signed and notarized macOS build, installers for every platform (v0.2.0), never-crash rules and recovery copies; no field record yet |
| Discoverability and UI clarity | Γ0.92 | the UI3 layout Figma users know, Actions palette, keyboard shortcuts panel; no onboarding, templates or Community files to start from |
-| Opening files people send them | Γ0.80 | what people send a Figma user is a Figma link or a `.fig`; neither opens. Images and SVG do |
+| Opening files people send them | Γ0.88 | what people send a Figma user is a Figma link or a `.fig`; a `.fig` opens (best effort, not yet checked on real files), a link doesn't. Images and SVG do |
-77.9% Γ 0.92 Γ 0.92 Γ 0.80 = **~53%** (estimated). User evidence: none yet (see above).
+77.8% Γ 0.92 Γ 0.92 Γ 0.88 = **~57%** (estimated). User evidence: none yet (see above).
## Mainstream practitioner
@@ -187,7 +187,7 @@ libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a produc
localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration
and is left out as an area, but its effect on working with Figma users is counted in discount 3.
-**2. Average depth** ("Ready" column, weighted): **67.1%**.
+**2. Average depth** ("Ready" column, weighted): **68.1%**.
**3. Discounts for what still stops real work:**
@@ -195,22 +195,22 @@ and is left out as an area, but its effect on working with Figma users is counte
|---|---|---|
| Interaction fidelity | Γ0.90 | UI3 look compared side by side (~65% by the builders); no tool-by-tool comparison of cursors, modifier keys while dragging, snapping thresholds or popovers yet; Ctrl to suspend snapping on macOS missing ([ui-parity](ui-parity.md)) |
| Stability on real machines | Γ0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's Γ0.92 because there is no outside evidence either way |
-| File exchange with Figma users | Γ0.80 | A Figma designer's work lives in shared Figma files. FigCraft can't open `.fig` or write anything Figma opens as a document; incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) |
+| File exchange with Figma users | Γ0.85 | A Figma designer's work lives in shared Figma files. FigCraft opens a `.fig` (best effort, not yet checked on real files) but writes nothing Figma opens as a document; incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) |
-67.1% Γ 0.90 Γ 0.90 Γ 0.80 = **~43%** (estimated).
+68.1% Γ 0.90 Γ 0.90 Γ 0.85 = **~46%** (estimated).
**4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments
from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is
private, 1 star, PRs only from maintainers). No outside evidence either way; nothing raises the
number.
-**Why it is lower than ready for real work (~47%), unusually.** The full number averages
+**Why it is lower than ready for real work (~49%), unusually.** The full number averages
dimensions additively, so the missing `.fig` exchange costs only the file-format dimension's 12%
weight, and FigCraft earns points the mainstream number doesn't count (platforms 75%: Linux,
FreeBSD and the web, where Figma has no desktop app). The mainstream number applies file exchange
as a multiplier on everything, because a Figma practitioner's whole working week is shared through
Figma files. Both are computed from written weights; neither was moved to match the other. The
-mainstream number rises fastest with `.fig` import (the Γ0.80 toward Γ0.95: ~51%) and the
+mainstream number rises fastest with `.fig` import checked on real files and `.fig` export (the Γ0.85 toward Γ0.95: ~52%) and the
interaction pass.
## How the hours are calibrated
@@ -581,7 +581,8 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
-| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`β¦, bindable fields in the Design panel and REST import). Layers panel 95%/78% β 98%/82%, 2β4 h β 1β2 h; Strokes 90%/72% β 95%/75%, 1β3 h β 1β2 h; feature breadth (~79%), ready for real work (~47%), essentials (~53%) and mainstream (~43%) unchanged at this rounding; full parity ~345β666 h β ~344β663 h |
+| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`β¦, bindable fields in the Design panel and REST import). Layers panel 95%/78% β 98%/82%, 2β4 h β 1β2 h; Strokes 90%/72% β 95%/75%, 1β3 h β 1β2 h; mainstream depth 67.8% β 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~323β621 h β ~322β618 h |
+| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.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 outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Import/export breadth 65% β 80%, ready 35% β 50%, 30β60 h β 8β15 h; file formats 40% β 55%, 30β60 h β 8β15 h; feature breadth ~79% β ~80% (core ~90% β ~91%), areas ready ~57% β ~58% (core ~67% β ~68%); ready for real work ~47% β ~49%; essentials ~53% β ~57% (opening files people send Γ0.80 β Γ0.88); mainstream ~43% β ~46% (weekly areas 66.8% β 67.8%, file exchange Γ0.80 β Γ0.85); beta ~195β355 h β ~173β310 h; full parity ~345β666 h β ~323β621 h |
| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% β 98%/74%, 6β12 h β 3β6 h; percentages unchanged at this rounding; full parity ~348β672 h β ~345β666 h |
| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View βΈ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13β27 h β 12β26 h; gap 18 6β12 h β 5β11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363β697 h β ~362β696 h |
| 2026-10-11 | minor | Several windows (gap 18): File βΈ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% β 60%, 13β27 h β 10β21 h; gap 18 6β12 h β 4β8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363β697 h β ~360β691 h |
|