M6: experimental .fig export
An experimental, best-effort `.fig` export, so work done in FigCraft can be handed back as a `.fig` file. It is written from the same public format write-ups the importer uses and is the inverse of the importer's mapping. Only round trips through FigCraft's own importer are verified; other apps may not open every file.
- **Encoder** (`crates/format/src/fig/kiwi.rs`): the kiwi encoder that test fixtures used is now shipped code. It never panics and checks every value against the schema: unknown fields or enum names, missing struct fields, wrong types, out-of-range numbers and non-finite floats are errors, and NULs inside strings are dropped. Varints and floats are written exactly the way the decoder reads them. `Schema::parse` reads the compact text schema form, and the importer's tests use it too.
- **Writer** (`crates/format/src/fig/export.rs`): writes the zip (`canvas.fig`, `meta.json`, `thumbnail.png`, `images/<hash>`). `canvas.fig` holds the fig-kiwi header, then a raw-deflate binary schema and a raw-deflate message (flate2's pure-Rust backend, already in the tree; no new dependencies). The schema covers only the fields we write. The canvas, images, blobs, node count and nesting depth are capped, and floats are made finite before they are written.
- **Mapping:** pages; frames, groups, sections, rectangles, ellipses (arcs), polygons, stars, lines and vectors (vector network blobs with per-point radius, mirroring and caps, plus fill outlines); text with styled runs (UTF-16 indexed) and list types; slices; boolean groups; solid, gradient and image paints (image bytes included); strokes; effects; corners; constraints; masks; auto layout, including grids with tracks and cells; child layout; layout grids; export settings; components, component sets and their properties. Instances are written as their main plus the fields their layers change, by id path. This covers nested instances and swaps, and fields the main sets but the instance clears. Prototype interactions (triggers, navigation and overlay actions, transitions, easing), scrolling and flow starts are included. Everything skipped is reported in a warnings list: style links, variables, comments, Dev Mode data, plugin data, guides, slots, brush and width-profile strokes, pattern fills, image adjustments, variable and conditional actions, and more.
- **Import fixes found by the round trips** (`map.rs`): ellipse arcs, boolean operation types and component set descriptions are now read. Durations are read as whole milliseconds, because seconds stored as 32-bit floats used to truncate by 1 ms.
- **File writes:** a failed write no longer leaves its `.tmp-figcraft` file behind. The junk-parameter sweep found this.
- **Commands and UI:**
- `file.export {format: "fig"}`, or any path ending in `.fig`, writes the whole file and returns `warnings`. Without a path it returns base64 `data`.
- File > Export... has a new "The whole file" section with a ".fig (experimental)" row. A note under it says other apps may not open every exported file. After export, a toast reports how many kinds of content were left out.
- `figcraft-cli export --format fig` (or `-o x.fig`) and `convert` to `.fig` print the warnings as `note:` lines.
- MCP's `export` accepts `format: "fig"` and passes the warnings through.
- New UI strings have English and Spanish entries.
- **Docs:** file-format-parity.md has a new export section covering what is written, what is skipped, and that only round trips through our own importer are verified. Also updated: mcp.md, cli.md, gaps.md (gap 1), target-app-parity.md and ROADMAP.md. Grading is kept low. Import/export goes to 86%/58% and file formats to 60%. The file-exchange discount stays at x0.85, because nothing shows that another app opens these files.
This commit is contained in:
parent
8d5f8a0978
commit
c420721597
21
ROADMAP.md
21
ROADMAP.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.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,10 +14,10 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
|||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| Menu/command breadth | **100%** (207 / 207) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Feature breadth | **~84%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
|
||||
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
|
||||
| Ready for real work | **~53%** | 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 | **~49%** | estimated: weekly areas 72.6% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Remaining to beta | **~155–280 h** one agent | estimated |
|
||||
| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
|||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
|
||||
| Mainstream practitioner | ~49% | ~130–244 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
|
||||
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the experimental `.fig` export opens elsewhere (5–15) |
|
||||
| Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), 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
|
||||
|
|
@ -58,7 +58,7 @@ native speakers' reviews of the six language drafts need people.
|
|||
|---|---|---|---|
|
||||
| Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats | 59% (`.fig` read best effort with editable vectors, no write) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, checked only through our own importer) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
|
||||
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
|
||||
|
|
@ -85,7 +85,7 @@ native speakers' reviews of the six language drafts need people.
|
|||
| Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) |
|
||||
| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) |
|
||||
| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) |
|
||||
| Import/export | 84% | 57% | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) |
|
||||
| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
|
|
@ -123,7 +123,7 @@ IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-pa
|
|||
|
||||
Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h.
|
||||
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the experimental `.fig` export (landed 2026-10-12) on real files: 5–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: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h).
|
||||
|
|
@ -131,6 +131,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-12** · Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig
|
||||
(experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document: layers,
|
||||
geometry, paints with their images, strokes, effects, styled text, vector networks, auto layout, components and
|
||||
instances with their overrides, and prototype interactions; what's left out (style links, variables, comments,
|
||||
Dev Mode data…) comes back as warnings. Checked only by reading the files back with our own importer. File
|
||||
formats 60%.
|
||||
- **2026-10-12** · Keyboard shortcut parity (gap 4): a table test with a row for each of the 129
|
||||
shortcuts in the public documentation, macOS and Windows/Linux keys each, and the 48 bindings it
|
||||
is silent on pinned; 126 bound (110 before), Run last plugin moved off a documented conflict on
|
||||
|
|
@ -245,6 +251,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder mirroring the decoder and the schema of the fields written; what's left out is a warning; verified only by round trips through our own importer. File formats 59% → 60%; import/export 84%/57% → 86%/58%; file exchange stays ×0.85; mainstream hours ~130–244 h → ~120–229 h; feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta (~155–280 h) and full parity (~265–523 h) unchanged at this rounding |
|
||||
| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding |
|
||||
| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h |
|
||||
| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Localization 35% → 36%, 32–55 h → 31–54 h; ready for real work unchanged (~53%); full parity ~268–528 h → ~267–527 h |
|
||||
|
|
|
|||
|
|
@ -107,11 +107,21 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
|
|||
.into_bytes(),
|
||||
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
|
||||
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
|
||||
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf)"))),
|
||||
"fig" => fig_bytes(doc)?,
|
||||
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig)"))),
|
||||
};
|
||||
write(out, &bytes)
|
||||
}
|
||||
|
||||
/// `doc` as a `.fig` file (experimental), printing what was left out.
|
||||
fn fig_bytes(doc: &Document) -> CliResult<Vec<u8>> {
|
||||
let (bytes, warnings) = figcraft_engine::cmd::file::fig_bytes(doc).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
for w in warnings {
|
||||
eprintln!("note: {w}");
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// A page as PDF: its top-level frames one page each, or (without frames) everything on one page.
|
||||
fn page_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> CliResult<Vec<u8>> {
|
||||
let r = if doc.top_frames(page).iter().any(|f| doc.node(*f).is_some_and(|n| n.visible)) {
|
||||
|
|
@ -198,10 +208,18 @@ pub struct Target {
|
|||
|
||||
pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
let doc = load(file)?;
|
||||
// `.fig`: the whole file.
|
||||
if t.format.as_deref().map_or(ext(out) == "fig", |f| f.eq_ignore_ascii_case("fig")) {
|
||||
write(out, &fig_bytes(&doc)?)?;
|
||||
println!("{out}");
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
let page = page_named(&doc, t.page.as_deref())?;
|
||||
let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print);
|
||||
let fmt = match t.format.as_deref() {
|
||||
Some(f) => image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf)")))?,
|
||||
Some(f) => {
|
||||
image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig)")))?
|
||||
}
|
||||
None => image_format(&ext(out)).unwrap_or(ExportFormat::Png),
|
||||
};
|
||||
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window
|
|||
|
||||
USAGE:
|
||||
figcraft-cli info <file> [--json]
|
||||
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME]
|
||||
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME]
|
||||
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]
|
||||
figcraft-cli convert <in> <out>
|
||||
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
|
||||
|
|
@ -31,7 +31,8 @@ USAGE:
|
|||
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,
|
||||
is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
|
||||
not open every file); 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.
|
||||
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"file.export",
|
||||
"Export",
|
||||
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}",
|
||||
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp, or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}",
|
||||
false,
|
||||
export
|
||||
),
|
||||
|
|
@ -429,7 +429,11 @@ pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
|
|||
pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
let tmp = format!("{path}.tmp-figcraft");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{tmp}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
std::fs::rename(&tmp, path).map_err(|e| {
|
||||
// Leave nothing behind when the file can't take its place.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EngineError::Other(format!("{path}: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
|
@ -806,6 +810,13 @@ pub fn copy_png(doc: &Document, ids: &[NodeId]) -> Option<figcraft_render::Pixma
|
|||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.export";
|
||||
let fig = match string(p, "format") {
|
||||
Some(f) => f.eq_ignore_ascii_case("fig"),
|
||||
None => string(p, "path").is_some_and(|pp| ext(&pp) == "fig"),
|
||||
};
|
||||
if fig {
|
||||
return export_fig(s, p);
|
||||
}
|
||||
let doc = s.doc()?;
|
||||
let mut ids = targets(s, p);
|
||||
if ids.is_empty() {
|
||||
|
|
@ -832,6 +843,25 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The whole document as a `.fig` file (experimental: only round trips through our own importer
|
||||
/// are verified), with the warnings about what was left out.
|
||||
fn export_fig(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = s.doc()?;
|
||||
let (bytes, warnings) = fig_bytes(doc)?;
|
||||
match string(p, "path") {
|
||||
Some(path) => {
|
||||
write(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "format": "fig", "warnings": warnings}))
|
||||
}
|
||||
None => Ok(json!({"format": "fig", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "warnings": warnings})),
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc` as a `.fig` file (with a thumbnail) and the warnings about what was left out.
|
||||
pub fn fig_bytes(doc: &Document) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
figcraft_format::fig::export(doc, thumbnail(doc).as_deref()).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// The scale that gives `ids` (their export area together) `setting`'s export size.
|
||||
pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 {
|
||||
export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height()))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
//! `.fig` export through `file.export {format: "fig"}`: files written by the command open again
|
||||
//! with the same layers, linked instances and auto layout. The export's own round-trip tests
|
||||
//! (generated documents, fuzzing) are in `figcraft_format::fig`; the local check here runs only
|
||||
//! when `FIGCRAFT_FIG_SAMPLES` names a folder of `.fig` files.
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn id(v: &Value) -> u64 {
|
||||
v["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-fig-export-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
/// The layers depth-first: kind, name, position, size, text and fill count, and whether an
|
||||
/// instance's layers are linked to its main's.
|
||||
fn outline(doc: &Document) -> Vec<String> {
|
||||
doc.pages
|
||||
.iter()
|
||||
.flat_map(|p| doc.subtree(*p))
|
||||
.filter_map(|i| doc.node(i))
|
||||
.map(|n| {
|
||||
let t = n.transform.translation();
|
||||
let text = n.kind.text().map(|t| t.characters.clone()).unwrap_or_default();
|
||||
let inside = doc.ancestors(n.id).iter().any(|a| matches!(doc.node(*a).map(|x| &x.kind), Some(NodeKind::Instance { .. })));
|
||||
format!(
|
||||
"{} {:?} ({:.1}, {:.1}) {:.1}×{:.1} {text:?} fills {} linked {} layout {:?}",
|
||||
n.kind.type_name(),
|
||||
n.name,
|
||||
t.x,
|
||||
t.y,
|
||||
n.width,
|
||||
n.height,
|
||||
n.fills.len(),
|
||||
!inside || n.source.is_some(),
|
||||
n.layout.as_ref().map(|l| (l.mode, l.gap, l.padding))
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A small file: a frame with auto layout holding text and a rectangle, a component with a text
|
||||
/// property and an instance of it setting the property, and a click from the frame to the
|
||||
/// instance.
|
||||
fn build(s: &mut Session) {
|
||||
let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap());
|
||||
s.execute("text.create", &json!({"text": "Hello 😀", "x": 10, "y": 10, "parent": frame})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 40, "width": 50, "height": 20, "parent": frame})).unwrap();
|
||||
s.execute("layout.add", &json!({"ids": [frame], "direction": "vertical"})).unwrap();
|
||||
let card = id(&s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 120, "height": 60})).unwrap());
|
||||
let label = id(&s.execute("text.create", &json!({"text": "Label", "x": 10, "y": 10, "parent": card})).unwrap());
|
||||
let comp = id(&s.execute("component.create", &json!({"ids": [card]})).unwrap());
|
||||
s.execute("component.addProperty", &json!({"id": comp, "name": "Title", "type": "text", "default": "Label", "layer": label})).unwrap();
|
||||
let inst = id(&s.execute("instance.create", &json!({"component": comp, "x": 300, "y": 200})).unwrap());
|
||||
s.execute("instance.setProperties", &json!({"ids": [inst], "properties": {"Title": "Changed"}})).unwrap();
|
||||
s.execute("proto.interaction.add", &json!({"id": frame, "dest": inst})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_fig_files_open_with_the_same_layers() {
|
||||
let mut s = Session::new();
|
||||
build(&mut s);
|
||||
let path = temp("roundtrip.fig");
|
||||
let r = s.execute("file.export", &json!({"format": "fig", "path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["format"], "fig");
|
||||
assert!(r["warnings"].is_array());
|
||||
let before = outline(s.doc().unwrap());
|
||||
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(r["warnings"].is_array());
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(outline(doc), before);
|
||||
let inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap();
|
||||
assert!(inst.children.iter().all(|c| doc.node(*c).is_some_and(|c| c.source.is_some())));
|
||||
assert_eq!(doc.nodes().filter(|n| !n.interactions.is_empty()).count(), 1);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_export_without_a_path_returns_the_file() {
|
||||
let mut s = Session::new();
|
||||
build(&mut s);
|
||||
let r = s.execute("file.export", &json!({"format": "FIG"})).unwrap();
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap();
|
||||
assert!(figcraft_format::fig::is_fig(&bytes));
|
||||
let (doc, _) = crate::cmd::file::load_bytes(&bytes, "x.fig").unwrap();
|
||||
assert_eq!(outline(&doc), outline(s.doc().unwrap()));
|
||||
// A path ending in .fig picks the format.
|
||||
let path = temp("by-extension.fig");
|
||||
let r = s.execute("file.export", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["format"], "fig");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fig_export_survives_junk_params() {
|
||||
let junk = [
|
||||
json!({"format": "fig", "path": [5]}),
|
||||
json!({"format": "fig", "path": ""}),
|
||||
json!({"format": "fig", "path": "\u{0}/x.fig"}),
|
||||
json!({"format": "fig", "ids": ["x"], "scale": "NaN", "size": {"by": 1}}),
|
||||
json!({"format": ["fig"]}),
|
||||
json!({"path": "nowhere/../\u{0}.fig"}),
|
||||
];
|
||||
for j in &junk {
|
||||
let mut s = Session::new();
|
||||
build(&mut s);
|
||||
if let Err(EngineError::Internal { cmd, msg }) = s.execute("file.export", j) {
|
||||
panic!("{cmd} panicked on {j}: {msg}");
|
||||
}
|
||||
}
|
||||
// A failed write leaves no temporary file behind.
|
||||
assert!(!std::path::Path::new(".tmp-figcraft").exists());
|
||||
// An empty document exports too.
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("file.export", &json!({"format": "fig"})).is_ok());
|
||||
}
|
||||
|
||||
/// Local only (`FIGCRAFT_FIG_SAMPLES`): each sample opened, exported and opened again has the
|
||||
/// same layers, with its instances linked.
|
||||
#[test]
|
||||
fn local_samples_export_and_open_again() {
|
||||
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();
|
||||
s.execute("file.open", &json!({"path": p.to_string_lossy()})).unwrap();
|
||||
let before = outline(s.doc().unwrap());
|
||||
let out = temp(&format!("again-{}", p.file_name().unwrap().to_string_lossy()));
|
||||
s.execute("file.export", &json!({"format": "fig", "path": out.to_string_lossy()})).unwrap();
|
||||
s.execute("file.open", &json!({"path": out.to_string_lossy()})).unwrap();
|
||||
assert_eq!(outline(s.doc().unwrap()), before, "{}", p.display());
|
||||
let _ = std::fs::remove_file(&out);
|
||||
}
|
||||
}
|
||||
|
|
@ -1303,6 +1303,8 @@ mod export_behaviour_tests;
|
|||
#[cfg(test)]
|
||||
mod extended_chain_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_export_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_import_tests;
|
||||
#[cfg(test)]
|
||||
mod frame_guides_tests;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,7 @@
|
|||
//! 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.
|
||||
//! A generic decoder and encoder 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,
|
||||
//! and encoded back from one ([`encode`]).
|
||||
//!
|
||||
//! 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.
|
||||
|
|
@ -372,8 +373,8 @@ impl<'s> Decoder<'s, '_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// An encoder for the same format (test fixtures are built with it).
|
||||
#[cfg(test)]
|
||||
/// An encoder for the same format: the `.fig` export writes its schema and message with it (and
|
||||
/// test fixtures are built with it). Values are checked against the schema; it never panics.
|
||||
pub mod encode {
|
||||
use super::*;
|
||||
|
||||
|
|
@ -427,8 +428,9 @@ pub mod encode {
|
|||
}
|
||||
}
|
||||
|
||||
/// A NUL-terminated string (NULs inside are dropped: they would end it early).
|
||||
pub fn string(&mut self, s: &str) {
|
||||
self.0.extend_from_slice(s.as_bytes());
|
||||
self.0.extend(s.bytes().filter(|b| *b != 0));
|
||||
self.0.push(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -448,8 +450,8 @@ pub mod encode {
|
|||
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),
|
||||
Some(Type::Def(i)) => i32::try_from(i).unwrap_or(i32::MAX),
|
||||
Some(t) => !(BUILTINS.iter().position(|b| *b == t).unwrap_or(0) as i32),
|
||||
});
|
||||
w.byte(u8::from(f.array));
|
||||
w.var_uint(f.value);
|
||||
|
|
@ -459,66 +461,128 @@ pub mod encode {
|
|||
}
|
||||
|
||||
/// 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> {
|
||||
/// required, in order). Fails on what doesn't fit the schema: unknown fields or enum names,
|
||||
/// missing struct fields, wrong types, numbers out of range, non-finite floats.
|
||||
pub fn message(s: &Schema, def: usize, v: &Kv) -> Result<Vec<u8>, KiwiError> {
|
||||
let mut w = Writer::default();
|
||||
encode_def(s, def, v, &mut w);
|
||||
w.0
|
||||
encode_def(s, def, v, &mut w, 0)?;
|
||||
Ok(w.0)
|
||||
}
|
||||
|
||||
fn encode_def(s: &Schema, i: usize, v: &Kv, w: &mut Writer) {
|
||||
let d = &s.defs[i];
|
||||
fn encode_def(s: &Schema, i: usize, v: &Kv, w: &mut Writer, depth: usize) -> Result<(), KiwiError> {
|
||||
if depth > MAX_DEPTH {
|
||||
return err("encode: nested too deeply");
|
||||
}
|
||||
let d = s.defs.get(i).ok_or_else(|| KiwiError("encode: missing definition".into()))?;
|
||||
match d.kind {
|
||||
Kind::Enum => {
|
||||
let name = v.str().unwrap();
|
||||
w.var_uint(d.fields.iter().find(|f| f.name == name).unwrap().value);
|
||||
let value = match v {
|
||||
Kv::Uint(u) => u32::try_from(*u).ok(),
|
||||
_ => v.str().and_then(|name| d.fields.iter().find(|f| f.name == name)).map(|f| f.value),
|
||||
};
|
||||
w.var_uint(value.ok_or_else(|| KiwiError(format!("encode: {v:?} is not a value of {}", d.name)))?);
|
||||
}
|
||||
Kind::Struct => {
|
||||
for f in &d.fields {
|
||||
encode_field(s, f, v.get(&f.name).unwrap_or_else(|| panic!("struct field {}", f.name)), w);
|
||||
let fv = v.get(&f.name).ok_or_else(|| KiwiError(format!("encode: {} needs field {}", d.name, f.name)))?;
|
||||
encode_field(s, f, fv, w, depth)?;
|
||||
}
|
||||
}
|
||||
Kind::Message => {
|
||||
let Kv::Object(fields) = v else { panic!("not an object") };
|
||||
let Kv::Object(fields) = v else { return err(format!("encode: {} needs an object", d.name)) };
|
||||
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));
|
||||
let f = d.fields.iter().find(|f| f.name == *name).ok_or_else(|| KiwiError(format!("encode: {} has no field {name}", d.name)))?;
|
||||
w.var_uint(f.value);
|
||||
encode_field(s, f, fv, w);
|
||||
encode_field(s, f, fv, w, depth)?;
|
||||
}
|
||||
w.var_uint(0);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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_field(s: &Schema, f: &Field, v: &Kv, w: &mut Writer, depth: usize) -> Result<(), KiwiError> {
|
||||
let ty = f.ty.ok_or_else(|| KiwiError("encode: untyped field".into()))?;
|
||||
if !f.array {
|
||||
return encode_value(s, ty, v, w, depth);
|
||||
}
|
||||
let len = |n: usize| u32::try_from(n).map_err(|_| KiwiError(format!("encode: {} is too long", f.name)));
|
||||
if let (Type::Byte, Kv::Bytes(b)) = (ty, v) {
|
||||
w.var_uint(len(b.len())?);
|
||||
w.0.extend_from_slice(b);
|
||||
return Ok(());
|
||||
}
|
||||
let Kv::Array(items) = v else { return err(format!("encode: {} needs a list", f.name)) };
|
||||
w.var_uint(len(items.len())?);
|
||||
for it in items {
|
||||
encode_value(s, ty, it, w, depth)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An integer value (whole finite floats count).
|
||||
fn int(v: &Kv) -> Option<i128> {
|
||||
match v {
|
||||
Kv::Int(i) => Some(i128::from(*i)),
|
||||
Kv::Uint(u) => Some(i128::from(*u)),
|
||||
Kv::Bool(b) => Some(i128::from(*b)),
|
||||
Kv::Float(f) if f.is_finite() && f.fract() == 0.0 => Some(*f as i128),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_value(s: &Schema, ty: Type, v: &Kv, w: &mut Writer) {
|
||||
fn encode_value(s: &Schema, ty: Type, v: &Kv, w: &mut Writer, depth: usize) -> Result<(), KiwiError> {
|
||||
let bad = || KiwiError(format!("encode: {v:?} is not a {ty:?}"));
|
||||
let ranged = |lo: i128, hi: i128| int(v).filter(|i| (lo..=hi).contains(i)).ok_or_else(bad);
|
||||
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),
|
||||
Type::Bool => w.byte(u8::from(v.bool().ok_or_else(bad)?)),
|
||||
Type::Byte => w.byte(ranged(0, 255)? as u8),
|
||||
Type::Int => w.var_int(ranged(i32::MIN.into(), i32::MAX.into())? as i32),
|
||||
Type::Uint => w.var_uint(ranged(0, u32::MAX.into())? as u32),
|
||||
Type::Float => w.var_float(v.f64().map(|x| x as f32).filter(|x| x.is_finite()).ok_or_else(bad)?),
|
||||
Type::Str => w.string(v.str().ok_or_else(bad)?),
|
||||
Type::Int64 => w.var_int64(ranged(i64::MIN.into(), i64::MAX.into())? as i64),
|
||||
Type::Uint64 => w.var_uint64(ranged(0, u64::MAX.into())? as u64),
|
||||
Type::Def(i) => encode_def(s, i, v, w, depth + 1)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in type names, in [`BUILTINS`] order.
|
||||
const BUILTIN_NAMES: [&str; 8] = ["bool", "byte", "int", "uint", "float", "string", "int64", "uint64"];
|
||||
|
||||
impl Schema {
|
||||
/// A schema from a compact text form, one definition per line: `enum Name A B …` (values from
|
||||
/// 0), `struct Name type field, type field[] …` or `message Name …` (field ids from 1), the
|
||||
/// types being built-in names or other definitions'.
|
||||
pub fn parse(text: &str) -> Result<Schema, KiwiError> {
|
||||
let lines: Vec<Vec<&str>> = text.lines().map(|l| l.split_whitespace().collect::<Vec<_>>()).filter(|l| !l.is_empty()).collect();
|
||||
let index = |name: &str| lines.iter().position(|l| l.get(1) == Some(&name));
|
||||
let mut defs = Vec::with_capacity(lines.len());
|
||||
for l in &lines {
|
||||
let (Some(kind), Some(name)) = (l.first(), l.get(1)) else { return err("schema text: a definition needs a kind and a name") };
|
||||
let rest = l.get(2..).unwrap_or_default();
|
||||
let (kind, fields) = match *kind {
|
||||
"enum" => (Kind::Enum, rest.iter().zip(0..).map(|(n, i)| Field { name: n.to_string(), ty: None, array: false, value: i }).collect()),
|
||||
"struct" | "message" => {
|
||||
let mut fields = Vec::new();
|
||||
for (f, id) in rest.join(" ").split(',').zip(1..) {
|
||||
let Some((t, n)) = f.trim().split_once(' ') else { return err(format!("schema text: bad field `{f}` in {name}")) };
|
||||
let base = t.trim_end_matches("[]");
|
||||
let ty = match BUILTIN_NAMES.iter().position(|b| *b == base) {
|
||||
Some(i) => BUILTINS.get(i).copied(),
|
||||
None => index(base).map(Type::Def),
|
||||
}
|
||||
.ok_or_else(|| KiwiError(format!("schema text: unknown type {base}")))?;
|
||||
fields.push(Field { name: n.trim().to_string(), ty: Some(ty), array: t.ends_with("[]"), value: id });
|
||||
}
|
||||
(if *kind == "struct" { Kind::Struct } else { Kind::Message }, fields)
|
||||
}
|
||||
k => return err(format!("schema text: unknown kind {k}")),
|
||||
};
|
||||
defs.push(Def { name: name.to_string(), kind, fields });
|
||||
}
|
||||
Ok(Schema { defs })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec<String>) {
|
|||
if !m.components.is_empty() {
|
||||
out["components"] = Value::Object(std::mem::take(&mut m.components));
|
||||
}
|
||||
if !m.sets.is_empty() {
|
||||
out["componentSets"] = Value::Object(std::mem::take(&mut m.sets));
|
||||
}
|
||||
if let Some(vars) = m.variables() {
|
||||
out["meta"] = vars;
|
||||
}
|
||||
|
|
@ -159,7 +162,9 @@ struct Mapper<'a> {
|
|||
/// Component property definitions: id → (REST name "Name#id", type).
|
||||
prop_defs: BTreeMap<String, (String, &'a str)>,
|
||||
styles: Obj,
|
||||
/// Descriptions of components and component sets (REST `components` / `componentSets`).
|
||||
components: Obj,
|
||||
sets: Obj,
|
||||
warnings: Vec<String>,
|
||||
count: usize,
|
||||
}
|
||||
|
|
@ -194,7 +199,7 @@ impl<'a> Mapper<'a> {
|
|||
}
|
||||
let blobs =
|
||||
msg.get("blobs").map(Kv::list).unwrap_or_default().iter().map(|b| b.get("bytes").and_then(Kv::bytes).unwrap_or_default()).collect();
|
||||
Self { nodes, kids, blobs, prop_defs, styles: Obj::new(), components: Obj::new(), warnings: Vec::new(), count: 0 }
|
||||
Self { nodes, kids, blobs, prop_defs, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 }
|
||||
}
|
||||
|
||||
fn warn(&mut self, w: impl Into<String>) {
|
||||
|
|
@ -307,6 +312,18 @@ impl<'a> Mapper<'a> {
|
|||
match ty {
|
||||
"TEXT" => self.text(&mut o, &view),
|
||||
"VECTOR" | "STAR" | "REGULAR_POLYGON" => self.geometry(&mut o, &view),
|
||||
"ELLIPSE" => {
|
||||
if let Some(a) = view.get("arcData") {
|
||||
let arc: Obj =
|
||||
["startingAngle", "endingAngle", "innerRadius"].iter().filter_map(|k| Some((k.to_string(), json!(num(a, k)?)))).collect();
|
||||
o.insert("arcData".into(), Value::Object(arc));
|
||||
}
|
||||
}
|
||||
"BOOLEAN_OPERATION" => {
|
||||
if let Some(op) = view.str("booleanOperation") {
|
||||
o.insert("booleanOperation".into(), json!(if op == "XOR" { "EXCLUDE" } else { op }));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if own == "CONNECTOR" {
|
||||
|
|
@ -791,7 +808,8 @@ impl<'a> Mapper<'a> {
|
|||
o.insert("componentPropertyDefinitions".into(), Value::Object(defs));
|
||||
}
|
||||
if let Some(d) = v.str("symbolDescription").or_else(|| v.str("description")).filter(|d| !d.is_empty()) {
|
||||
self.components.insert(id.to_string(), json!({"description": d}));
|
||||
let into = if o.get("type").and_then(Value::as_str) == Some("COMPONENT_SET") { &mut self.sets } else { &mut self.components };
|
||||
into.insert(id.to_string(), json!({"description": d}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -954,7 +972,8 @@ impl<'a> Mapper<'a> {
|
|||
};
|
||||
let mut trigger = json!({"type": ty});
|
||||
if let Some(t) = ev.and_then(|e| num(e, "transitionTimeout")) {
|
||||
trigger["timeout"] = json!(t * 1000.0);
|
||||
// Seconds as 32-bit floats: whole milliseconds.
|
||||
trigger["timeout"] = json!((t * 1000.0).round());
|
||||
}
|
||||
if let Some(k) = ev.and_then(|e| e.get("keyTrigger")) {
|
||||
let codes: Vec<f64> = k.get("keyCodes").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).collect();
|
||||
|
|
@ -1577,7 +1596,7 @@ fn transition(a: &Kv) -> Option<Value> {
|
|||
if let (true, [x1, y1, x2, y2, ..]) = (easing == "CUSTOM_CUBIC_BEZIER", f.as_slice()) {
|
||||
e["easingFunctionCubicBezier"] = json!({"x1": x1, "y1": y1, "x2": x2, "y2": y2});
|
||||
}
|
||||
let mut t = json!({"type": kind, "duration": num(a, "transitionDuration").unwrap_or(0.3) * 1000.0, "easing": e});
|
||||
let mut t = json!({"type": kind, "duration": (num(a, "transitionDuration").unwrap_or(0.3) * 1000.0).round(), "easing": e});
|
||||
if let Some(d) = dir {
|
||||
t["direction"] = json!(d);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@
|
|||
//! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped
|
||||
//! with a warning; malformed input never panics.
|
||||
|
||||
mod export;
|
||||
#[cfg(test)]
|
||||
mod export_tests;
|
||||
pub mod kiwi;
|
||||
mod map;
|
||||
mod network;
|
||||
|
|
@ -22,6 +25,8 @@ use std::io::Read;
|
|||
|
||||
use figcraft_doc::Document;
|
||||
|
||||
pub use export::export;
|
||||
|
||||
use crate::FormatError;
|
||||
use kiwi::Schema;
|
||||
|
||||
|
|
|
|||
|
|
@ -96,8 +96,7 @@ pub(super) fn decode(b: &[u8]) -> Option<Network> {
|
|||
Some(net)
|
||||
}
|
||||
|
||||
/// The blob of `net` (for tests: fixtures are written with it).
|
||||
#[cfg(test)]
|
||||
/// The blob of `net` (the export writes it; so do test fixtures). Callers pass finite values.
|
||||
pub(super) fn encode(net: &Network) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let u = |v: u32, out: &mut Vec<u8>| out.extend_from_slice(&v.to_le_bytes());
|
||||
|
|
|
|||
|
|
@ -384,7 +384,7 @@ fn mangled_tail_files_never_panic() {
|
|||
// Mangled messages decode and map without panicking.
|
||||
let sc = schema();
|
||||
let root = sc.find("Message").unwrap();
|
||||
let msg = encode::message(&sc, root, &tail_message());
|
||||
let msg = encode::message(&sc, root, &tail_message()).unwrap();
|
||||
for _ in 0..2000 {
|
||||
let mut m = msg.clone();
|
||||
for _ in 0..1 + r.next() % 4 {
|
||||
|
|
|
|||
|
|
@ -76,47 +76,7 @@ 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 }
|
||||
Schema::parse(SCHEMA).unwrap()
|
||||
}
|
||||
|
||||
fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||||
|
|
@ -188,7 +148,7 @@ fn triangle_blob() -> Vec<u8> {
|
|||
}
|
||||
|
||||
/// A 2×1 PNG.
|
||||
fn png() -> Vec<u8> {
|
||||
pub(super) 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();
|
||||
|
|
@ -399,7 +359,7 @@ fn canvas(schema: &Schema, msg: &Kv) -> Vec<u8> {
|
|||
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 msg_chunk = ruzstd::encoding::compress_to_vec(&encode::message(schema, root, msg).unwrap()[..], 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] {
|
||||
|
|
@ -440,7 +400,7 @@ fn kiwi_round_trip() {
|
|||
}
|
||||
let msg = message();
|
||||
let root = sc.find("Message").unwrap();
|
||||
assert_eq!(back.decode_message(root, &encode::message(&sc, root, &msg)).unwrap(), msg);
|
||||
assert_eq!(back.decode_message(root, &encode::message(&sc, root, &msg).unwrap()).unwrap(), msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -619,7 +579,7 @@ 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 msg = encode::message(&sc, root, &message()).unwrap();
|
||||
let mut r = Lcg(11);
|
||||
for cut in 0..schema_bytes.len() {
|
||||
let _ = Schema::decode(&schema_bytes[..cut]);
|
||||
|
|
@ -709,7 +669,7 @@ fn instances_inside_their_own_main_stop() {
|
|||
mod tail;
|
||||
|
||||
/// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset).
|
||||
fn samples() -> Vec<std::path::PathBuf> {
|
||||
pub(super) 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()
|
||||
|
|
|
|||
|
|
@ -316,12 +316,17 @@ fn parse_json(text: &str) -> Result<(Document, Vec<String>, ImageIndex), FormatE
|
|||
Ok((doc, issues, header.images))
|
||||
}
|
||||
|
||||
fn io_err(e: impl std::fmt::Display) -> FormatError {
|
||||
pub(crate) fn io_err(e: impl std::fmt::Display) -> FormatError {
|
||||
FormatError::Io(e.to_string())
|
||||
}
|
||||
|
||||
/// Add one member to the archive.
|
||||
fn put<W: Write + Seek>(z: &mut zip::ZipWriter<W>, name: &str, opts: zip::write::SimpleFileOptions, bytes: &[u8]) -> Result<(), FormatError> {
|
||||
pub(crate) fn put<W: Write + Seek>(
|
||||
z: &mut zip::ZipWriter<W>,
|
||||
name: &str,
|
||||
opts: zip::write::SimpleFileOptions,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), FormatError> {
|
||||
z.start_file(name, opts).map_err(io_err)?;
|
||||
z.write_all(bytes).map_err(io_err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ mod conditional_tests;
|
|||
mod effects_tests;
|
||||
#[cfg(test)]
|
||||
mod gamepad_tests;
|
||||
mod props;
|
||||
mod proto;
|
||||
pub(crate) mod props;
|
||||
pub(crate) mod proto;
|
||||
#[cfg(test)]
|
||||
mod proto_variable_tests;
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ pub(super) fn transform(v: &Value) -> Option<Affine> {
|
|||
|
||||
// ---- enums ----
|
||||
|
||||
pub(super) fn blend_mode(s: &str) -> Option<BlendMode> {
|
||||
pub(crate) fn blend_mode(s: &str) -> Option<BlendMode> {
|
||||
Some(match s {
|
||||
"PASS_THROUGH" => BlendMode::PassThrough,
|
||||
"NORMAL" => BlendMode::Normal,
|
||||
|
|
|
|||
|
|
@ -516,7 +516,7 @@ fn easing_type(ty: Option<&str>, e: Option<&Value>) -> Easing {
|
|||
}
|
||||
}
|
||||
|
||||
fn overlay_position(s: &str) -> Option<OverlayPosition> {
|
||||
pub(crate) fn overlay_position(s: &str) -> Option<OverlayPosition> {
|
||||
Some(match s {
|
||||
"CENTER" => OverlayPosition::Center,
|
||||
"TOP_LEFT" => OverlayPosition::TopLeft,
|
||||
|
|
@ -537,7 +537,7 @@ fn modifier(code: f64) -> Option<i64> {
|
|||
}
|
||||
|
||||
/// Our key name for a keyboard key code.
|
||||
fn key_name(code: f64) -> Option<String> {
|
||||
pub(crate) fn key_name(code: f64) -> Option<String> {
|
||||
if !(code.is_finite() && (0.0..=255.0).contains(&code)) {
|
||||
return None;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,11 +196,11 @@ pub fn tool_definitions() -> Vec<Value> {
|
|||
tool(
|
||||
"export",
|
||||
"Export",
|
||||
"Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp or pdf at `scale` (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, other formats as base64 `data`.",
|
||||
"Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp or pdf at `scale`, or the whole file as `fig` (experimental: written from public format knowledge, with `warnings` for what was left out; other apps may not open every file) (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, other formats as base64 `data`.",
|
||||
obj(
|
||||
json!({
|
||||
"ids": ids_schema("Layers to export (several are composited into one image)"),
|
||||
"format": {"type": "string", "enum": ["png", "jpg", "svg", "webp", "pdf"], "description": "Format (default png, or from the path's extension)"},
|
||||
"format": {"type": "string", "enum": ["png", "jpg", "svg", "webp", "pdf", "fig"], "description": "Format (default png, or from the path's extension)"},
|
||||
"scale": num("Pixel scale for raster formats (default 1, max 64)"),
|
||||
"size": string("Instead of `scale`: `2x`, or a width or height in pixels like `512w` / `512h`"),
|
||||
"path": string("Output file path"),
|
||||
|
|
@ -652,8 +652,8 @@ fn export(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Str
|
|||
}
|
||||
let format = s_arg(a, "format")?.map(str::to_ascii_lowercase);
|
||||
if let Some(f) = &format {
|
||||
if !["png", "jpg", "jpeg", "svg", "webp", "pdf"].contains(&f.as_str()) {
|
||||
return Err(format!("unknown format `{f}` (png|jpg|svg|webp|pdf)"));
|
||||
if !["png", "jpg", "jpeg", "svg", "webp", "pdf", "fig"].contains(&f.as_str()) {
|
||||
return Err(format!("unknown format `{f}` (png|jpg|svg|webp|pdf|fig)"));
|
||||
}
|
||||
params.insert("format".into(), json!(f));
|
||||
}
|
||||
|
|
@ -690,7 +690,11 @@ fn export(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Str
|
|||
"webp" => Ok(ToolResult::image(&bytes, "image/webp", &json!({"format": "webp", "bytes": bytes.len()}))),
|
||||
"svg" => Ok(ToolResult::text(String::from_utf8_lossy(&bytes).into_owned())),
|
||||
other => {
|
||||
Ok(ToolResult::json(&json!({"format": other, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})))
|
||||
let mut out = json!({"format": other, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)});
|
||||
if let Some(w) = r.get("warnings") {
|
||||
out["warnings"] = w.clone();
|
||||
}
|
||||
Ok(ToolResult::json(&out))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ name = "English"
|
|||
"(no longer published)" = "(no longer published)"
|
||||
"({n} warnings: some entries were skipped)" = { one = "({n} warning: some entries were skipped)", other = "({n} warnings: some entries were skipped)" }
|
||||
"+ Create variable" = "+ Create variable"
|
||||
".fig (experimental)" = ".fig (experimental)"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "A collection of this file with these modes and variables: override their values per mode and add variables of its own"
|
||||
|
|
@ -425,6 +426,7 @@ name = "English"
|
|||
"Export {n} layers" = { one = "Export {n} layer", other = "Export {n} layers" }
|
||||
"Exported {file}" = "Exported {file}"
|
||||
"Exported {name}" = "Exported {name}"
|
||||
"Exported {name}; {n} kinds of content were left out" = { one = "Exported {name}; {n} kind of content was left out", other = "Exported {name}; {n} kinds of content were left out" }
|
||||
"Exported {n} files" = { one = "Exported {n} file", other = "Exported {n} files" }
|
||||
"Export…" = "Export…"
|
||||
"Expose properties to instances" = "Expose properties to instances"
|
||||
|
|
@ -824,6 +826,7 @@ name = "English"
|
|||
"Options" = "Options"
|
||||
"Or keep the file on a FigCraft server and share links to edit or to view it." = "Or keep the file on a FigCraft server and share links to edit or to view it."
|
||||
"Orange" = "Orange"
|
||||
"Other apps may not open every .fig file exported here." = "Other apps may not open every .fig file exported here."
|
||||
"Outline stroke" = "Outline stroke"
|
||||
"Outline text" = "Outline text"
|
||||
"Outlines" = "Outlines"
|
||||
|
|
@ -1190,6 +1193,7 @@ name = "English"
|
|||
"The file now holds another library (\"{name}\")" = "The file now holds another library (\"{name}\")"
|
||||
"The link carries the session's secret: share it only with people you invite." = "The link carries the session's secret: share it only with people you invite."
|
||||
"The selection uses several text styles: click to apply one" = "The selection uses several text styles: click to apply one"
|
||||
"The whole file" = "The whole file"
|
||||
"Theme" = "Theme"
|
||||
"There's no destination for that action" = "There's no destination for that action"
|
||||
"Thickness" = "Thickness"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ status = "draft — needs native review"
|
|||
"(no longer published)" = "(ya no publicado)"
|
||||
"({n} warnings: some entries were skipped)" = { one = "({n} advertencia: se omitieron algunas entradas)", other = "({n} advertencias: se omitieron algunas entradas)" }
|
||||
"+ Create variable" = "+ Crear variable"
|
||||
".fig (experimental)" = ".fig (experimental)"
|
||||
"1…9, 0" = "1…9, 0"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{bin_added} / −{bin_deleted} recursos binarios, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Una colección de este archivo con estos modos y variables: modifica sus valores por modo y añade variables propias"
|
||||
|
|
@ -426,6 +427,7 @@ status = "draft — needs native review"
|
|||
"Export {n} layers" = { one = "Exportar {n} capa", other = "Exportar {n} capas" }
|
||||
"Exported {file}" = "Exportado: {file}"
|
||||
"Exported {name}" = "{name} exportado"
|
||||
"Exported {name}; {n} kinds of content were left out" = { one = "{name} exportado; se omitió {n} tipo de contenido", other = "{name} exportado; se omitieron {n} tipos de contenido" }
|
||||
"Exported {n} files" = { one = "{n} archivo exportado", other = "{n} archivos exportados" }
|
||||
"Export…" = "Exportar…"
|
||||
"Expose properties to instances" = "Exponer propiedades a las instancias"
|
||||
|
|
@ -825,6 +827,7 @@ status = "draft — needs native review"
|
|||
"Options" = "Opciones"
|
||||
"Or keep the file on a FigCraft server and share links to edit or to view it." = "O guarda el archivo en un servidor FigCraft y comparte enlaces para editarlo o verlo."
|
||||
"Orange" = "Naranja"
|
||||
"Other apps may not open every .fig file exported here." = "Puede que otras aplicaciones no abran todos los archivos .fig exportados aquí."
|
||||
"Outline stroke" = "Contornear trazo"
|
||||
"Outline text" = "Texto como contornos"
|
||||
"Outlines" = "Contornos"
|
||||
|
|
@ -1191,6 +1194,7 @@ status = "draft — needs native review"
|
|||
"The file now holds another library (\"{name}\")" = "El archivo ahora contiene otra biblioteca (\"{name}\")"
|
||||
"The link carries the session's secret: share it only with people you invite." = "El enlace lleva la clave secreta de la sesión: compártelo solo con quienes invites."
|
||||
"The selection uses several text styles: click to apply one" = "La selección usa varios estilos de texto: haz clic para aplicar uno"
|
||||
"The whole file" = "El archivo completo"
|
||||
"Theme" = "Tema"
|
||||
"There's no destination for that action" = "No hay destino para esa acción"
|
||||
"Thickness" = "Grosor"
|
||||
|
|
|
|||
|
|
@ -444,12 +444,27 @@ fn color_profile_dialog(app: &mut FigApp, ctx: &Context) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Save the whole file as `.fig`; the toast says how much was left out.
|
||||
fn save_fig(app: &mut FigApp, name: &str) {
|
||||
let Some(p) = app.services.pick_save(tr("Export"), &format!("{name}.fig"), &["fig"]) else { return };
|
||||
let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
|
||||
if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) {
|
||||
let left_out = r["warnings"].as_array().map_or(0, Vec::len);
|
||||
app.toast(match left_out {
|
||||
0 => trf("Exported {name}", &[("name", &file)]),
|
||||
n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// What the Export dialog was asked to do.
|
||||
enum ExportChoice {
|
||||
/// Every layer with export settings, with those settings.
|
||||
Layers,
|
||||
/// The layers together as one file.
|
||||
Once(ExportSetting),
|
||||
/// The whole file as `.fig` (experimental).
|
||||
Fig,
|
||||
}
|
||||
|
||||
/// The Export dialog: the layers' own export settings when they have some, and one-off presets for
|
||||
|
|
@ -484,6 +499,20 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) {
|
|||
let caption = |ui: &mut egui::Ui, s: &str| {
|
||||
ui.label(RichText::new(s).font(regular(11.0)).color(t.text_secondary));
|
||||
};
|
||||
let divider = |ui: &mut egui::Ui| {
|
||||
ui.add_space(10.0);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(320.0, 1.0), Sense::hover());
|
||||
ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border));
|
||||
ui.add_space(10.0);
|
||||
};
|
||||
let row = |ui: &mut egui::Ui, label: &str| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(320.0, 30.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
ui.painter().text(r.left_center() + vec2(10.0, 0.0), Align2::LEFT_CENTER, label, regular(12.0), t.text);
|
||||
resp.clicked()
|
||||
};
|
||||
if !jobs.is_empty() {
|
||||
caption(ui, tr("With the layers' export settings"));
|
||||
const SHOWN: usize = 6;
|
||||
|
|
@ -499,21 +528,13 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) {
|
|||
if crate::widgets::button(ui, &label, true, true).clicked() {
|
||||
choice = Some(ExportChoice::Layers);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(320.0, 1.0), Sense::hover());
|
||||
ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border));
|
||||
ui.add_space(10.0);
|
||||
divider(ui);
|
||||
}
|
||||
caption(ui, &trn("{n} layers as one file", ids.len(), &[]));
|
||||
ui.add_space(4.0);
|
||||
for p in &presets {
|
||||
let label = if p.format.is_raster() { format!("{} @{}", p.format.label(), p.size.label()) } else { p.format.label().to_string() };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(320.0, 30.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
ui.painter().text(r.left_center() + vec2(10.0, 0.0), Align2::LEFT_CENTER, label, regular(12.0), t.text);
|
||||
if resp.clicked() {
|
||||
if row(ui, &label) {
|
||||
choice = Some(ExportChoice::Once(p.clone()));
|
||||
}
|
||||
}
|
||||
|
|
@ -523,12 +544,20 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) {
|
|||
if let Some(next) = crate::export_panel::print_options(ui, egui::Id::new("export_dialog_print"), &print) {
|
||||
app.ui.pdf_print = next;
|
||||
}
|
||||
divider(ui);
|
||||
caption(ui, tr("The whole file"));
|
||||
ui.add_space(4.0);
|
||||
if row(ui, tr(".fig (experimental)")) {
|
||||
choice = Some(ExportChoice::Fig);
|
||||
}
|
||||
caption(ui, tr("Other apps may not open every .fig file exported here."));
|
||||
});
|
||||
let close = choice.is_some() || !open;
|
||||
match choice {
|
||||
Some(ExportChoice::Layers) => export_layers(app, &with_settings, false),
|
||||
Some(ExportChoice::Once(s)) if !ids.is_empty() => save_one(app, &ids, &s, &suggested_name(&doc, &ids, &s, "Export")),
|
||||
Some(ExportChoice::Once(_)) => app.toast(tr("Nothing to export")),
|
||||
Some(ExportChoice::Fig) => save_fig(app, &doc.name),
|
||||
None => {}
|
||||
}
|
||||
if close {
|
||||
|
|
|
|||
|
|
@ -31,12 +31,16 @@ page 1 "Screens": 380 nodes, 4 top-level
|
|||
…
|
||||
```
|
||||
|
||||
### `export <file> -o <out> [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]`
|
||||
### `export <file> -o <out> [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]`
|
||||
|
||||
- `<out>` is a directory: one file per top-level frame of the page (default the first page), named
|
||||
after the frame (`Home.png`, `Settings.png`…); with no frames, one file per top-level layer.
|
||||
- `<out>` has an image extension: one file with the given `--node`, or the whole page composited.
|
||||
- `--node` takes a layer id (`12` or `0:12`) or a layer name.
|
||||
- `--format fig` (or an `<out>` ending in `.fig`) writes the whole file as `.fig`, experimental:
|
||||
written from public format notes and checked only by opening it again in FigCraft; other apps
|
||||
may not open every file. What was left out (shared style links, variables, comments, Dev Mode
|
||||
data…) is printed as `note:` lines.
|
||||
- The format comes from `--format`, else `<out>`'s extension, else png. `--scale` (default 1, max 64)
|
||||
applies to raster formats.
|
||||
- A `.pdf` `<out>` without `--node` holds the page's top-level frames, one PDF page each in reading
|
||||
|
|
@ -56,6 +60,7 @@ figcraft-cli export app.figcraft -o exports/ --scale 2
|
|||
figcraft-cli export app.figcraft -o hero.svg --node "Hero"
|
||||
figcraft-cli export app.figcraft -o screens.pdf --page "Screens"
|
||||
figcraft-cli export app.figcraft -o poster.pdf --node "Poster" --bleed 3mm --crop-marks --cmyk
|
||||
figcraft-cli export app.figcraft -o handoff.fig
|
||||
```
|
||||
|
||||
### `convert <in> <out>`
|
||||
|
|
@ -64,7 +69,7 @@ By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.f
|
|||
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
|
||||
frames, one page each).
|
||||
frames, one page each), `.fig` (the whole file, experimental, as `export --format fig`).
|
||||
|
||||
### `render <file> -o <out.png> [--scale N] [--page NAME]`
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (experimental `.fig` export, verified only through our own importer; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
|
||||
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
|
||||
help center (image and video types, export formats), and FigCraft's source.
|
||||
|
||||
**Summary (estimated):** ~59% ready. The core interchange formats (SVG, PNG, JPG, PDF) are solid
|
||||
**Summary (estimated):** ~60% 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.
|
||||
files) and can be written, experimentally: only round trips through FigCraft's own importer are
|
||||
verified, and nothing yet shows that other apps open the files.
|
||||
|
||||
## Documents
|
||||
|
||||
| Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours |
|
||||
|---|---|---|---|---|---|
|
||||
| `.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 editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed); no real-world files yet | read tail 5–10, 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) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
|
||||
| `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — |
|
||||
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, 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`) | — |
|
||||
|
|
@ -46,7 +47,58 @@ files) but can't be written.
|
|||
| WebP | — | yes (ours) | | — |
|
||||
| Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — |
|
||||
| Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML and React | see [dev-mode-parity](dev-mode-parity.md) | — |
|
||||
| `.fig` ("Save local copy") | yes | no | | 15–30 |
|
||||
| `.fig` ("Save local copy") | yes | **experimental** | the whole file; see [Export to `.fig`](#export-to-fig-experimental). Verified only by round trips through our own importer | 5–15 (checks against real files) |
|
||||
|
||||
### Export to `.fig` (experimental)
|
||||
|
||||
`file.export {format: "fig"}` (or a `path` ending in `.fig`), File ▸ Export… ▸ .fig (experimental),
|
||||
`figcraft-cli export <file> -o <out>.fig` (and `convert`) and MCP's `export {format: "fig"}` write
|
||||
the whole document. The writer (`crates/format/src/fig/export.rs`) works from the same public format
|
||||
notes as the import and inverts its mapping: the zip holds `canvas.fig` (the fig-kiwi header, then a
|
||||
raw-deflate binary schema and a raw-deflate message), `meta.json`, `thumbnail.png` and
|
||||
`images/<hash>`. The schema written into the file describes only the fields written. The message
|
||||
lists one node change per layer, parents first, each with its GUID, its parent and a position
|
||||
string that orders it among its siblings; vector networks and outlines go in blobs, written once
|
||||
each.
|
||||
|
||||
- **Written:** pages (name, background); frames, groups, sections, rectangles, ellipses (arcs),
|
||||
polygons, stars, lines, vectors (editable network blobs with per-point corner radius, handle
|
||||
mirroring, caps and joins, plus the outline of their filled areas), text, boolean groups, slices;
|
||||
solid, gradient (handles as the paint transform) and image paints (the image bytes, scale mode,
|
||||
rotation, tile scale, crop); strokes (weight, align, join, caps, dashes, miter limit, per-side
|
||||
weights); shadows, layer and background blurs (progressive too), noise and texture; corners and
|
||||
smoothing, constraints, clip content, masks; auto layout (direction, wrap, gaps, padding,
|
||||
alignment, hug, strokes in layout, stacking order, grids with their tracks and cells) and how
|
||||
layers sit in it (fill, absolute, min/max sizes); layout grids; export settings; text style
|
||||
(font, size, line height, letter spacing, case, decoration, links), alignment, auto resize,
|
||||
truncation, paragraph spacing and indent, list types and styled runs with their fills (indexed
|
||||
by UTF-16 unit); components (description, boolean, text and instance swap properties and the
|
||||
layers bound to them), component sets, instances (their main, property values, and the fields
|
||||
their layers change from the main's, by id path, through nested instances and swaps); prototype
|
||||
interactions (clicks, hovers, presses, drags, mouse triggers, delays, keys with modifiers and
|
||||
controller buttons; navigate, overlays with their position, outside-click setting and
|
||||
background, swap, change to, scroll to, back, close, open link; transitions and easing, custom
|
||||
curves included), scrolling and fixed layers, flow starting points.
|
||||
- **Left out, with a warning each:** shared styles (layers keep the values), variables and their
|
||||
bindings, comments, Dev Mode annotations, statuses, resources and code links, plugin data, ruler
|
||||
guides, prototype device settings, slot properties and slot content, variable-width and brush
|
||||
strokes (written plain), outline and luminance masks (written as alpha masks), pattern fills,
|
||||
image adjustments, glass settings other than frost, springs other than Gentle (written as ease
|
||||
out), set-variable, set-variable-mode and conditional actions, scroll-to offsets, text decoration
|
||||
looks, variable font axes, text on a path, nested list levels, list spacing, hanging punctuation,
|
||||
leading trim, OpenType features, per-layer operations inside boolean groups, grid tracks of fixed
|
||||
or content size, different stroke ends on lines. Not written at all: stroke outlines, version
|
||||
history and branches.
|
||||
- **Verified only by round trips through our own importer:** 60 generated documents (every layer
|
||||
kind above, components, nested instances with overrides, a grid inside a component,
|
||||
interactions) are exported, read back and compared layer by layer: structure, geometry within
|
||||
32-bit float tolerance, paints, text and runs, auto layout, components and prototypes. The written
|
||||
file decodes with the generic kiwi decoder and lists every layer after its parent; the encoder
|
||||
agrees with the decoder on random messages and refuses values the schema can't hold; junk
|
||||
documents never panic (`crates/format/src/fig/export_tests.rs`,
|
||||
`crates/engine/src/fig_export_tests.rs`). Field and enum names follow the public notes; the enum
|
||||
numbers and the header version are not checked against anything, and no other app has opened
|
||||
these files.
|
||||
|
||||
## Fonts
|
||||
|
||||
|
|
@ -61,6 +113,7 @@ files) but can't be written.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder that mirrors the decoder and the schema of the fields written; what's left out is a warning. The import gained ellipse arcs, boolean operation types and component set descriptions, and reads durations as whole milliseconds. Verified only by round trips through our own importer. Summary ~59% → ~60%; `.fig` write 15–30 h → 5–15 h (checks against real files) |
|
||||
| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes `<text>`), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Summary ~55% → ~56%; SVG options 1–2 h → done |
|
||||
| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h |
|
||||
| 2026-10-11 | minor | Embedded image colour profiles: placed and opened images are converted once, when decoded, from their own colour space into the file's (sRGB or Display P3): ICC profiles in PNG (`iCCP`), JPG (APP2), WebP and TIFF, matrix/curve and table profiles in RGB or grey (moxcms, already built for the `image` crate), and a PNG's `sRGB`, `gAMA` and `cHRM` chunks; images without a profile, or with one that can't be used (CMYK, damaged, over 4 MB), are taken as sRGB as before. The original file is kept (PDF exports still embed it); pixel exports carry the converted pixels with the file's profile. The image fill's details show the source profile (Unknown — treated as sRGB when there's none) and `image.place` reports it as `imageProfile`. |
|
||||
|
|
|
|||
23
docs/gaps.md
23
docs/gaps.md
|
|
@ -1,6 +1,6 @@
|
|||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.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: best-effort import with editable vector networks and stand-ins for non-design layers landed; real-file checks, the small tail and export left (**beta blocker** until checked on real files, gap 3) | File format | 5–10 | [file-format-parity](file-format-parity.md) |
|
||||
| 1 | `.fig` files: best-effort import with editable vector networks and stand-ins for non-design layers landed, and an experimental export (checked only through our own importer); real-file checks and the small tail left (**beta blocker** until checked on real files, gap 3) | File format | 5–10 | [file-format-parity](file-format-parity.md) |
|
||||
| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [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) |
|
||||
|
|
@ -44,16 +44,28 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
- **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, 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`). Then (2026-10-11): vectors open as editable vector networks (per-point corner radius, handle mirroring, end caps; outlines when the network blob is missing or damaged), sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning, and set-variable, set-variable-mode and conditional prototype actions come across. `file.open`,
|
||||
`file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved
|
||||
over.
|
||||
- **Landed (2026-10-12):** an experimental export: `file.export {format: "fig"}`, File ▸ Export… ▸
|
||||
.fig (experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document
|
||||
with a kiwi encoder mirroring the decoder and a schema of the fields written: the layer tree,
|
||||
geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto
|
||||
layout, constraints, components, sets, instances with their overrides and property values, and
|
||||
prototype interactions; shared style links, variables, comments, Dev Mode data and the rest are
|
||||
left out with a warning ([file-format-parity](file-format-parity.md#export-to-fig-experimental)).
|
||||
The import gained ellipse arcs, boolean operation types and component set descriptions.
|
||||
- **Missing:** connector labels, elbowed or curved connectors without a saved path (drawn straight),
|
||||
table cells beyond their own layers, filled areas of a vector with their own fills, per-point stroke
|
||||
joins, variables bound to fields the editor can't bind, fixed or content-sized 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.
|
||||
local fonts. Nothing has been checked against real-world files yet (gap 3). The export is checked
|
||||
only against our own importer: whether other apps open its files is unknown.
|
||||
- **Evidence:** fixtures generated in code (`crates/format/src/fig/tests.rs` and `tail_tests.rs`: kiwi
|
||||
round trips, every mapped feature, vector network blobs, truncation, bit flips and junk never panic); `crates/engine/src/fig_import_tests.rs`.
|
||||
Export: 60 generated documents round trip through the importer layer by layer, written files
|
||||
decode with the generic decoder and list parents first, the encoder agrees with the decoder,
|
||||
junk documents never panic (`crates/format/src/fig/export_tests.rs`,
|
||||
`crates/engine/src/fig_export_tests.rs`).
|
||||
- **Impact:** existing work can be brought across, but only real files will show how much of it.
|
||||
- **Estimate:** 5–10 h for the read tail (real-file fixes, connector labels, table cells); export to `.fig` a further 15–30 h and lower priority.
|
||||
- **Estimate:** 5–10 h for the read tail (real-file fixes, connector labels, table cells); checking the export against real files a further 5–15 h (with gap 3).
|
||||
|
||||
### 2. Multiplayer and sharing are a preview, without accounts
|
||||
- **Missing:** accounts, teams and projects, per-person permissions, audio,
|
||||
|
|
@ -355,6 +367,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Gap 1: an experimental `.fig` export landed (the whole document, from the same public format notes as the import; checked only by round trips through our own importer); export hours 15–30 h → 5–15 h (checks against real files); gap 1's read tail (5–10 h) and the ranking unchanged |
|
||||
| 2026-10-12 | minor | Gap 4: keyboard shortcut parity suite per platform (129 documented rows: 126 bound, 1 moved, 2 missing; 48 undocumented bindings pinned) added to the landed list; estimate and ranking unchanged |
|
||||
| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Gap 5 31–54 h → 29–51 h, rank unchanged |
|
||||
| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Gap 5 32–55 h → 31–54 h, rank unchanged |
|
||||
|
|
|
|||
18
docs/mcp.md
18
docs/mcp.md
|
|
@ -196,7 +196,7 @@ Or in a project's `.mcp.json`:
|
|||
| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). |
|
||||
| `get_node {id, depth}` | One layer as JSON. |
|
||||
| `select {ids}` | Replace the selection (`[]` deselects). |
|
||||
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf; `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). 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?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
|
||||
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). 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?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
|
||||
| `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), `.fig` (best effort, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. |
|
||||
| `undo` / `redo` | Step through history. |
|
||||
|
|
@ -683,6 +683,22 @@ text, connectors, tables and similar layers come in as the closest frames, text
|
|||
vectors; set-variable, set-variable-mode and conditional actions come across; video fills are
|
||||
skipped. The result's `opened.warnings` list what was approximated or skipped.
|
||||
|
||||
### Exporting `.fig` files (experimental)
|
||||
|
||||
`export {format: "fig", path?}` (or `run_command` `file.export {format: "fig"}`, or any `path`
|
||||
ending in `.fig`) writes the whole document as a `.fig` file, from the same public format notes the
|
||||
import uses: the layer tree, geometry, paints with image bytes, strokes, effects, text with styled
|
||||
runs, vector networks, auto layout, constraints, components, sets, instances with their overrides
|
||||
and property values, and prototype interactions. `warnings` lists what was left out (shared style
|
||||
links, variables, comments, Dev Mode data and the like). Only round trips through FigCraft's own
|
||||
importer are verified; other apps may not open every file. Without `path` the file comes back as
|
||||
base64 `data`. Check one by opening it again:
|
||||
|
||||
```text
|
||||
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"export","arguments":{"format":"fig","path":"/tmp/handoff.fig"}}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"open_file","arguments":{"path":"/tmp/handoff.fig"}}}
|
||||
```
|
||||
|
||||
### 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 parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.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,10 +16,10 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
|
|||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Menu/command breadth | **100%** (207 / 207 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-12) |
|
||||
| Feature breadth (does each Figma Design feature exist, weighted) | **~84%** (core editor areas 1–16: ~94%) | estimated from the area table below |
|
||||
| Feature breadth (does each Figma Design feature exist, weighted) | **~85%** (core editor areas 1–16: ~94%) | estimated from the area table below |
|
||||
| Ready for real work | **~53%** | 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 | **~49%** | estimated ([below](#mainstream-practitioner): weekly areas 72.6% × discounts 0.9 × 0.9 × 0.85) |
|
||||
| Mainstream practitioner | **~50%** | estimated ([below](#mainstream-practitioner): weekly areas 72.7% × discounts 0.9 × 0.9 × 0.85) |
|
||||
| Remaining to beta | **~155–280 h** one Opus 5.5 agent (~32–64 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~262–519 h** one agent (~83–145 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)) | |
|
||||
|
|
@ -36,7 +36,7 @@ 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) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
|
||||
| Mainstream practitioner | ~49% | ~130–244 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
|
||||
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the experimental `.fig` export opens elsewhere (5–15) |
|
||||
| Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), 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
|
||||
|
|
@ -107,7 +107,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries, component playground (behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 |
|
||||
| 12 | Styles & variables | 5 | 95% | 76% | Variables inside styles, real-library QA (behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 |
|
||||
| 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, delays on mouse triggers, manual overlay offsets, real prototype QA (behaviour suite from the documentation, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 |
|
||||
| 14 | Import/export | 6 | 84% | 57% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 |
|
||||
| 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), checking the experimental `.fig` export (landed; verified only through our own importer) on real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 |
|
||||
| 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 |
|
||||
| 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 |
|
||||
| 17 | Multiplayer & sharing | 8 | 17% | 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, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 |
|
||||
|
|
@ -115,7 +115,7 @@ 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 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 |
|
||||
| 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 | **~84%** | **~63%** | | **117–255** |
|
||||
| | **Weighted (÷113)** | 113 | **~85%** | **~63%** | | **117–255** |
|
||||
| | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 32–74 |
|
||||
|
||||
## 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 | 63% | 28–67 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | 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 | 59% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) |
|
||||
| File formats | 12 | 60% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), `.fig` write experimental (round trips through our own importer only); REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) |
|
||||
| Collaboration | 10 | 13% | 52–104 | 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, with cursor chat and spotlight (`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) |
|
||||
|
|
@ -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): **72.6%**.
|
||||
**2. Average depth** ("Ready" column, weighted): **72.7%**.
|
||||
|
||||
**3. Discounts for what still stops real work:**
|
||||
|
||||
|
|
@ -195,9 +195,9 @@ 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.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)) |
|
||||
| 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) and writes an experimental one that only its own importer is known to read; 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)) |
|
||||
|
||||
72.6% × 0.90 × 0.90 × 0.85 = **~49%** (estimated; 49.98%, rounded down).
|
||||
72.7% × 0.90 × 0.90 × 0.85 = **~50%** (estimated; 50.03%, rounded down).
|
||||
|
||||
**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
|
||||
|
|
@ -210,7 +210,7 @@ weight, and FigCraft earns points the mainstream number doesn't count (platforms
|
|||
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 checked on real files and `.fig` export (the ×0.85 toward ×0.95: ~53%) and the
|
||||
mainstream number rises fastest with `.fig` import and the experimental `.fig` export checked on real files (the ×0.85 toward ×0.95: ~53%) and the
|
||||
interaction pass.
|
||||
|
||||
## How the hours are calibrated
|
||||
|
|
@ -625,6 +625,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Experimental `.fig` export (gap 1): the whole document written from the same public format notes as the import, verified only by round trips through our own importer. Import/export breadth 84% → 86%, ready 57% → 58%; file formats 59% → 60%; file exchange with Figma users stays ×0.85 (nothing shows another app opens the files); mainstream hours ~130–244 h → ~120–229 h (`.fig` export 15–30 h → checking it elsewhere 5–15 h); feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta and full parity hours unchanged at this rounding |
|
||||
| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding |
|
||||
| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h |
|
||||
| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Localization 35% → 36%, 32–55 h → 31–54 h; ready for real work unchanged (~53%); full parity ~268–528 h → ~267–527 h |
|
||||
|
|
|
|||
Loading…
Reference in New Issue