M6: .fig import (best effort) (#132)
.fig files open through file.open, file.import, drag and drop, the dialogs, the CLI and MCP. A generic reader written from the public format description decodes the zip, the deflate/zstd chunks and the kiwi schema and message, then maps nodes by field name onto the REST importer: layer tree, geometry, paints with styles resolved, strokes, effects, text runs, vectors as outlines, auto layout including grid, constraints, components, sets, instances expanded from their main with overrides, styles, variables with modes and bindings, prototyping and image fills. Unsupported layers come back as warnings. Fuzzed; tested on fixtures generated in code. New dependency: ruzstd (pure-Rust zstd decoder).
This commit is contained in:
parent
10a9b7b165
commit
2f49fcc490
|
|
@ -1531,7 +1531,9 @@ name = "figcraft-format"
|
|||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"flate2",
|
||||
"image",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
@ -3908,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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@
|
|||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🔌 Offline and private</h3>
|
||||
Your files are plain <code>.figcraft</code> 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 <code>.figcraft</code> archives on your own disk. No account, no cloud, no telemetry. SVG, PNG, JPG, WebP and PDF in and out; <code>.fig</code> files open (best effort), and so do designs exported as the public REST API file JSON, with their prototypes and variables.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🤖 Agent-ready</h3>
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
37
ROADMAP.md
37
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 (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 ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.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.8% × 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 66.8% × 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 | **~345–666 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 67.8% × 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 | **~323–621 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%) | 60–115 | [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.
|
||||
|
|
|
|||
|
|
@ -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 <out> directory, or one file when <out>
|
||||
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 <out> directory, or one file when <out> 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.";
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"file.open",
|
||||
"Open",
|
||||
"{path, variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.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 `<name>.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,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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
|
||||
),
|
||||
|
|
@ -431,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,
|
||||
|
|
@ -457,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);
|
||||
|
|
@ -977,7 +982,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1239,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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<T>(msg: impl Into<String>) -> Result<T, KiwiError> {
|
||||
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<Type>,
|
||||
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<Field>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Schema {
|
||||
pub defs: Vec<Def>,
|
||||
}
|
||||
|
||||
/// 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<u8>),
|
||||
Enum(&'s str),
|
||||
Array(Vec<Kv<'s>>),
|
||||
/// 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<f64> {
|
||||
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<bool> {
|
||||
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<u8, KiwiError> {
|
||||
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<u32, KiwiError> {
|
||||
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<i32, KiwiError> {
|
||||
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<u64, KiwiError> {
|
||||
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<i64, KiwiError> {
|
||||
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<f32, KiwiError> {
|
||||
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<String, KiwiError> {
|
||||
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<Schema, KiwiError> {
|
||||
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<usize> {
|
||||
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<Kv<'s>, 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<Kv<'s>, 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<Kv<'s>, 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<Kv<'s>, 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<u8>);
|
||||
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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/<hash>` 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<Result<(Document, Vec<String>), 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<String>), 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<String>) -> FormatError {
|
||||
FormatError::Corrupt(format!(".fig file: {}", msg.into()))
|
||||
}
|
||||
|
||||
/// The parts of a `.fig` archive the import reads.
|
||||
struct Archive<'b> {
|
||||
canvas: Vec<u8>,
|
||||
file_name: Option<String>,
|
||||
/// `images/<hash>` members (or nothing for a bare canvas).
|
||||
zip: Option<zip::ZipArchive<std::io::Cursor<&'b [u8]>>>,
|
||||
}
|
||||
|
||||
impl<'b> Archive<'b> {
|
||||
fn open(bytes: &'b [u8]) -> Result<Self, FormatError> {
|
||||
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::<serde_json::Value>(&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<Vec<u8>> {
|
||||
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<u8>), 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<Vec<&[u8]>, 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<Vec<u8>, FormatError> {
|
||||
fn capped(r: impl Read) -> Option<Vec<u8>> {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Vec<&str>> = 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<'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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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::<String>());
|
||||
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<u8> {
|
||||
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::<Vec<_>>();
|
||||
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<u8> = (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<u8> = {
|
||||
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<std::path::PathBuf> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>), FormatError>
|
|||
}
|
||||
|
||||
/// Read archive member `name`, refusing members larger than `limit` bytes.
|
||||
fn read_member<R: Read + Seek>(z: &mut zip::ZipArchive<R>, name: &str, limit: u64) -> Result<Vec<u8>, FormatError> {
|
||||
pub(crate) fn read_member<R: Read + Seek>(z: &mut zip::ZipArchive<R>, name: &str, limit: u64) -> Result<Vec<u8>, 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 {
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
|||
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"),
|
||||
|
|
|
|||
|
|
@ -612,7 +612,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;
|
||||
|
|
@ -624,7 +626,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();
|
||||
|
|
|
|||
|
|
@ -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/<imageRef>.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 <in> <out>`
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
33
docs/gaps.md
33
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 (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)
|
||||
> **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,7 +14,7 @@ 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) |
|
||||
|
|
@ -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,
|
||||
|
|
@ -289,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 |
|
||||
|
|
|
|||
14
docs/mcp.md
14
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 `<layer><suffix>.<format>`). |
|
||||
| `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. |
|
||||
|
|
@ -519,6 +519,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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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 (`.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.8% × discounts 0.92 × 0.92 × 0.8) |
|
||||
| Mainstream practitioner | **~43%** | estimated ([below](#mainstream-practitioner): weekly areas 66.8% × 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 | **~345–666 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 67.8% × 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 | **~323–621 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%** | | **187–380** |
|
||||
| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 82–161 |
|
||||
| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **165–335** |
|
||||
| | Core editor only (1–16, ÷94) | 94 | ~91% | ~68% | | 60–116 |
|
||||
|
||||
## 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% | 60–115 (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%** | **~345–666** | | |
|
||||
| **Ready for real work** | 100 | **~49%** | **~323–621** | | |
|
||||
|
||||
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
|
||||
|
|
@ -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.8% × 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): **66.8%**.
|
||||
**2. Average depth** ("Ready" column, weighted): **67.8%**.
|
||||
|
||||
**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)) |
|
||||
|
||||
66.8% × 0.90 × 0.90 × 0.80 = **~43%** (estimated).
|
||||
67.8% × 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
|
||||
|
|
@ -580,6 +580,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| 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. 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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue