diff --git a/ROADMAP.md b/ROADMAP.md index 56c8864..a5bca7b 100644 --- a/ROADMAP.md +++ b/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 | diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index f1048cc..4e3ef43 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -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> { + 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> { 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 { 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()?; diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index db972ad..6b641f7 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -17,7 +17,7 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window USAGE: figcraft-cli info [--json] - figcraft-cli export -o [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME] + figcraft-cli export -o [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] figcraft-cli convert figcraft-cli render -o [--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 directory, or one file when has an image extension or --node -is given; PDFs take print options (a bleed like 3mm or 0.125in, +is given (`fig`, or an 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) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 75aa967..fd7dab3 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -38,7 +38,7 @@ pub fn specs() -> Vec { 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> { 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 Result { 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 { } } +/// 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 { + 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, Vec)> { + 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())) diff --git a/crates/engine/src/fig_export_tests.rs b/crates/engine/src/fig_export_tests.rs new file mode 100644 index 0000000..5308538 --- /dev/null +++ b/crates/engine/src/fig_export_tests.rs @@ -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 { + 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); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 624ac03..c2c938d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs new file mode 100644 index 0000000..dbb45f4 --- /dev/null +++ b/crates/format/src/fig/export.rs @@ -0,0 +1,1667 @@ +//! Export to `.fig` (experimental, best effort), from the same public format knowledge as the +//! import: the inverse of its mapping ([`super::map`]). +//! +//! The file is the zip the import reads: `canvas.fig` (the fig-kiwi header, then the binary schema +//! and the message, each a raw-deflate chunk), `meta.json`, an optional `thumbnail.png` and +//! `images/`. The schema travels in the file, so it describes only the fields written here +//! ([`SCHEMA`]). The message lists one node change per layer (its id, its parent and its position +//! among the siblings, then its fields), parents first, plus blobs (vector networks and outlines). +//! Instances are written as their main and the fields their layers override (by id path), like +//! the files the import reads. What the format can't carry yet (shared styles, variables, Dev Mode +//! data, …) is left out and listed in the warnings. Only round trips through our own importer are +//! verified; other apps may not open every file. + +use std::collections::BTreeMap; +use std::io::Write; +use std::sync::Arc; + +use figcraft_doc::color::{BlendMode, Color, GradientKind, ImageScaleMode, Paint, PaintKind}; +use figcraft_doc::geom::kurbo::PathEl; +use figcraft_doc::geom::{Affine, BezPath}; +use figcraft_doc::proto::{ + Action, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, +}; +use figcraft_doc::{ + AutoLayout, BoolOp, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, HConstraint, LayoutMode, LetterSpacing, LineHeight, + ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAlignV, + TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VectorNetwork, WindingRule, +}; +use serde_json::Value; + +use super::kiwi::{Kv, Schema, encode}; +use super::network; +use crate::FormatError; + +/// Most layers written, and deepest nesting followed (the import's limits). +const MAX_NODES: usize = crate::rest::MAX_NODES; +const MAX_DEPTH: usize = 256; +/// Largest `canvas.fig` and image written (what the import reads). +const MAX_CANVAS: u64 = super::MAX_CANVAS; +const MAX_IMAGE: u64 = super::MAX_IMAGE; +/// Largest whole file written. +const MAX_FILE: u64 = 2 * 1024 * 1024 * 1024; +/// Most characters of one text layer, and vertices + segments + loop entries of one vector +/// network (what the import reads). +const MAX_CHARS: usize = 1 << 20; +const MAX_NETWORK: usize = 200_000; +/// Largest magnitude written for a float (the format stores 32-bit floats). +const MAX_FLOAT: f64 = 1e30; +/// The version number in the `canvas.fig` header. +const VERSION: u32 = 101; +/// Session ids of the GUIDs: layers, and grid tracks and component properties. +const LAYERS: u32 = 1; +const AUX: u32 = 2; + +/// The schema written into every file: `enum Name A B …` (values from 0), `struct|message Name +/// type field, …` (field ids from 1). Only what the export writes. +pub(super) const SCHEMA: &str = " +enum MessageType JOIN_START NODE_CHANGES +enum NodePhase CREATED REMOVED +enum NodeType NONE DOCUMENT CANVAS GROUP FRAME BOOLEAN_OPERATION VECTOR STAR LINE ELLIPSE RECTANGLE REGULAR_POLYGON ROUNDED_RECTANGLE TEXT SLICE SYMBOL INSTANCE SECTION +enum BlendMode PASS_THROUGH NORMAL DARKEN MULTIPLY LINEAR_BURN COLOR_BURN LIGHTEN SCREEN LINEAR_DODGE COLOR_DODGE OVERLAY SOFT_LIGHT HARD_LIGHT DIFFERENCE EXCLUSION HUE SATURATION COLOR LUMINOSITY +enum PaintType SOLID GRADIENT_LINEAR GRADIENT_RADIAL GRADIENT_ANGULAR GRADIENT_DIAMOND IMAGE +enum ImageScaleMode STRETCH FIT FILL TILE +enum WindingRule NONZERO ODD +enum StrokeAlign CENTER INSIDE OUTSIDE +enum StrokeCap NONE ROUND SQUARE ARROW_LINES ARROW_EQUILATERAL TRIANGLE_FILLED DIAMOND_FILLED CIRCLE_FILLED +enum StrokeJoin MITER BEVEL ROUND +enum ConstraintType MIN CENTER MAX STRETCH SCALE +enum StackMode NONE HORIZONTAL VERTICAL GRID +enum StackAlign MIN CENTER MAX BASELINE SPACE_BETWEEN SPACE_AROUND SPACE_EVENLY +enum StackSize FIXED RESIZE_TO_FIT +enum StackWrap NO_WRAP WRAP +enum StackPositioning AUTO ABSOLUTE +enum StackChildAlign AUTO STRETCH +enum EffectType INNER_SHADOW DROP_SHADOW FOREGROUND_BLUR BACKGROUND_BLUR NOISE TEXTURE GLASS +enum BlurOpType NORMAL PROGRESSIVE +enum NoiseType MONOTONE DUOTONE MULTITONE +enum TextAlignHorizontal LEFT CENTER RIGHT JUSTIFIED +enum TextAlignVertical TOP CENTER BOTTOM +enum TextAutoResize NONE WIDTH_AND_HEIGHT HEIGHT +enum TextTruncation DISABLED ENDING +enum TextCase ORIGINAL UPPER LOWER TITLE SMALL_CAPS +enum TextDecoration NONE UNDERLINE STRIKETHROUGH +enum NumberUnits RAW PIXELS PERCENT +enum LineType PLAIN ORDERED_LIST UNORDERED_LIST +enum VectorMirror NONE ANGLE ANGLE_AND_LENGTH +enum BooleanOperation UNION INTERSECT SUBTRACT XOR +enum LayoutGridType MIN CENTER STRETCH MAX +enum LayoutGridPattern STRIPES GRID +enum Axis X Y +enum ExportImageType PNG JPEG SVG PDF WEBP +enum ExportConstraintType CONTENT_SCALE CONTENT_WIDTH CONTENT_HEIGHT +enum ComponentPropType BOOL TEXT INSTANCE_SWAP VARIANT +enum VariableField MISSING VISIBLE TEXT_DATA OVERRIDDEN_SYMBOL_ID +enum VariableDataType BOOLEAN FLOAT STRING ALIAS COLOR PROP_REF +enum InteractionType ON_CLICK DRAG ON_HOVER ON_PRESS MOUSE_IN MOUSE_OUT MOUSE_DOWN MOUSE_UP AFTER_TIMEOUT ON_KEY_DOWN +enum TriggerDevice KEYBOARD UNKNOWN_CONTROLLER +enum ConnectionType NONE INTERNAL_NODE URL BACK CLOSE +enum NavigationType NAVIGATE OVERLAY SWAP SWAP_STATE SCROLL_TO +enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE SLIDE_FROM_LEFT SLIDE_FROM_RIGHT SLIDE_FROM_TOP SLIDE_FROM_BOTTOM PUSH_FROM_LEFT PUSH_FROM_RIGHT PUSH_FROM_TOP PUSH_FROM_BOTTOM MOVE_FROM_LEFT MOVE_FROM_RIGHT MOVE_FROM_TOP MOVE_FROM_BOTTOM SLIDE_OUT_TO_LEFT SLIDE_OUT_TO_RIGHT SLIDE_OUT_TO_TOP SLIDE_OUT_TO_BOTTOM MOVE_OUT_TO_LEFT MOVE_OUT_TO_RIGHT MOVE_OUT_TO_TOP MOVE_OUT_TO_BOTTOM +enum EasingType IN_CUBIC OUT_CUBIC INOUT_CUBIC LINEAR IN_BACK_CUBIC OUT_BACK_CUBIC INOUT_BACK_CUBIC CUSTOM_CUBIC GENTLE_SPRING +enum ScrollDirection NONE HORIZONTAL VERTICAL BOTH +enum ScrollBehavior SCROLLS FIXED_WHEN_CHILD_OF_SCROLLING_FRAME STICKY_SCROLLS +enum OverlayPositionType CENTER TOP_LEFT TOP_CENTER TOP_RIGHT BOTTOM_LEFT BOTTOM_CENTER BOTTOM_RIGHT MANUAL +enum OverlayBackgroundInteraction NONE CLOSE_ON_CLICK_OUTSIDE +enum OverlayBackgroundType NONE SOLID_COLOR +struct GUID uint sessionID, uint localID +struct Color float r, float g, float b, float a +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +struct ColorStop Color color, float position +message ParentIndex GUID guid, string position +message Image byte[] hash, string name +message Paint PaintType type, Color color, float opacity, bool visible, BlendMode blendMode, ColorStop[] stops, Matrix transform, Image image, ImageScaleMode imageScaleMode, float rotation, float scale +message Effect EffectType type, Color color, Vector offset, float radius, bool visible, BlendMode blendMode, float spread, bool showShadowBehindNode, BlurOpType blurOpType, float startRadius, Vector startOffset, Vector endOffset, NoiseType noiseType, Vector noiseSize, float density, float opacity, Color secondaryColor, bool clipToShape +message FontName string family, string style, string postscript +message Number float value, NumberUnits units +message Hyperlink string url +message TextLine LineType lineType +message TextData string characters, uint[] characterStyleIDs, NodeChange[] styleOverrideTable, TextLine[] lines +message Path WindingRule windingRule, uint commandsBlob +message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable +message ArcData float startingAngle, float endingAngle, float innerRadius +message OptionalVector Vector value +message LayoutGrid LayoutGridType type, Axis axis, bool visible, int numSections, float offset, float sectionSize, float gutterSize, Color color, LayoutGridPattern pattern +message ExportConstraint ExportConstraintType type, float value +message ExportSettings string suffix, ExportImageType imageType, ExportConstraint constraint +message GUIDPath GUID[] guids +message SymbolData GUID symbolID, NodeChange[] symbolOverrides +message ComponentPropValue bool boolValue, TextData textValue, GUID guidValue +message ComponentPropDef GUID id, string name, ComponentPropValue initialValue, ComponentPropType type +message ComponentPropAssignment GUID defID, ComponentPropValue value +message PropRefValue GUID defId +message VariableAnyValue PropRefValue propRefValue +message VariableData VariableAnyValue value, VariableDataType dataType +message VariableMapEntry VariableData variableData, VariableField variableField +message VariableDataMap VariableMapEntry[] entries +message KeyTrigger int[] keyCodes, TriggerDevice triggerDevice +message PrototypeEvent InteractionType interactionType, float transitionTimeout, KeyTrigger keyTrigger +message PrototypeAction GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, float[] easingFunction, ConnectionType connectionType, NavigationType navigationType, string connectionURL, bool transitionPreserveScroll +message PrototypeInteraction PrototypeEvent event, PrototypeAction[] actions +message PrototypeStartingPoint string name, string position +message OverlayBackgroundAppearance OverlayBackgroundType backgroundType, Color backgroundColor +message GridTrack GUID id, string position +message GridTrackMap GridTrack[] entries +message NodeChange GUID guid, NodePhase phase, ParentIndex parentIndex, NodeType type, string name, bool visible, bool locked, float opacity, BlendMode blendMode, Vector size, Matrix transform, bool mask, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, StrokeAlign strokeAlign, StrokeJoin strokeJoin, StrokeCap strokeCap, float[] dashPattern, float miterLimit, bool borderStrokeWeightsIndependent, float borderTopWeight, float borderRightWeight, float borderBottomWeight, float borderLeftWeight, Effect[] effects, float cornerRadius, float cornerSmoothing, bool rectangleCornerRadiiIndependent, float rectangleTopLeftCornerRadius, float rectangleTopRightCornerRadius, float rectangleBottomRightCornerRadius, float rectangleBottomLeftCornerRadius, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, bool frameMaskDisabled, bool resizeToFit, bool isStateGroup, bool sectionContentsHidden, Color backgroundColor, StackMode stackMode, float stackSpacing, float stackCounterSpacing, StackWrap stackWrap, float stackHorizontalPadding, float stackVerticalPadding, float stackPaddingRight, float stackPaddingBottom, StackAlign stackPrimaryAlignItems, StackAlign stackCounterAlignItems, StackSize stackPrimarySizing, StackSize stackCounterSizing, bool bordersTakeSpace, bool stackReverseZIndex, float gridColumnGap, float gridRowGap, GridTrackMap gridColumns, GridTrackMap gridRows, float stackChildPrimaryGrow, StackChildAlign stackChildAlignSelf, StackPositioning stackPositioning, OptionalVector minSize, OptionalVector maxSize, GUID gridColumnAnchor, GUID gridRowAnchor, int gridColumnSpan, int gridRowSpan, LayoutGrid[] layoutGrids, ExportSettings[] exportSettings, TextData textData, FontName fontName, float fontSize, Number lineHeight, Number letterSpacing, TextCase textCase, TextDecoration textDecoration, TextAlignHorizontal textAlignHorizontal, TextAlignVertical textAlignVertical, TextAutoResize textAutoResize, TextTruncation textTruncation, int maxLines, float paragraphSpacing, float paragraphIndent, Hyperlink hyperlink, uint styleID, Path[] fillGeometry, VectorData vectorData, VectorMirror handleMirroring, uint count, float starInnerScale, ArcData arcData, BooleanOperation booleanOperation, string symbolDescription, SymbolData symbolData, GUID overriddenSymbolID, ComponentPropDef[] componentPropDefs, ComponentPropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, GUIDPath guidPath, PrototypeInteraction[] prototypeInteractions, PrototypeStartingPoint prototypeStartingPoint, ScrollDirection scrollDirection, ScrollBehavior scrollBehavior, OverlayPositionType overlayPositionType, OverlayBackgroundInteraction overlayBackgroundInteraction, OverlayBackgroundAppearance overlayBackgroundAppearance +message Blob byte[] bytes +message Message MessageType type, uint sessionID, NodeChange[] nodeChanges, Blob[] blobs +"; + +/// Write `doc` as a `.fig` file, with `thumbnail` (a PNG) when given. Returns the bytes and the +/// warnings about what was left out or approximated. +pub fn export(doc: &Document, thumbnail: Option<&[u8]>) -> Result<(Vec, Vec), FormatError> { + let mut x = Exporter::new(doc); + let message = x.message(); + let fail = |e: super::kiwi::KiwiError| FormatError::Io(format!(".fig export: {e}")); + let schema = Schema::parse(SCHEMA).map_err(fail)?; + let root = schema.find("Message").ok_or_else(|| FormatError::Io(".fig export: the schema has no Message".into()))?; + let body = encode::message(&schema, root, &message).map_err(fail)?; + let canvas = canvas(&encode::schema(&schema), &body)?; + let bytes = archive(&doc.name, &canvas, &x.files, thumbnail)?; + Ok((bytes, x.warnings)) +} + +fn too_large() -> FormatError { + FormatError::Io("the document is too large for a .fig file".into()) +} + +/// `canvas.fig`: the magic, the version and the two deflated chunks, each after its length. +fn canvas(schema: &[u8], message: &[u8]) -> Result, FormatError> { + let mut out = b"fig-kiwi".to_vec(); + out.extend_from_slice(&VERSION.to_le_bytes()); + for chunk in [schema, message] { + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(chunk).map_err(crate::io_err)?; + let c = z.finish().map_err(crate::io_err)?; + let len = u32::try_from(c.len()).map_err(|_| too_large())?; + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&c); + } + if out.len() as u64 > MAX_CANVAS { + return Err(too_large()); + } + Ok(out) +} + +/// The zip around `canvas.fig`. +fn archive(name: &str, canvas: &[u8], images: &[(String, Arc>)], thumbnail: Option<&[u8]>) -> Result, FormatError> { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + crate::put(&mut z, "canvas.fig", stored, canvas)?; + let meta = serde_json::to_vec(&serde_json::json!({"file_name": name})).map_err(crate::io_err)?; + crate::put(&mut z, "meta.json", crate::json_options(), &meta)?; + if let Some(t) = thumbnail { + crate::put(&mut z, "thumbnail.png", stored, t)?; + } + let mut total = canvas.len() as u64; + for (hash, bytes) in images { + total += bytes.len() as u64; + if total > MAX_FILE { + return Err(too_large()); + } + crate::put(&mut z, &format!("images/{hash}"), stored, bytes)?; + } + z.finish().map_err(crate::io_err)?; + } + Ok(buf.into_inner()) +} + +type Fields = Vec<(&'static str, Kv<'static>)>; + +fn o(f: Fields) -> Kv<'static> { + Kv::Object(f) +} +fn arr(v: Vec>) -> Kv<'static> { + Kv::Array(v) +} +/// A float the format can hold: finite (non-finite values become 0) and within range. +fn fl(x: f64) -> Kv<'static> { + Kv::Float(if x.is_finite() { x.clamp(-MAX_FLOAT, MAX_FLOAT) as f32 } else { 0.0 }) +} +fn u(x: u32) -> Kv<'static> { + Kv::Uint(u64::from(x)) +} +fn int(x: i64) -> Kv<'static> { + Kv::Int(x.clamp(i64::from(i32::MIN), i64::from(i32::MAX))) +} +fn e(name: &'static str) -> Kv<'static> { + Kv::Enum(name) +} +fn s(x: &str) -> Kv<'static> { + Kv::Str(x.to_string()) +} +fn b(x: bool) -> Kv<'static> { + Kv::Bool(x) +} +fn guid(session: u32, local: u32) -> Kv<'static> { + o(vec![("sessionID", u(session)), ("localID", u(local))]) +} +fn xy(x: f64, y: f64) -> Kv<'static> { + o(vec![("x", fl(x)), ("y", fl(y))]) +} +fn color(c: Color) -> Kv<'static> { + o(vec![("r", fl(c.r.into())), ("g", fl(c.g.into())), ("b", fl(c.b.into())), ("a", fl(c.a.into()))]) +} +fn matrix(m: Affine) -> Kv<'static> { + let [a, b, c, d, e, f] = m.as_coeffs(); + o(vec![("m00", fl(a)), ("m01", fl(c)), ("m02", fl(e)), ("m10", fl(b)), ("m11", fl(d)), ("m12", fl(f))]) +} +/// A finite coordinate (vector network blobs store raw floats). +fn finite(x: f64) -> f64 { + if x.is_finite() { x.clamp(-MAX_FLOAT, MAX_FLOAT) } else { 0.0 } +} + +/// A position string that sorts as child `i` does among its siblings (fixed width, printable). +fn position(i: usize) -> String { + let mut out = [0u8; 4]; + let mut v = i; + for c in out.iter_mut().rev() { + *c = b'!' + (v % 94) as u8; + v /= 94; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// The format's name of a blend mode: the one the import reads back as `m`. +fn blend_name(m: BlendMode) -> &'static str { + const NAMES: [&str; 19] = [ + "PASS_THROUGH", + "NORMAL", + "DARKEN", + "MULTIPLY", + "LINEAR_BURN", + "COLOR_BURN", + "LIGHTEN", + "SCREEN", + "LINEAR_DODGE", + "COLOR_DODGE", + "OVERLAY", + "SOFT_LIGHT", + "HARD_LIGHT", + "DIFFERENCE", + "EXCLUSION", + "HUE", + "SATURATION", + "COLOR", + "LUMINOSITY", + ]; + NAMES.into_iter().find(|n| crate::rest::props::blend_mode(n) == Some(m)).unwrap_or("NORMAL") +} + +fn cap_name(c: StrokeCap) -> &'static str { + match c { + StrokeCap::None => "NONE", + StrokeCap::Round => "ROUND", + StrokeCap::Square => "SQUARE", + StrokeCap::LineArrow => "ARROW_LINES", + StrokeCap::TriangleArrow | StrokeCap::ReversedTriangle => "ARROW_EQUILATERAL", + StrokeCap::CircleFilled => "CIRCLE_FILLED", + StrokeCap::DiamondFilled => "DIAMOND_FILLED", + } +} + +fn join_name(j: StrokeJoin) -> &'static str { + match j { + StrokeJoin::Miter => "MITER", + StrokeJoin::Bevel => "BEVEL", + StrokeJoin::Round => "ROUND", + } +} + +/// A geometry blob (the import's `svg_path` reads it): commands `0` close, `1` move, `2` line, +/// `3` quadratic, `4` cubic, each followed by its points as little-endian `f32` pairs. +fn path_blob(p: &BezPath) -> Vec { + let mut out = Vec::new(); + let mut put = |cmd: u8, pts: &[figcraft_doc::geom::Point]| { + out.push(cmd); + for q in pts { + out.extend_from_slice(&(finite(q.x) as f32).to_le_bytes()); + out.extend_from_slice(&(finite(q.y) as f32).to_le_bytes()); + } + }; + for el in p.elements() { + match *el { + PathEl::MoveTo(a) => put(1, &[a]), + PathEl::LineTo(a) => put(2, &[a]), + PathEl::QuadTo(a, c) => put(3, &[a, c]), + PathEl::CurveTo(a, c, d) => put(4, &[a, c, d]), + PathEl::ClosePath => put(0, &[]), + } + } + out +} + +/// Settings an overlay's destination carries. +struct Overlay { + position: OverlayPosition, + close_on_outside: bool, + background: Option, +} + +struct Exporter<'d> { + doc: &'d Document, + /// Layer → local id of its GUID (session [`LAYERS`]). + ids: BTreeMap, + nodes: Vec>, + /// Blob bytes → index (identical blobs are written once). + blobs: BTreeMap, u32>, + blob_bytes: u64, + /// Image key → its hash (lowercase hex) once written, `None` when its bytes aren't there. + images: BTreeMap>, + /// `images/` members. + files: Vec<(String, Arc>)>, + /// Component property (name, type) → local id of its definition's GUID (session [`AUX`]). + defs: BTreeMap<(String, &'static str), u32>, + /// Grid tracks of written grid frames: (columns, rows) local ids (session [`AUX`]). + tracks: BTreeMap, Vec)>, + aux: u32, + overlays: BTreeMap, + /// Flow starting points: layer → (order, name). + starts: BTreeMap, + warnings: Vec, +} + +impl<'d> Exporter<'d> { + fn new(doc: &'d Document) -> Self { + Self { + doc, + ids: BTreeMap::new(), + nodes: Vec::new(), + blobs: BTreeMap::new(), + blob_bytes: 0, + images: BTreeMap::new(), + files: Vec::new(), + defs: BTreeMap::new(), + tracks: BTreeMap::new(), + aux: 0, + overlays: BTreeMap::new(), + starts: BTreeMap::new(), + warnings: Vec::new(), + } + } + + fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + fn next_aux(&mut self) -> u32 { + self.aux += 1; + self.aux + } + + fn guid_of(&self, id: NodeId) -> Option> { + self.ids.get(&id).map(|l| guid(LAYERS, *l)) + } + + /// The message: the document, its pages and their layers, and the blobs. + fn message(&mut self) -> Kv<'static> { + let doc = self.doc; + self.assign(); + self.nodes.push(o(vec![("guid", guid(0, 0)), ("phase", e("CREATED")), ("type", e("DOCUMENT")), ("name", s(&doc.name))])); + for (i, p) in doc.pages.iter().enumerate() { + self.emit(*p, guid(0, 0), i, None, 0); + } + if !doc.styles.is_empty() { + self.warn("shared styles were left out (their layers keep the values)"); + } + if !doc.variables.collections.is_empty() || !doc.variables.variables.is_empty() { + self.warn("variables were left out"); + } + if !doc.comments.is_empty() { + self.warn("comments were left out"); + } + let mut blobs: Vec<(u32, Vec)> = std::mem::take(&mut self.blobs).into_iter().map(|(bytes, i)| (i, bytes)).collect(); + blobs.sort_by_key(|x| x.0); + o(vec![ + ("type", e("NODE_CHANGES")), + ("sessionID", u(0)), + ("nodeChanges", arr(std::mem::take(&mut self.nodes))), + ("blobs", arr(blobs.into_iter().map(|(_, bytes)| o(vec![("bytes", Kv::Bytes(bytes))])).collect())), + ]) + } + + /// GUIDs for every layer (pages first, then depth-first), component property definitions, + /// flow starting points and overlay settings, before anything refers to them. + fn assign(&mut self) { + let doc = self.doc; + let mut stack: Vec = doc.pages.iter().rev().copied().collect(); + while let Some(id) = stack.pop() { + let Some(n) = doc.node(id) else { continue }; + if self.ids.len() >= MAX_NODES { + self.warn("the document has more layers than a .fig file is read with; the rest were left out"); + break; + } + let local = u32::try_from(self.ids.len() + 1).unwrap_or(u32::MAX); + self.ids.insert(id, local); + // An instance's layers aren't written (its main says them), so nothing may refer to them. + let mirrored = matches!(&n.kind, NodeKind::Instance { component, .. } if doc.node(*component).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))); + if !mirrored { + stack.extend(n.children.iter().rev()); + } + match &n.kind { + NodeKind::Component { props, .. } => { + for (name, d) in props { + if let Some(ty) = prop_type(d) { + let key = (name.clone(), ty); + if !self.defs.contains_key(&key) { + let id = self.next_aux(); + self.defs.insert(key, id); + } + } + } + } + NodeKind::Page { flows, .. } => { + for (i, f) in flows.iter().enumerate() { + self.starts.entry(f.start).or_insert((i, f.name.clone())); + } + } + _ => {} + } + for a in n.interactions.iter().flat_map(|it| figcraft_doc::proto::walk(&it.actions)) { + if let Action::OpenOverlay { dest, position, close_on_outside, background } = a { + let o = Overlay { position: *position, close_on_outside: *close_on_outside, background: *background }; + match self.overlays.get(dest) { + Some(old) if (old.position, old.close_on_outside, old.background) != (o.position, o.close_on_outside, o.background) => { + self.warn("overlays opened with different settings keep the first one's"); + } + Some(_) => {} + None => { + self.overlays.insert(*dest, o); + } + } + } + } + } + } + + /// Write layer `id` (child `index` of the layer with GUID `parent_guid`) and its subtree. + fn emit(&mut self, id: NodeId, parent_guid: Kv<'static>, index: usize, parent: Option<&Node>, depth: usize) { + let doc = self.doc; + let (Some(&local), Some(n)) = (self.ids.get(&id), doc.node(id)) else { return }; + if depth > MAX_DEPTH { + self.warn("layers nested deeper than 256 levels were left out"); + return; + } + let mut f: Fields = vec![ + ("guid", guid(LAYERS, local)), + ("phase", e("CREATED")), + ("parentIndex", o(vec![("guid", parent_guid), ("position", s(&position(index)))])), + ]; + let children = if let NodeKind::Page { background, guides, prototype, .. } = &n.kind { + f.extend([("type", e("CANVAS")), ("name", s(&n.name)), ("backgroundColor", color(*background))]); + if !guides.is_empty() { + self.warn("ruler guides were left out"); + } + if *prototype != Default::default() { + self.warn("prototype device settings were left out"); + } + true + } else { + self.layer(n, parent, &mut f, false) + }; + self.nodes.push(o(f)); + if children { + for (i, c) in n.children.iter().enumerate() { + self.emit(*c, guid(LAYERS, local), i, Some(n), depth + 1); + } + } + } + + /// The fields of layer `n` (whose parent is `parent`). `nested`: for an instance's override + /// (no type, no instance or component data). Returns whether its children are written as + /// layers (an instance's come from its main). + fn layer(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields, nested: bool) -> bool { + let doc = self.doc; + let main = match &n.kind { + NodeKind::Instance { component, .. } => { + let found = self.ids.contains_key(component) && doc.node(*component).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. })); + if !found && !nested { + self.warn("instances of components that aren't in the file were written as frames"); + } + found.then_some(*component) + } + _ => None, + }; + let ty = match &n.kind { + NodeKind::Page { .. } | NodeKind::Frame | NodeKind::Group | NodeKind::ComponentSet { .. } => "FRAME", + NodeKind::Instance { .. } if main.is_none() => "FRAME", + NodeKind::Section { .. } => "SECTION", + NodeKind::Rectangle => "RECTANGLE", + NodeKind::Ellipse { .. } => "ELLIPSE", + NodeKind::Polygon { .. } => "REGULAR_POLYGON", + NodeKind::Star { .. } => "STAR", + NodeKind::Line => "LINE", + NodeKind::Vector { .. } => "VECTOR", + NodeKind::Text(_) => "TEXT", + NodeKind::BooleanOp { .. } => "BOOLEAN_OPERATION", + NodeKind::Component { .. } => "SYMBOL", + NodeKind::Instance { .. } => "INSTANCE", + NodeKind::Slice => "SLICE", + }; + if !nested { + f.push(("type", e(ty))); + } + f.push(("name", s(&n.name))); + self.common(n, parent, f); + match &n.kind { + NodeKind::Group => f.push(("resizeToFit", b(true))), + NodeKind::Section { contents_hidden } => f.push(("sectionContentsHidden", b(*contents_hidden))), + NodeKind::Ellipse { start, sweep, inner } => { + f.push(("arcData", o(vec![("startingAngle", fl(*start)), ("endingAngle", fl(start + sweep)), ("innerRadius", fl(*inner))]))) + } + NodeKind::Polygon { count } => f.push(("count", u(*count))), + NodeKind::Star { count, inner_ratio } => f.extend([("count", u(*count)), ("starInnerScale", fl(*inner_ratio))]), + NodeKind::Vector { network } => self.vector(n, network, f), + NodeKind::Text(td) => self.text(td, &n.fills, f), + NodeKind::BooleanOp { op } => { + let name = match op { + BoolOp::Union => "UNION", + BoolOp::Subtract => "SUBTRACT", + BoolOp::Intersect => "INTERSECT", + BoolOp::Exclude => "XOR", + }; + f.push(("booleanOperation", e(name))); + if n.children.iter().filter_map(|c| doc.node(*c)).any(|c| c.bool_op.is_some()) { + self.warn("boolean groups whose layers combine in different ways use the group's operation"); + } + } + NodeKind::Component { description, props } if !nested => { + let defs: Vec = props.iter().filter_map(|(name, d)| self.prop_def(name, d)).collect(); + if !defs.is_empty() { + f.push(("componentPropDefs", arr(defs))); + } + if props.values().any(|d| matches!(d, PropDef::Slot { .. })) { + self.warn("slot properties were left out"); + } + if !description.is_empty() { + f.push(("symbolDescription", s(description))); + } + } + NodeKind::ComponentSet { description } if !nested => { + f.push(("isStateGroup", b(true))); + if !description.is_empty() { + f.push(("symbolDescription", s(description))); + } + } + NodeKind::Instance { props, .. } if !nested => { + if let Some(main) = main { + self.instance(n, main, props, f); + } + } + _ => {} + } + if matches!(n.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. }) + && let Some(p) = figcraft_doc::geometry::shape_path(n) + { + self.geometry(&p, WindingRule::NonZero, f); + } + main.is_none() + } + + /// Fields every layer kind shares. + fn common(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) { + f.extend([ + ("visible", b(n.visible)), + ("locked", b(n.locked)), + ("opacity", fl(n.opacity.into())), + ("blendMode", e(blend_name(n.blend))), + ("size", xy(n.width, n.height)), + ("transform", matrix(n.transform)), + ]); + if n.is_mask { + f.push(("mask", b(true))); + if n.mask_type != MaskType::Alpha { + self.warn("outline and luminance masks were written as alpha masks"); + } + } + // Text whose own style sets fills paints with them (the layer's fills only show through runs). + let fills = match &n.kind { + NodeKind::Text(td) => td.style.fills.as_deref().unwrap_or(&n.fills), + _ => &n.fills, + }; + let fills = self.paints(fills); + let strokes = self.paints(&n.strokes); + f.extend([("fillPaints", fills), ("strokePaints", strokes)]); + self.stroke(n, f); + if !n.effects.is_empty() { + let effects = n.effects.iter().map(|x| self.effect(x)).collect(); + f.push(("effects", arr(effects))); + } + if n.kind.has_corner_radius() { + let r = n.corners.radii; + f.push(("cornerRadius", fl(r[0]))); + if n.kind.has_independent_corners() && r.iter().any(|x| *x != r[0]) { + f.extend([ + ("rectangleCornerRadiiIndependent", b(true)), + ("rectangleTopLeftCornerRadius", fl(r[0])), + ("rectangleTopRightCornerRadius", fl(r[1])), + ("rectangleBottomRightCornerRadius", fl(r[2])), + ("rectangleBottomLeftCornerRadius", fl(r[3])), + ]); + } + if n.corners.smoothing > 0.0 { + f.push(("cornerSmoothing", fl(n.corners.smoothing))); + } + } + let h = match n.constraints.horizontal { + HConstraint::Left => "MIN", + HConstraint::Right => "MAX", + HConstraint::LeftRight => "STRETCH", + HConstraint::Center => "CENTER", + HConstraint::Scale => "SCALE", + }; + let v = match n.constraints.vertical { + VConstraint::Top => "MIN", + VConstraint::Bottom => "MAX", + VConstraint::TopBottom => "STRETCH", + VConstraint::Center => "CENTER", + VConstraint::Scale => "SCALE", + }; + f.extend([("horizontalConstraint", e(h)), ("verticalConstraint", e(v))]); + if n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }) { + f.push(("frameMaskDisabled", b(!n.clip))); + } + if n.kind.is_frame_like() { + if let Some(l) = &n.layout { + self.auto_layout(n, l, f); + } + if !n.grids.is_empty() { + f.push(("layoutGrids", arr(n.grids.iter().map(layout_grid).collect()))); + } + } + self.child_layout(n, parent, f); + if !n.exports.is_empty() { + f.push(("exportSettings", arr(n.exports.iter().map(export_setting).collect()))); + } + self.prototype(n, f); + let refs: Vec = n.prop_refs.iter().filter_map(|(field, prop)| self.prop_ref(field, prop)).collect(); + if !refs.is_empty() { + f.push(("parameterConsumptionMap", o(vec![("entries", arr(refs))]))); + } + if !n.styles.is_empty() { + self.warn("shared styles were left out (their layers keep the values)"); + } + if !n.bound.is_empty() || !n.var_modes.is_empty() { + self.warn("variables were left out"); + } + if !n.guides.is_empty() { + self.warn("ruler guides were left out"); + } + if !n.annotations.is_empty() || !n.dev_resources.is_empty() || !n.code_links.is_empty() || !n.dev_status.is_none() { + self.warn("Dev Mode annotations, statuses, resources and code links were left out"); + } + if !n.plugin_data.is_empty() { + self.warn("plugin data was left out"); + } + } + + fn paints(&mut self, list: &[Paint]) -> Kv<'static> { + arr(list.iter().filter_map(|p| self.paint(p)).collect()) + } + + fn paint(&mut self, p: &Paint) -> Option> { + let mut f: Fields = vec![("visible", b(p.visible)), ("blendMode", e(blend_name(p.blend)))]; + let mut opacity = f64::from(p.opacity); + match &p.kind { + PaintKind::Solid { color: c } => { + opacity *= f64::from(c.a); + f.extend([("type", e("SOLID")), ("color", color(c.with_alpha(1.0)))]); + } + PaintKind::Gradient(g) => { + let ty = match g.kind { + GradientKind::Linear => "GRADIENT_LINEAR", + GradientKind::Radial => "GRADIENT_RADIAL", + GradientKind::Angular => "GRADIENT_ANGULAR", + GradientKind::Diamond => "GRADIENT_DIAMOND", + }; + // The handles in the unit box come from the inverse of the paint transform: it maps + // (0, ½) → start, (1, ½) → end, (0, 1) → width for linear gradients, and the centre + // (½, ½) and the points half a box away from it for the others. + let (from, to, width) = (g.from, g.to, g.width); + let (ax, ay) = + if g.kind == GradientKind::Linear { (to.0 - from.0, to.1 - from.1) } else { (2.0 * (to.0 - from.0), 2.0 * (to.1 - from.1)) }; + let (cx, cy) = (2.0 * (width.0 - from.0), 2.0 * (width.1 - from.1)); + let (ex, ey) = if g.kind == GradientKind::Linear { + (from.0 - 0.5 * cx, from.1 - 0.5 * cy) + } else { + (from.0 - 0.5 * (ax + cx), from.1 - 0.5 * (ay + cy)) + }; + let handles = Affine::new([ax, ay, cx, cy, ex, ey]); + let t = if handles.determinant().abs() > 1e-12 && handles.as_coeffs().iter().all(|x| x.is_finite()) { + handles.inverse() + } else { + self.warn("gradients with handles on one line were written with the default direction"); + Affine::IDENTITY + }; + let stops = g.stops.iter().map(|st| o(vec![("color", color(st.color)), ("position", fl(st.position.into()))])).collect(); + f.extend([("type", e(ty)), ("stops", arr(stops)), ("transform", matrix(t))]); + } + PaintKind::Image(img) => { + let hash = self.image(&img.image); + let mode = match img.scale_mode { + ImageScaleMode::Fill => "FILL", + ImageScaleMode::Fit => "FIT", + ImageScaleMode::Crop => "STRETCH", + ImageScaleMode::Tile => "TILE", + }; + f.extend([ + ("type", e("IMAGE")), + ("image", o(vec![("hash", Kv::Bytes(hash)), ("name", s(""))])), + ("imageScaleMode", e(mode)), + ("rotation", fl(f64::from(img.rotation.rem_euclid(4)) * 90.0)), + ("scale", fl(img.scale)), + ]); + if let (ImageScaleMode::Crop, Some(c)) = (img.scale_mode, img.crop) { + f.push(("transform", matrix(Affine::new(c)))); + } + if img.filters != Default::default() { + self.warn("image adjustments (exposure, contrast, …) were left out"); + } + } + PaintKind::Pattern(_) => { + self.warn("pattern fills were left out"); + return None; + } + } + f.push(("opacity", fl(opacity))); + Some(o(f)) + } + + /// The hash bytes of image `key`, writing its file once (empty when its bytes aren't there). + fn image(&mut self, key: &str) -> Vec { + let hex = match self.images.get(key) { + Some(h) => h.clone(), + None => { + let h = match self.doc.images.get(key) { + Some(img) if img.bytes.len() as u64 <= MAX_IMAGE => { + let h = crate::image_hash(&img.bytes); + if !self.files.iter().any(|(x, _)| *x == h) { + self.files.push((h.clone(), img.bytes.clone())); + } + Some(h) + } + Some(_) => { + self.warn("images larger than 256 MB were left out"); + None + } + None => { + self.warn("image fills whose image isn't in the document were written without one"); + None + } + }; + self.images.insert(key.to_string(), h.clone()); + h + } + }; + let hex = hex.unwrap_or_default(); + (0..hex.len() / 2).filter_map(|i| hex.get(2 * i..2 * i + 2).and_then(|d| u8::from_str_radix(d, 16).ok())).collect() + } + + fn stroke(&mut self, n: &Node, f: &mut Fields) { + let sp = &n.stroke; + let align = match sp.align { + StrokeAlign::Inside => "INSIDE", + StrokeAlign::Center => "CENTER", + StrokeAlign::Outside => "OUTSIDE", + }; + f.extend([ + ("strokeWeight", fl(sp.weight)), + ("strokeAlign", e(align)), + ("strokeJoin", e(join_name(sp.join))), + ("strokeCap", e(cap_name(sp.cap_start))), + ("miterLimit", fl(sp.miter_limit)), + ]); + if !sp.dashes.is_empty() { + f.push(("dashPattern", arr(sp.dashes.iter().map(|d| fl(*d)).collect()))); + } + if let Some([t, r, bo, l]) = sp.sides { + f.extend([ + ("borderStrokeWeightsIndependent", b(true)), + ("borderTopWeight", fl(t)), + ("borderRightWeight", fl(r)), + ("borderBottomWeight", fl(bo)), + ("borderLeftWeight", fl(l)), + ]); + } + if sp.width_profile.is_some() || sp.brush.is_some() { + self.warn("variable-width and brush strokes were written as plain strokes"); + } + if [sp.cap_start, sp.cap_end].contains(&StrokeCap::ReversedTriangle) { + self.warn("reversed triangle stroke ends were written as triangle arrows"); + } + // Vectors keep each end's cap on their end points; other layers have one cap. + if sp.cap_start != sp.cap_end && !matches!(n.kind, NodeKind::Vector { .. }) { + self.warn("lines with different stroke ends use the start's at both ends"); + } + } + + fn effect(&mut self, x: &figcraft_doc::Effect) -> Kv<'static> { + let mut f: Fields = vec![("visible", b(x.visible))]; + let blur = |f: &mut Fields, ty: &'static str, bl: &figcraft_doc::Blur| { + f.extend([("type", e(ty)), ("radius", fl(bl.radius))]); + if bl.blur_type == figcraft_doc::BlurType::Progressive { + f.extend([ + ("blurOpType", e("PROGRESSIVE")), + ("startRadius", fl(bl.start_radius)), + ("startOffset", xy(bl.start.0, bl.start.1)), + ("endOffset", xy(bl.end.0, bl.end.1)), + ]); + } + }; + let grain = |size: f64, size_y: Option| xy(size, size_y.unwrap_or(size)); + match &x.kind { + EffectKind::DropShadow(sh) | EffectKind::InnerShadow(sh) => { + let ty = if matches!(x.kind, EffectKind::DropShadow(_)) { "DROP_SHADOW" } else { "INNER_SHADOW" }; + f.extend([ + ("type", e(ty)), + ("color", color(sh.color)), + ("offset", xy(sh.offset.0, sh.offset.1)), + ("radius", fl(sh.radius)), + ("spread", fl(sh.spread)), + ("blendMode", e(blend_name(sh.blend))), + ("showShadowBehindNode", b(sh.show_behind)), + ]); + } + EffectKind::LayerBlur(bl) => blur(&mut f, "FOREGROUND_BLUR", bl), + EffectKind::BackgroundBlur(bl) => blur(&mut f, "BACKGROUND_BLUR", bl), + EffectKind::Noise(nz) => { + let mode = match nz.mode { + NoiseMode::Mono => "MONOTONE", + NoiseMode::Duo => "DUOTONE", + NoiseMode::Multi => "MULTITONE", + }; + f.extend([ + ("type", e("NOISE")), + ("noiseType", e(mode)), + ("noiseSize", grain(nz.size, nz.size_y)), + ("density", fl(nz.density)), + ("color", color(nz.color)), + ("secondaryColor", color(nz.secondary)), + ("opacity", fl(nz.opacity.into())), + ("blendMode", e(blend_name(nz.blend))), + ]); + } + EffectKind::Texture(t) => { + f.extend([ + ("type", e("TEXTURE")), + ("noiseSize", grain(t.size, t.size_y)), + ("radius", fl(t.radius)), + ("clipToShape", b(t.clip_to_shape)), + ]); + } + EffectKind::Glass(g) => { + self.warn("glass effects keep only their frost"); + f.extend([("type", e("GLASS")), ("radius", fl(g.frost))]); + } + } + o(f) + } + + fn auto_layout(&mut self, n: &Node, l: &AutoLayout, f: &mut Fields) { + let grid = l.mode == LayoutMode::Grid; + let mode = match l.mode { + LayoutMode::Horizontal => "HORIZONTAL", + LayoutMode::Vertical => "VERTICAL", + LayoutMode::Grid => "GRID", + }; + let [top, right, bottom, left] = l.padding; + let main = match l.main_align { + MainAlign::Min => "MIN", + MainAlign::Center => "CENTER", + MainAlign::Max => "MAX", + MainAlign::SpaceBetween => "SPACE_BETWEEN", + MainAlign::SpaceAround => "SPACE_AROUND", + MainAlign::SpaceEvenly => "SPACE_EVENLY", + }; + let cross = match l.cross_align { + CrossAlign::Min => "MIN", + CrossAlign::Center => "CENTER", + CrossAlign::Max => "MAX", + CrossAlign::Baseline => "BASELINE", + }; + // The main axis is the width for horizontal and grid layouts. + let (main_hug, cross_hug) = { + let (h, v) = (n.child_layout.sizing_h == Sizing::Hug, n.child_layout.sizing_v == Sizing::Hug); + if l.mode == LayoutMode::Vertical { (v, h) } else { (h, v) } + }; + let size = |hug: bool| e(if hug { "RESIZE_TO_FIT" } else { "FIXED" }); + f.extend([ + ("stackMode", e(mode)), + ("stackWrap", e(if l.wrap { "WRAP" } else { "NO_WRAP" })), + ("stackHorizontalPadding", fl(left)), + ("stackVerticalPadding", fl(top)), + ("stackPaddingRight", fl(right)), + ("stackPaddingBottom", fl(bottom)), + ("stackPrimaryAlignItems", e(main)), + ("stackCounterAlignItems", e(cross)), + ("stackPrimarySizing", size(main_hug)), + ("stackCounterSizing", size(cross_hug)), + ("bordersTakeSpace", b(l.strokes_included)), + ("stackReverseZIndex", b(l.reverse_z)), + ]); + if grid { + // Instance layers share their main layer's track ids, so unchanged tracks and the cells + // their layers sit in still match. + let key = self.main_layer(n.id); + let (want_c, want_r) = (l.grid_columns.min(1000) as usize, l.grid_rows.min(1000) as usize); + let (cols, rows) = match self.tracks.get(&key) { + Some((c, r)) if (c.len(), r.len()) == (want_c, want_r) => (c.clone(), r.clone()), + _ => ((0..want_c).map(|_| self.next_aux()).collect(), (0..want_r).map(|_| self.next_aux()).collect()), + }; + let map = |ids: &[u32]| { + o(vec![( + "entries", + arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()), + )]) + }; + f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]); + if l.column_tracks.iter().chain(&l.row_tracks).any(|t| t.sizing != Sizing::Fill) { + self.warn("grid tracks of fixed or content size were written as equal tracks"); + } + self.tracks.insert(key, (cols, rows)); + } else { + f.extend([("stackSpacing", fl(l.gap)), ("stackCounterSpacing", fl(l.cross_gap))]); + } + } + + /// How a layer sizes and sits in its auto layout parent. + fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) { + let cl = &n.child_layout; + let Some(pl) = parent.and_then(|p| p.layout.as_ref()) else { return }; + let (fill_h, fill_v) = (cl.sizing_h == Sizing::Fill, cl.sizing_v == Sizing::Fill); + let (grow, stretch) = if pl.mode == LayoutMode::Vertical { (fill_v, fill_h) } else { (fill_h, fill_v) }; + if grow { + f.push(("stackChildPrimaryGrow", fl(1.0))); + } + if stretch { + f.push(("stackChildAlignSelf", e("STRETCH"))); + } + if cl.absolute { + f.push(("stackPositioning", e("ABSOLUTE"))); + } + for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] { + if w.is_some() || h.is_some() { + f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))]))); + } + } + if pl.mode == LayoutMode::Grid + && let Some((cols, rows)) = parent.and_then(|p| self.tracks.get(&self.main_layer(p.id))) + { + let anchor = |ids: &[u32], i: Option| i.and_then(|i| ids.get(i as usize)).map(|t| guid(AUX, *t)); + let (c, r) = (anchor(cols, cl.grid_column), anchor(rows, cl.grid_row)); + f.extend(c.map(|g| ("gridColumnAnchor", g))); + f.extend(r.map(|g| ("gridRowAnchor", g))); + if cl.grid_column_span > 0 { + f.push(("gridColumnSpan", int(cl.grid_column_span.into()))); + } + if cl.grid_row_span > 0 { + f.push(("gridRowSpan", int(cl.grid_row_span.into()))); + } + } + } + + /// Text: its style, box settings, and characters with their styled runs. `fills`: the layer's + /// (what runs without fills of their own show when the text's own style sets other fills). + fn text(&mut self, td: &TextData, fills: &[Paint], f: &mut Fields) { + style_fields(&td.style, f); + let (ha, va) = ( + match td.align_h { + TextAlignH::Left => "LEFT", + TextAlignH::Center => "CENTER", + TextAlignH::Right => "RIGHT", + TextAlignH::Justified => "JUSTIFIED", + }, + match td.align_v { + TextAlignV::Top => "TOP", + TextAlignV::Middle => "CENTER", + TextAlignV::Bottom => "BOTTOM", + }, + ); + let resize = match td.auto_resize { + TextAutoResize::WidthAndHeight => "WIDTH_AND_HEIGHT", + TextAutoResize::Height => "HEIGHT", + TextAutoResize::None | TextAutoResize::Truncate => "NONE", + }; + f.extend([ + ("textAlignHorizontal", e(ha)), + ("textAlignVertical", e(va)), + ("textAutoResize", e(resize)), + ("paragraphSpacing", fl(td.paragraph_spacing)), + ("paragraphIndent", fl(td.paragraph_indent)), + ]); + if td.auto_resize == TextAutoResize::Truncate { + f.push(("textTruncation", e("ENDING"))); + } + if td.max_lines > 0 { + f.push(("maxLines", int(td.max_lines.into()))); + } + // Styles per UTF-16 unit: 0 for the base style, else an entry of the override table. + if td.char_count() > MAX_CHARS { + self.warn("text longer than 1,048,576 characters was cut"); + } + let spans = td.spans(); + let mut table: Vec<&TextStyle> = Vec::new(); + let mut span_ids = Vec::with_capacity(spans.len()); + for (_, _, st) in &spans { + span_ids.push(if **st == td.style { + 0 + } else { + let i = table.iter().position(|t| *t == *st).unwrap_or_else(|| { + table.push(st); + table.len() - 1 + }); + i as u32 + 1 + }); + } + let mut chars = String::new(); + let mut ids: Vec = Vec::new(); + let mut span = 0; + for (i, ch) in td.characters.chars().take(MAX_CHARS).enumerate() { + while spans.get(span).is_some_and(|sp| i >= sp.1) { + span += 1; + } + // NULs can't be stored in a string. + if ch == '\0' { + continue; + } + chars.push(ch); + let id = span_ids.get(span).copied().unwrap_or(0); + ids.extend((0..ch.len_utf16()).map(|_| u(id))); + } + let mut data: Fields = vec![("characters", Kv::Str(chars))]; + if !table.is_empty() { + let entries = table + .iter() + .zip(1u32..) + .map(|(st, id)| { + let mut ef: Fields = vec![("styleID", u(id))]; + style_fields(st, &mut ef); + if let Some(p) = st.fills.as_deref().or(td.style.fills.as_ref().map(|_| fills)) { + ef.push(("fillPaints", self.paints(p))); + } + o(ef) + }) + .collect(); + data.extend([("characterStyleIDs", arr(ids)), ("styleOverrideTable", arr(entries))]); + } + let lists: Vec = (0..td.paragraph_count()).map(|p| td.para_list(p).list).collect(); + if lists.iter().any(|l| *l != ListStyle::None) { + let line = |l: &ListStyle| { + o(vec![( + "lineType", + e(match l { + ListStyle::None => "PLAIN", + ListStyle::Numbered => "ORDERED_LIST", + ListStyle::Bulleted => "UNORDERED_LIST", + }), + )]) + }; + data.push(("lines", arr(lists.iter().map(line).collect()))); + } + f.push(("textData", o(data))); + if (0..td.paragraph_count()).any(|p| td.para_list(p).indent > 0) { + self.warn("nested list levels were written as the first level"); + } + let styles = std::iter::once(&td.style).chain(td.runs.iter().map(|r| &r.style)); + if styles.clone().any(|st| st.decoration_look.is_some() || !st.variations.is_empty()) { + self.warn("text decoration looks and variable font settings were left out"); + } + if styles.clone().any(|st| st.style_id.is_some()) { + self.warn("shared styles were left out (their layers keep the values)"); + } + if td.path.is_some() { + self.warn("text on a path was written as plain text"); + } + if td.list_spacing != 0.0 || td.hanging_punctuation || td.hanging_list || td.leading_trim != Default::default() || !td.features.is_empty() { + self.warn("list spacing, hanging punctuation, leading trim and OpenType features were left out"); + } + } + + /// The editable network (with per-point corner radii, handle mirroring and end caps) and, + /// when it has filled areas, their outline. + fn vector(&mut self, n: &Node, net: &VectorNetwork, f: &mut Fields) { + let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum(); + if net.vertices.len() + net.segments.len() + entries > MAX_NETWORK { + self.warn("vector networks too large for the format were written as outlines"); + } else { + // The stroke starts at the first end point and stops at the last: the layer's end caps go + // there (where the point has none of its own). + let mut degree = vec![0usize; net.vertices.len()]; + for sg in &net.segments { + for i in [sg.a, sg.b] { + if let Some(d) = degree.get_mut(i) { + *d += 1; + } + } + } + let ends: Vec = (0..degree.len()).filter(|i| degree.get(*i) == Some(&1)).collect(); + let (first, last) = (ends.first().copied(), ends.last().copied()); + let caps_differ = n.stroke.cap_start != n.stroke.cap_end || net.vertices.iter().any(|v| v.cap.is_some()); + let mut styles: Vec = Vec::new(); + let mut style_of = |fields: Fields| -> u32 { + if fields.is_empty() { + return 0; + } + let i = styles.iter().position(|x| *x == fields).unwrap_or_else(|| { + styles.push(fields); + styles.len() - 1 + }); + i as u32 + 1 + }; + let vertices = net + .vertices + .iter() + .enumerate() + .map(|(i, v)| { + let mut st: Fields = Vec::new(); + if v.radius > 0.0 { + st.push(("cornerRadius", fl(v.radius))); + } + if let Some(m) = v.mirror { + st.push(( + "handleMirroring", + e(match m { + figcraft_doc::HandleMirroring::None => "NONE", + figcraft_doc::HandleMirroring::Angle => "ANGLE", + figcraft_doc::HandleMirroring::AngleAndLength => "ANGLE_AND_LENGTH", + }), + )); + } + let end_cap = if Some(i) == first { + Some(n.stroke.cap_start) + } else if Some(i) == last { + Some(n.stroke.cap_end) + } else { + None + }; + if let Some(c) = v.cap.or(end_cap.filter(|_| caps_differ)) { + st.push(("strokeCap", e(cap_name(c)))); + } + if let Some(j) = v.join { + st.push(("strokeJoin", e(join_name(j)))); + } + network::Vertex { style: style_of(st), x: finite(v.x), y: finite(v.y) } + }) + .collect(); + let segments = net + .segments + .iter() + .map(|sg| network::Segment { a: sg.a, ta: (finite(sg.ta.0), finite(sg.ta.1)), b: sg.b, tb: (finite(sg.tb.0), finite(sg.tb.1)) }) + .collect(); + let regions = net + .regions + .iter() + .map(|r| network::Region { style: 0, non_zero: r.winding == WindingRule::NonZero, loops: r.loops.clone() }) + .collect(); + let blob = network::encode(&network::Network { vertices, segments, regions }); + let table = styles.into_iter().zip(1u32..).map(|(mut st, id)| { + st.insert(0, ("styleID", u(id))); + o(st) + }); + let table: Vec = table.collect(); + if let Some(i) = self.blob(blob) { + f.push(( + "vectorData", + o(vec![("vectorNetworkBlob", u(i)), ("normalizedSize", xy(n.width, n.height)), ("styleOverrideTable", arr(table))]), + )); + } + } + if !net.regions.is_empty() { + let winding = net.regions.first().map(|r| r.winding).unwrap_or_default(); + if net.regions.iter().any(|r| r.winding != winding) { + self.warn("vectors whose filled areas use different winding rules use the first one's in their outline"); + } + self.geometry(&net.fill_path(), winding, f); + } + } + + /// `fillGeometry` from a local path. + fn geometry(&mut self, p: &BezPath, winding: WindingRule, f: &mut Fields) { + if p.elements().is_empty() { + return; + } + if let Some(i) = self.blob(path_blob(p)) { + let rule = if winding == WindingRule::EvenOdd { "ODD" } else { "NONZERO" }; + f.push(("fillGeometry", arr(vec![o(vec![("windingRule", e(rule)), ("commandsBlob", u(i))])]))); + } + } + + /// Add a blob; its index (`None` once the blobs would make the file too large). + fn blob(&mut self, bytes: Vec) -> Option { + if let Some(i) = self.blobs.get(&bytes) { + return Some(*i); + } + self.blob_bytes += bytes.len() as u64; + if self.blob_bytes > MAX_CANVAS { + self.warn("vector data too large for the format was left out"); + return None; + } + let i = u32::try_from(self.blobs.len()).ok()?; + self.blobs.insert(bytes, i); + Some(i) + } + + /// A component property definition (variant properties are implied by the variants' names). + fn prop_def(&mut self, name: &str, d: &PropDef) -> Option> { + let ty = prop_type(d)?; + let id = *self.defs.get(&(name.to_string(), ty))?; + let value = match d { + PropDef::Boolean { default } => o(vec![("boolValue", b(*default))]), + PropDef::Text { default } => o(vec![("textValue", o(vec![("characters", s(default))]))]), + PropDef::InstanceSwap { default, preferred } => { + if !preferred.is_empty() { + self.warn("preferred values of instance swap properties were left out"); + } + o(vec![("guidValue", self.guid_of(*default)?)]) + } + PropDef::Variant { .. } | PropDef::Slot { .. } => return None, + }; + Some(o(vec![("id", guid(AUX, id)), ("name", s(name)), ("initialValue", value), ("type", e(ty))])) + } + + /// Which property controls a layer's `field`. + fn prop_ref(&mut self, field: &str, prop: &str) -> Option> { + let (var_field, ty) = match field { + "visible" => ("VISIBLE", "BOOL"), + "characters" => ("TEXT_DATA", "TEXT"), + "mainComponent" => ("OVERRIDDEN_SYMBOL_ID", "INSTANCE_SWAP"), + _ => { + self.warn("slot layers were written as plain layers"); + return None; + } + }; + let id = *self.defs.get(&(prop.to_string(), ty))?; + let data = o(vec![("value", o(vec![("propRefValue", o(vec![("defId", guid(AUX, id))]))])), ("dataType", e("PROP_REF"))]); + Some(o(vec![("variableData", data), ("variableField", e(var_field))])) + } + + /// An instance: its main, the fields its layers override, its property values. + fn instance(&mut self, n: &Node, main: NodeId, props: &BTreeMap, f: &mut Fields) { + let doc = self.doc; + let mut overrides = Vec::new(); + let mut stack: Vec<(NodeId, NodeId)> = n.children.iter().rev().map(|c| (*c, n.id)).collect(); + let mut seen = 0usize; + while let Some((id, parent)) = stack.pop() { + let Some(d) = doc.node(id) else { continue }; + seen += 1; + if seen > MAX_NODES { + break; + } + stack.extend(d.children.iter().rev().map(|c| (*c, id))); + let path = d.source.and_then(|src| self.path_in(src, main, 0)); + let guids: Option> = path.and_then(|p| p.iter().map(|x| self.guid_of(*x)).collect()); + let (Some(guids), Some(src)) = (guids, d.source.and_then(|src| doc.node(src))) else { + self.warn("instance layers that don't come from their main component were left out"); + continue; + }; + // The fields that differ from the main's layer: overrides, and layout derived from them. + let (mut mine, mut theirs) = (Vec::new(), Vec::new()); + self.layer(d, doc.node(parent), &mut mine, true); + self.layer(src, src.parent.and_then(|p| doc.node(p)), &mut theirs, true); + let mut of: Fields = vec![("guidPath", o(vec![("guids", arr(guids))]))]; + // Fields the main's layer has and this one doesn't are written at their empty values. + let cleared: Fields = + theirs.iter().filter(|(k, _)| !mine.iter().any(|(mk, _)| mk == k)).filter_map(|(k, _)| Some((*k, unset(k)?))).collect(); + of.extend(mine.into_iter().filter(|(k, v)| !theirs.iter().any(|(tk, tv)| tk == k && tv == v))); + of.extend(cleared); + // A nested instance swapped for another component. + if let NodeKind::Instance { component, .. } = &d.kind { + let was = d.source.and_then(|src| doc.node(src)).and_then(|m| match &m.kind { + NodeKind::Instance { component, .. } => Some(*component), + _ => None, + }); + if was != Some(*component) + && let Some(g) = self.guid_of(*component) + { + of.push(("overriddenSymbolID", g)); + } + } + if of.len() > 1 { + overrides.push(o(of)); + } + } + let Some(symbol) = self.guid_of(main) else { return }; + f.push(("symbolData", o(vec![("symbolID", symbol), ("symbolOverrides", arr(overrides))]))); + let mut assignments = Vec::new(); + for (name, v) in props { + let (ty, value) = match v { + Value::Bool(x) => ("BOOL", Some(o(vec![("boolValue", b(*x))]))), + Value::String(t) => ("TEXT", Some(o(vec![("textValue", o(vec![("characters", s(t))]))]))), + Value::Number(c) => ("INSTANCE_SWAP", c.as_u64().and_then(|c| self.guid_of(NodeId(c))).map(|g| o(vec![("guidValue", g)]))), + _ => continue, + }; + // Variant values have no definition of their own: the main they show says them. + if let (Some(&id), Some(value)) = (self.defs.get(&(name.clone(), ty)), value) { + assignments.push(o(vec![("defID", guid(AUX, id)), ("value", value)])); + } + } + if !assignments.is_empty() { + f.push(("componentPropAssignments", arr(assignments))); + } + if n.children.iter().filter_map(|c| doc.node(*c)).any(|c| c.source.is_none()) { + self.warn("instance layers that don't come from their main component were left out"); + } + } + + /// The main component layer an instance or instance layer mirrors (through nested instances), + /// or `id` itself. + fn main_layer(&self, id: NodeId) -> NodeId { + let mut cur = id; + for _ in 0..32 { + let next = self.doc.node(cur).and_then(|n| match &n.kind { + _ if n.source.is_some() => n.source, + NodeKind::Instance { component, .. } => Some(*component), + _ => None, + }); + match next.filter(|s| self.doc.contains(*s)) { + Some(s) => cur = s, + None => break, + } + } + cur + } + + /// The id path of layer `s` inside component `comp`: `s` itself, or through the nested + /// instance it is expanded in, then on inside that instance's main. + fn path_in(&self, s: NodeId, comp: NodeId, depth: usize) -> Option> { + if depth > 32 { + return None; + } + let doc = self.doc; + let up = doc.ancestors(s); + let below: Vec = up.iter().take_while(|a| **a != comp).copied().collect(); + if below.len() == up.len() { + return None; + } + // The outermost nested instance between the component and the layer. + match below.iter().rev().find_map(|a| match doc.node(*a).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => Some((*a, *component)), + _ => None, + }) { + None => Some(vec![s]), + Some((inst, main)) => { + let mut p = vec![inst]; + p.extend(self.path_in(doc.node(s)?.source?, main, depth + 1)?); + Some(p) + } + } + } + + /// Interactions, scrolling, flow starts and the overlay settings this layer is opened with. + fn prototype(&mut self, n: &Node, f: &mut Fields) { + let list: Vec = n.interactions.iter().filter_map(|it| self.interaction(it)).collect(); + if !list.is_empty() { + f.push(("prototypeInteractions", arr(list))); + } + let dir = match n.overflow { + Overflow::None => None, + Overflow::Horizontal => Some("HORIZONTAL"), + Overflow::Vertical => Some("VERTICAL"), + Overflow::Both => Some("BOTH"), + }; + f.extend(dir.map(|d| ("scrollDirection", e(d)))); + let behavior = match n.scroll_position { + ScrollPosition::Scrolls => None, + ScrollPosition::Fixed => Some("FIXED_WHEN_CHILD_OF_SCROLLING_FRAME"), + ScrollPosition::Sticky => Some("STICKY_SCROLLS"), + }; + f.extend(behavior.map(|x| ("scrollBehavior", e(x)))); + if let Some((i, name)) = self.starts.get(&n.id) { + f.push(("prototypeStartingPoint", o(vec![("name", s(name)), ("position", s(&position(*i)))]))); + } + if let Some(ov) = self.overlays.get(&n.id) { + const POSITIONS: [&str; 8] = ["CENTER", "TOP_LEFT", "TOP_CENTER", "TOP_RIGHT", "BOTTOM_LEFT", "BOTTOM_CENTER", "BOTTOM_RIGHT", "MANUAL"]; + let pos = POSITIONS.into_iter().find(|p| crate::rest::proto::overlay_position(p) == Some(ov.position)).unwrap_or("CENTER"); + let bg = match ov.background { + Some(c) => o(vec![("backgroundType", e("SOLID_COLOR")), ("backgroundColor", color(c))]), + None => o(vec![("backgroundType", e("NONE"))]), + }; + f.extend([ + ("overlayPositionType", e(pos)), + ("overlayBackgroundInteraction", e(if ov.close_on_outside { "CLOSE_ON_CLICK_OUTSIDE" } else { "NONE" })), + ("overlayBackgroundAppearance", bg), + ]); + } + } + + fn interaction(&mut self, it: &Interaction) -> Option> { + let mut event: Fields = Vec::new(); + let ty = match &it.trigger { + Trigger::OnClick => "ON_CLICK", + Trigger::OnHover => "ON_HOVER", + Trigger::OnPress => "ON_PRESS", + Trigger::OnDrag => "DRAG", + Trigger::MouseEnter => "MOUSE_IN", + Trigger::MouseLeave => "MOUSE_OUT", + Trigger::MouseDown => "MOUSE_DOWN", + Trigger::MouseUp => "MOUSE_UP", + Trigger::AfterDelay { ms } => { + event.push(("transitionTimeout", fl(f64::from(*ms) / 1000.0))); + "AFTER_TIMEOUT" + } + Trigger::KeyPress { key } => { + let Some((codes, pad)) = key_codes(key) else { + self.warn("key triggers on keys the format has no code for were left out"); + return None; + }; + let device = e(if pad { "UNKNOWN_CONTROLLER" } else { "KEYBOARD" }); + event.push(("keyTrigger", o(vec![("keyCodes", arr(codes.into_iter().map(int).collect())), ("triggerDevice", device)]))); + "ON_KEY_DOWN" + } + }; + event.insert(0, ("interactionType", e(ty))); + let actions: Vec = it.actions.iter().filter_map(|a| self.action(a, it)).collect(); + (!actions.is_empty()).then(|| o(vec![("event", o(event)), ("actions", arr(actions))])) + } + + fn action(&mut self, a: &Action, it: &Interaction) -> Option> { + let node = |x: &mut Self, dest: &NodeId, nav: &'static str| -> Option> { + let Some(g) = x.guid_of(*dest) else { + x.warn("prototype actions whose destination isn't written were left out"); + return None; + }; + let mut f: Fields = vec![("transitionNodeID", g), ("connectionType", e("INTERNAL_NODE")), ("navigationType", e(nav))]; + x.transition(it, &mut f); + Some(o(f)) + }; + let plain = |ty: &'static str| Some(o(vec![("connectionType", e(ty))])); + match a { + Action::Navigate { dest, preserve_scroll } => { + let mut act = node(self, dest, "NAVIGATE")?; + if let Kv::Object(f) = &mut act { + f.push(("transitionPreserveScroll", b(*preserve_scroll))); + } + Some(act) + } + Action::OpenOverlay { dest, .. } => node(self, dest, "OVERLAY"), + Action::SwapOverlay { dest } => node(self, dest, "SWAP"), + Action::ChangeTo { dest } => node(self, dest, "SWAP_STATE"), + Action::ScrollTo { dest, offset } => { + if *offset != [0.0, 0.0] { + self.warn("scroll-to offsets were left out"); + } + node(self, dest, "SCROLL_TO") + } + Action::Back => plain("BACK"), + Action::CloseOverlay => plain("CLOSE"), + Action::OpenUrl { url } => Some(o(vec![("connectionType", e("URL")), ("connectionURL", s(url))])), + Action::SetVariable { .. } | Action::SetVariableMode { .. } | Action::Conditional { .. } => { + self.warn("variable and conditional prototype actions were left out"); + None + } + } + } + + /// An interaction's transition on one of its actions. + fn transition(&mut self, it: &Interaction, f: &mut Fields) { + let side_from = |d: Direction, l: &'static str, r: &'static str, t: &'static str, bo: &'static str| match d { + // The direction names where the incoming screen moves: it comes from the other side. + Direction::Right => l, + Direction::Left => r, + Direction::Bottom => t, + Direction::Top => bo, + }; + let side_to = |d: Direction, l: &'static str, r: &'static str, t: &'static str, bo: &'static str| match d { + Direction::Left => l, + Direction::Right => r, + Direction::Top => t, + Direction::Bottom => bo, + }; + let ty = match it.transition { + Transition::Instant => "INSTANT_TRANSITION", + Transition::Dissolve => "DISSOLVE", + Transition::SmartAnimate => "SMART_ANIMATE", + Transition::SlideIn { direction } => side_from(direction, "SLIDE_FROM_LEFT", "SLIDE_FROM_RIGHT", "SLIDE_FROM_TOP", "SLIDE_FROM_BOTTOM"), + Transition::Push { direction } => side_from(direction, "PUSH_FROM_LEFT", "PUSH_FROM_RIGHT", "PUSH_FROM_TOP", "PUSH_FROM_BOTTOM"), + Transition::MoveIn { direction } => side_from(direction, "MOVE_FROM_LEFT", "MOVE_FROM_RIGHT", "MOVE_FROM_TOP", "MOVE_FROM_BOTTOM"), + Transition::SlideOut { direction } => { + side_to(direction, "SLIDE_OUT_TO_LEFT", "SLIDE_OUT_TO_RIGHT", "SLIDE_OUT_TO_TOP", "SLIDE_OUT_TO_BOTTOM") + } + Transition::MoveOut { direction } => side_to(direction, "MOVE_OUT_TO_LEFT", "MOVE_OUT_TO_RIGHT", "MOVE_OUT_TO_TOP", "MOVE_OUT_TO_BOTTOM"), + }; + let easing = match it.easing { + Easing::Linear => "LINEAR", + Easing::EaseIn => "IN_CUBIC", + Easing::EaseOut => "OUT_CUBIC", + Easing::EaseInOut => "INOUT_CUBIC", + Easing::EaseInBack => "IN_BACK_CUBIC", + Easing::EaseOutBack => "OUT_BACK_CUBIC", + Easing::EaseInOutBack => "INOUT_BACK_CUBIC", + Easing::Gentle => "GENTLE_SPRING", + Easing::CustomBezier { .. } => "CUSTOM_CUBIC", + Easing::Quick | Easing::Bouncy | Easing::Slow | Easing::CustomSpring { .. } => { + self.warn("spring animations other than Gentle were written as ease out"); + "OUT_CUBIC" + } + }; + f.extend([("transitionType", e(ty)), ("transitionDuration", fl(f64::from(it.duration_ms) / 1000.0)), ("easingType", e(easing))]); + if let Easing::CustomBezier { x1, y1, x2, y2 } = it.easing { + f.push(("easingFunction", arr(vec![fl(x1), fl(y1), fl(x2), fl(y2)]))); + } + } +} + +/// The empty value of a field only written when set (an override clears it with this). +fn unset(key: &str) -> Option> { + Some(match key { + "mask" | "rectangleCornerRadiiIndependent" | "borderStrokeWeightsIndependent" => b(false), + "effects" | "dashPattern" | "layoutGrids" | "exportSettings" | "prototypeInteractions" | "fillGeometry" => arr(Vec::new()), + "cornerSmoothing" | "stackChildPrimaryGrow" => fl(0.0), + "stackMode" => e("NONE"), + "stackChildAlignSelf" | "stackPositioning" => e("AUTO"), + "minSize" | "maxSize" => o(vec![("value", xy(0.0, 0.0))]), + "gridColumnSpan" | "gridRowSpan" | "maxLines" => int(0), + "scrollDirection" => e("NONE"), + "scrollBehavior" => e("SCROLLS"), + "parameterConsumptionMap" => o(vec![("entries", arr(Vec::new()))]), + "textTruncation" => e("DISABLED"), + "hyperlink" => o(vec![("url", s(""))]), + _ => return None, + }) +} + +/// The format's type of a component property (`None`: not written). +fn prop_type(d: &PropDef) -> Option<&'static str> { + match d { + PropDef::Boolean { .. } => Some("BOOL"), + PropDef::Text { .. } => Some("TEXT"), + PropDef::InstanceSwap { .. } => Some("INSTANCE_SWAP"), + PropDef::Variant { .. } | PropDef::Slot { .. } => None, + } +} + +/// The key codes of a key trigger (modifiers first), and whether it's a controller button. +fn key_codes(key: &str) -> Option<(Vec, bool)> { + if let Some(i) = GAMEPAD_BUTTONS.iter().position(|g| g.eq_ignore_ascii_case(key.trim())) { + return Some((vec![i as i64], true)); + } + let (mods, k): (KeyMods, &str) = KeyMods::split(key); + let code = (0..=255).find(|c| crate::rest::proto::key_name(f64::from(*c)).is_some_and(|n| n.eq_ignore_ascii_case(k)))?; + let mut codes: Vec = [(mods.shift, 16), (mods.ctrl, 17), (mods.alt, 18), (mods.cmd, 91)].into_iter().filter(|m| m.0).map(|m| m.1).collect(); + codes.push(i64::from(code)); + Some((codes, false)) +} + +/// The text style fields of a layer or of an override table entry. +fn style_fields(st: &TextStyle, f: &mut Fields) { + let line_height = match st.line_height { + // 100 % in percent units reads as the font's own line height: a raw multiplier keeps it. + LineHeight::Auto => o(vec![("value", fl(100.0)), ("units", e("PERCENT"))]), + LineHeight::Percent(p) if (p - 100.0).abs() < 1e-9 => o(vec![("value", fl(1.0)), ("units", e("RAW"))]), + LineHeight::Percent(p) => o(vec![("value", fl(p)), ("units", e("PERCENT"))]), + LineHeight::Pixels(v) => o(vec![("value", fl(v)), ("units", e("PIXELS"))]), + }; + let letter_spacing = match st.letter_spacing { + LetterSpacing::Percent(p) => o(vec![("value", fl(p)), ("units", e("PERCENT"))]), + LetterSpacing::Pixels(v) => o(vec![("value", fl(v)), ("units", e("PIXELS"))]), + }; + let case = match st.case { + TextCase::Original => "ORIGINAL", + TextCase::Upper => "UPPER", + TextCase::Lower => "LOWER", + TextCase::Title => "TITLE", + TextCase::SmallCaps => "SMALL_CAPS", + }; + let decoration = match st.decoration { + TextDecoration::None => "NONE", + TextDecoration::Underline => "UNDERLINE", + TextDecoration::Strikethrough => "STRIKETHROUGH", + }; + f.extend([ + ("fontName", o(vec![("family", s(&st.font_family)), ("style", s(&st.font_style)), ("postscript", s(""))])), + ("fontSize", fl(st.font_size)), + ("lineHeight", line_height), + ("letterSpacing", letter_spacing), + ("textCase", e(case)), + ("textDecoration", e(decoration)), + ]); + if let Some(url) = &st.link { + f.push(("hyperlink", o(vec![("url", s(url))]))); + } +} + +fn layout_grid(g: &figcraft_doc::LayoutGrid) -> Kv<'static> { + let (pattern, axis) = match g.kind { + GridKind::Grid => ("GRID", "X"), + GridKind::Columns => ("STRIPES", "X"), + GridKind::Rows => ("STRIPES", "Y"), + }; + let align = match g.align { + GridAlign::Stretch => "STRETCH", + GridAlign::Min => "MIN", + GridAlign::Center => "CENTER", + GridAlign::Max => "MAX", + }; + o(vec![ + ("type", e(align)), + ("axis", e(axis)), + ("visible", b(g.visible)), + ("numSections", int(g.count.into())), + ("offset", fl(g.offset)), + ("sectionSize", fl(g.size)), + ("gutterSize", fl(g.gutter)), + ("color", color(g.color)), + ("pattern", e(pattern)), + ]) +} + +fn export_setting(x: &figcraft_doc::ExportSetting) -> Kv<'static> { + let format = match x.format { + figcraft_doc::ExportFormat::Png => "PNG", + figcraft_doc::ExportFormat::Jpg => "JPEG", + figcraft_doc::ExportFormat::Svg => "SVG", + figcraft_doc::ExportFormat::Pdf => "PDF", + figcraft_doc::ExportFormat::Webp => "WEBP", + }; + let (ty, value) = match x.size { + ExportSize::Scale { value } => ("CONTENT_SCALE", value), + ExportSize::Width { value } => ("CONTENT_WIDTH", value), + ExportSize::Height { value } => ("CONTENT_HEIGHT", value), + }; + o(vec![("suffix", s(&x.suffix)), ("imageType", e(format)), ("constraint", o(vec![("type", e(ty)), ("value", fl(value))]))]) +} diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs new file mode 100644 index 0000000..fa90003 --- /dev/null +++ b/crates/format/src/fig/export_tests.rs @@ -0,0 +1,1059 @@ +//! `.fig` export tests: generated documents round trip through our own importer (layer tree, +//! geometry within tolerance, paints, text, auto layout, components, prototypes); the written file +//! decodes with the generic decoder and lists every layer after its parent; the encoder agrees +//! with the decoder; junk documents never panic. + +use std::collections::{BTreeMap, BTreeSet}; + +use figcraft_doc::color::{BlendMode, Color, ColorStop, Gradient, GradientKind, ImageScaleMode, Paint, PaintKind}; +use figcraft_doc::geom::{Affine, Point}; +use figcraft_doc::proto::{Action, Direction, Easing, Flow, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger}; +use figcraft_doc::*; +use serde_json::Value; + +use super::kiwi::{Kind, Kv, Schema, Type, encode}; +use super::*; + +/// A cheap deterministic generator. +struct Lcg(u64); +impl Lcg { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + self.0 >> 33 + } + fn below(&mut self, n: u64) -> u64 { + self.next() % n.max(1) + } + fn chance(&mut self, pct: u64) -> bool { + self.below(100) < pct + } + /// A value in `lo..hi` in steps of 1/8 (exact in 32-bit floats). + fn f(&mut self, lo: f64, hi: f64) -> f64 { + let steps = ((hi - lo) * 8.0).max(1.0) as u64; + lo + self.below(steps) as f64 / 8.0 + } + fn pick(&mut self, xs: &[T]) -> T { + xs[self.below(xs.len() as u64) as usize] + } +} + +fn rgb(r: &mut Lcg) -> Color { + Color::new(r.below(9) as f32 / 8.0, r.below(9) as f32 / 8.0, r.below(9) as f32 / 8.0, 1.0) +} + +const BLENDS: [BlendMode; 4] = [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Overlay]; + +fn paint(r: &mut Lcg, image: &str) -> Paint { + let kind = match r.below(4) { + 0 | 1 => PaintKind::Solid { color: rgb(r) }, + 2 => { + let kind = r.pick(&[GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond]); + let mut g = Gradient::default_for(kind, rgb(r)); + g.from = (r.f(0.0, 1.0), r.f(0.0, 1.0)); + g.to = (g.from.0 + r.f(0.25, 1.0), g.from.1 - r.f(0.0, 0.5)); + // Across the main axis (handles on one line can't be written). + let k = r.pick(&[0.5, 1.0]); + g.width = (g.from.0 - (g.to.1 - g.from.1) * k, g.from.1 + (g.to.0 - g.from.0) * k); + g.stops = vec![ColorStop { position: 0.0, color: rgb(r) }, ColorStop { position: 0.5, color: rgb(r).with_alpha(0.5) }]; + PaintKind::Gradient(g) + } + _ => { + let mut p = + Paint::image(image.to_string(), r.pick(&[ImageScaleMode::Fill, ImageScaleMode::Fit, ImageScaleMode::Tile, ImageScaleMode::Crop])); + if let PaintKind::Image(img) = &mut p.kind { + img.rotation = r.below(4) as i32; + img.scale = r.f(0.5, 2.0); + if img.scale_mode == ImageScaleMode::Crop { + img.crop = Some([r.f(0.5, 1.0), 0.0, 0.0, r.f(0.5, 1.0), r.f(0.0, 0.25), r.f(0.0, 0.25)]); + } + } + p.kind + } + }; + Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS) } +} + +fn effect(r: &mut Lcg) -> Effect { + let shadow = |r: &mut Lcg| Shadow { + color: rgb(r).with_alpha(0.25), + offset: (r.f(-4.0, 4.0), r.f(-4.0, 4.0)), + radius: r.f(0.0, 8.0), + spread: r.f(-2.0, 2.0), + blend: r.pick(&BLENDS), + show_behind: r.chance(30), + }; + let blur = |r: &mut Lcg| { + let mut b = Blur { radius: r.f(1.0, 10.0), ..Blur::default() }; + if r.chance(40) { + b.blur_type = BlurType::Progressive; + b.start_radius = r.f(0.0, 2.0); + b.start = (0.5, 0.0); + b.end = (0.5, 1.0); + } + b + }; + let kind = match r.below(6) { + 0 => EffectKind::DropShadow(shadow(r)), + 1 => EffectKind::InnerShadow(shadow(r)), + 2 => EffectKind::LayerBlur(blur(r)), + 3 => EffectKind::BackgroundBlur(blur(r)), + 4 => EffectKind::Noise(Noise { + mode: r.pick(&[NoiseMode::Mono, NoiseMode::Duo, NoiseMode::Multi]), + size: r.f(0.5, 4.0), + size_y: r.chance(50).then_some(2.5), + density: r.f(0.0, 1.0), + color: rgb(r), + secondary: rgb(r), + opacity: 0.5, + blend: BlendMode::Normal, + }), + _ => EffectKind::Texture(Texture { size: r.f(1.0, 4.0), size_y: None, radius: r.f(0.0, 4.0), clip_to_shape: r.chance(50) }), + }; + Effect { kind, visible: r.chance(90) } +} + +/// Random looks: paints, strokes, effects, opacity, blend, corners, constraints, a rotation. +fn style(r: &mut Lcg, n: &mut Node, image: &str) { + n.fills = (0..r.below(3)).map(|_| paint(r, image)).collect(); + n.strokes = (0..r.below(2)).map(|_| Paint::solid(rgb(r))).collect(); + n.stroke.weight = r.f(0.0, 4.0); + n.stroke.align = r.pick(&[StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside]); + n.stroke.join = r.pick(&[StrokeJoin::Miter, StrokeJoin::Bevel, StrokeJoin::Round]); + let cap = r.pick(&[StrokeCap::None, StrokeCap::Round, StrokeCap::Square]); + (n.stroke.cap_start, n.stroke.cap_end) = (cap, cap); + n.stroke.miter_limit = r.pick(&[4.0, 2.0, 10.0]); + if r.chance(20) { + n.stroke.dashes = vec![r.f(1.0, 6.0), r.f(1.0, 6.0)]; + } + if r.chance(15) && matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame) { + n.stroke.sides = Some([1.0, 2.0, 0.0, 3.0]); + } + n.effects = (0..r.below(3)).map(|_| effect(r)).collect(); + n.opacity = r.pick(&[1.0, 0.75, 0.5]); + n.visible = r.chance(90); + n.locked = r.chance(10); + if !n.kind.is_container() { + n.blend = r.pick(&BLENDS); + } + if n.kind.has_corner_radius() { + n.corners.radii = if n.kind.has_independent_corners() && r.chance(40) { [1.0, 2.0, 3.0, 4.0] } else { [r.f(0.0, 8.0); 4] }; + n.corners.smoothing = r.pick(&[0.0, 0.6]); + } + n.constraints = Constraints { + horizontal: r.pick(&[HConstraint::Left, HConstraint::Right, HConstraint::LeftRight, HConstraint::Center, HConstraint::Scale]), + vertical: r.pick(&[VConstraint::Top, VConstraint::Bottom, VConstraint::TopBottom, VConstraint::Center, VConstraint::Scale]), + }; + if r.chance(30) { + let a = r.f(-1.5, 1.5); + n.transform *= Affine::rotate(a); + } + if r.chance(15) { + n.exports = + vec![ExportSetting { size: ExportSize::Scale { value: 2.0 }, suffix: "@2x".into(), format: ExportFormat::Png, ..Default::default() }]; + } +} + +fn text_data(r: &mut Lcg) -> TextData { + const BITS: [&str; 8] = ["Hello", " ", "wörld", "😀", "\n", "日本", "x", "Fig"]; + let chars: String = (0..1 + r.below(8)).map(|_| r.pick(&BITS)).collect(); + let mut td = TextData::new(chars); + td.style.font_size = r.f(8.0, 32.0); + td.style.font_style = r.pick(&["Regular", "Bold"]).into(); + td.style.letter_spacing = r.pick(&[LetterSpacing::Percent(0.0), LetterSpacing::Percent(5.0), LetterSpacing::Pixels(1.5)]); + td.style.line_height = r.pick(&[LineHeight::Auto, LineHeight::Pixels(20.0), LineHeight::Percent(150.0), LineHeight::Percent(100.0)]); + td.style.case = r.pick(&[TextCase::Original, TextCase::Upper, TextCase::Title]); + td.style.decoration = r.pick(&[TextDecoration::None, TextDecoration::Underline, TextDecoration::Strikethrough]); + if r.chance(15) { + td.style.link = Some("https://example.com/a".into()); + } + if r.chance(15) { + td.style.fills = Some(vec![Paint::solid(rgb(r))]); + } + td.auto_resize = r.pick(&[TextAutoResize::WidthAndHeight, TextAutoResize::Height, TextAutoResize::None, TextAutoResize::Truncate]); + if td.auto_resize == TextAutoResize::Truncate { + td.max_lines = 2; + } + td.align_h = r.pick(&[TextAlignH::Left, TextAlignH::Center, TextAlignH::Right, TextAlignH::Justified]); + td.align_v = r.pick(&[TextAlignV::Top, TextAlignV::Middle, TextAlignV::Bottom]); + td.paragraph_spacing = r.f(0.0, 8.0); + let n = td.char_count(); + for _ in 0..r.below(3) { + let (a, b) = (r.below(n as u64 + 1) as usize, r.below(n as u64 + 1) as usize); + let (size, bold, fill, ls) = (r.f(8.0, 40.0), r.chance(50), r.chance(30).then(|| vec![Paint::solid(rgb(r))]), r.f(0.0, 10.0)); + td.restyle(a.min(b), a.max(b), |s| { + s.font_size = size; + if bold { + s.font_style = "Bold".into(); + } + if fill.is_some() { + s.fills = fill.clone(); + } + s.letter_spacing = LetterSpacing::Percent(ls); + s.decoration = TextDecoration::Underline; + }); + } + if r.chance(30) { + let list = r.pick(&[ListStyle::Bulleted, ListStyle::Numbered]); + td.set_para_lists(0, 1, |p| p.list = list); + } + td +} + +fn network(r: &mut Lcg, w: f64, h: f64) -> VectorNetwork { + let pts: Vec = (0..3 + r.below(4)).map(|_| Point::new(r.f(0.0, w), r.f(0.0, h))).collect(); + let mut net = VectorNetwork::from_points(&pts, r.chance(60)); + if let Some(s) = net.segments.first_mut() { + s.ta = (r.f(0.0, 8.0), 0.0); + s.tb = (0.0, r.f(-8.0, 0.0)); + } + if let Some(v) = net.vertices.get_mut(1) { + v.radius = r.pick(&[0.0, 2.0]); + v.mirror = r.pick(&[None, Some(HandleMirroring::Angle), Some(HandleMirroring::AngleAndLength), Some(HandleMirroring::None)]); + } + if let Some(reg) = net.regions.first_mut() { + reg.winding = r.pick(&[WindingRule::NonZero, WindingRule::EvenOdd]); + } + net +} + +struct Gen { + r: Lcg, + doc: Document, + image: String, + /// Top-level frames (prototype destinations) and every layer made. + frames: Vec, + layers: Vec, +} + +impl Gen { + fn node(&mut self, parent: NodeId, kind: NodeKind, name: &str) -> NodeId { + let (x, y, w, h) = (self.r.f(-50.0, 400.0), self.r.f(-50.0, 400.0), self.r.f(1.0, 200.0), self.r.f(1.0, 120.0)); + let id = self.doc.create(parent, kind, name, x, y, w, h).unwrap(); + let image = self.image.clone(); + let n = self.doc.node_mut(id).unwrap(); + style(&mut self.r, n, &image); + self.layers.push(id); + id + } + + /// A random layer under `parent` (with children for containers), `depth` levels down. + fn layer(&mut self, parent: NodeId, depth: usize) -> NodeId { + let parent_layout = self.doc.node(parent).and_then(|p| p.layout.clone()); + let on_page = self.doc.node(parent).is_some_and(Node::is_page); + let kind = if depth >= 3 { 3 + self.r.below(8) } else { self.r.below(12) }; + let id = match kind { + 0 => { + let id = self.node(parent, NodeKind::Frame, "Frame"); + if self.r.chance(60) { + let mode = self.r.pick(&[LayoutMode::Horizontal, LayoutMode::Vertical, LayoutMode::Grid]); + let l = AutoLayout { + mode, + wrap: mode != LayoutMode::Grid && self.r.chance(30), + gap: self.r.f(0.0, 16.0), + cross_gap: self.r.f(0.0, 8.0), + padding: [1.0, 2.0, self.r.f(0.0, 8.0), 4.0], + main_align: self.r.pick(&[MainAlign::Min, MainAlign::Center, MainAlign::Max, MainAlign::SpaceBetween]), + cross_align: self.r.pick(&[CrossAlign::Min, CrossAlign::Center, CrossAlign::Max, CrossAlign::Baseline]), + grid_columns: 1 + self.r.below(3) as u32, + grid_rows: 1 + self.r.below(3) as u32, + strokes_included: self.r.chance(30), + reverse_z: self.r.chance(20), + ..AutoLayout::default() + }; + let hug = (self.r.chance(30), self.r.chance(30)); + let n = self.doc.node_mut(id).unwrap(); + n.layout = Some(l); + n.clip = self.r.chance(50); + if hug.0 { + n.child_layout.sizing_h = Sizing::Hug; + } + if hug.1 { + n.child_layout.sizing_v = Sizing::Hug; + } + } + if self.r.chance(20) { + self.doc.node_mut(id).unwrap().grids = vec![LayoutGrid::columns(), LayoutGrid::rows()]; + } + if self.r.chance(15) { + let n = self.doc.node_mut(id).unwrap(); + n.overflow = Overflow::Vertical; + } + for _ in 0..self.r.below(4) { + self.layer(id, depth + 1); + } + if on_page { + self.frames.push(id); + } + id + } + 1 | 2 if depth < 3 => { + let kind = if kind == 1 { + NodeKind::Group + } else { + NodeKind::BooleanOp { op: self.r.pick(&[BoolOp::Union, BoolOp::Subtract, BoolOp::Intersect, BoolOp::Exclude]) } + }; + let id = self.node(parent, kind, "Group"); + // Groups paint nothing of their own. + self.doc.node_mut(id).unwrap().fills.clear(); + for _ in 0..1 + self.r.below(3) { + let leaf = if self.r.chance(50) { + NodeKind::Rectangle + } else { + NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 } + }; + self.node(id, leaf, "Operand"); + } + self.doc.fit_to_children(id).unwrap(); + id + } + 3 => self.node(parent, NodeKind::Rectangle, "Rect"), + 4 => { + let (start, sweep, inner) = if self.r.chance(50) { (0.0, std::f64::consts::TAU, 0.0) } else { (0.5, 3.0, 0.25) }; + self.node(parent, NodeKind::Ellipse { start, sweep, inner }, "Ellipse") + } + 5 => { + let count = 3 + self.r.below(6) as u32; + self.node(parent, NodeKind::Polygon { count }, "Polygon") + } + 6 => { + let (count, inner_ratio) = (3 + self.r.below(6) as u32, self.r.f(0.25, 0.875)); + self.node(parent, NodeKind::Star { count, inner_ratio }, "Star") + } + 7 => { + let id = self.node(parent, NodeKind::Line, "Line"); + self.doc.node_mut(id).unwrap().height = 0.0; + id + } + 8 => { + let id = self.node(parent, NodeKind::Vector { network: VectorNetwork::default() }, "Vector"); + let n = self.doc.node_mut(id).unwrap(); + let net = network(&mut self.r, n.width, n.height); + n.kind = NodeKind::Vector { network: net }; + if self.r.chance(40) { + (n.stroke.cap_start, n.stroke.cap_end) = (StrokeCap::None, StrokeCap::LineArrow); + } + id + } + 9 => { + let td = text_data(&mut self.r); + self.node(parent, NodeKind::Text(td), "Text") + } + 10 if on_page => { + let id = self.doc.alloc_id(); + let mut n = Node::new_section(id, "Section", 300.0, 200.0); + n.transform = Affine::translate((self.r.f(0.0, 500.0), 600.0)); + self.doc.insert(n, parent, None).unwrap(); + for _ in 0..self.r.below(3) { + self.layer(id, depth + 1); + } + id + } + _ => self.node(parent, NodeKind::Slice, "Slice"), + }; + if let Some(pl) = parent_layout { + let fill_ok = !matches!(self.doc.node(id).map(|n| &n.kind), Some(NodeKind::Group | NodeKind::BooleanOp { .. })); + let (fh, fv, abs) = (self.r.chance(30) && fill_ok, self.r.chance(30) && fill_ok, self.r.chance(10)); + let (col, row) = (self.r.below(u64::from(pl.grid_columns)) as u32, self.r.below(u64::from(pl.grid_rows)) as u32); + let min = self.r.chance(20).then_some(10.0); + let n = self.doc.node_mut(id).unwrap(); + if fh { + n.child_layout.sizing_h = Sizing::Fill; + } + if fv { + n.child_layout.sizing_v = Sizing::Fill; + } + n.child_layout.absolute = abs; + n.child_layout.min_w = min; + if pl.mode == LayoutMode::Grid { + n.child_layout.grid_column = Some(col); + n.child_layout.grid_row = Some(row); + n.child_layout.grid_column_span = 1; + } + } + id + } + + /// A copy of `src`'s subtree under `parent`, each layer mirroring its original (`source`). + fn mirror(&mut self, src: NodeId, parent: NodeId) -> NodeId { + let mut n = self.doc.node(src).unwrap().clone(); + let kids = std::mem::take(&mut n.children); + n.id = self.doc.alloc_id(); + n.source = Some(src); + let id = self.doc.insert(n, parent, None).unwrap(); + for k in kids { + self.mirror(k, id); + } + id + } + + /// An instance of `main` under `parent`, its layers mirroring the main's. + fn instance(&mut self, main: NodeId, parent: NodeId, props: BTreeMap) -> NodeId { + let m = self.doc.node(main).unwrap().clone(); + let id = self.doc.alloc_id(); + let mut n = m.clone(); + (n.id, n.kind, n.name, n.parent, n.source) = (id, NodeKind::Instance { component: main, props }, "Instance".into(), None, None); + n.children.clear(); + n.transform = Affine::translate((self.r.f(0.0, 400.0), self.r.f(400.0, 600.0))); + self.doc.insert(n, parent, None).unwrap(); + for k in m.children { + self.mirror(k, id); + } + id + } + + /// A component with a boolean and a text property, a component set, and instances of both + /// (with property values and overrides), and one nested two levels deep. + fn components(&mut self, page: NodeId) { + let comp = self.node(page, NodeKind::Component { description: "A button".into(), props: BTreeMap::new() }, "Button"); + let icon = self.node(comp, NodeKind::Rectangle, "Icon"); + let label = self.node(comp, NodeKind::Text(TextData::new("Label")), "Label"); + { + let n = self.doc.node_mut(comp).unwrap(); + if let NodeKind::Component { props, .. } = &mut n.kind { + props.insert("Show".into(), PropDef::Boolean { default: true }); + props.insert("Label".into(), PropDef::Text { default: "Label".into() }); + } + } + let fx = effect(&mut self.r); + let n = self.doc.node_mut(icon).unwrap(); + n.prop_refs.insert("visible".into(), "Show".into()); + n.effects = vec![fx]; + self.doc.node_mut(label).unwrap().prop_refs.insert("characters".into(), "Label".into()); + let props = BTreeMap::from([("Show".to_string(), Value::Bool(false)), ("Label".to_string(), Value::String("Hi".into()))]); + let inst = self.instance(comp, page, props); + // Apply the property values and a fill override, as the engine would. + let kids = self.doc.node(inst).unwrap().children.clone(); + for k in kids { + let fill = Paint::solid(rgb(&mut self.r)); + let n = self.doc.node_mut(k).unwrap(); + match &mut n.kind { + NodeKind::Text(td) => { + td.characters = "Hi".into(); + td.runs.clear(); + n.overrides.insert("characters".into()); + } + _ => { + // Its main's effect cleared: an override that removes what the main has. + n.visible = false; + n.fills = vec![fill]; + n.effects.clear(); + n.overrides.extend(["visible".to_string(), "fills".to_string(), "effects".to_string()]); + } + } + } + // A card holding an instance of the button, and an instance of the card overriding a layer of + // the nested button. + let card = self.node(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Card"); + // A grid whose second cell holds the nested button. + self.doc.node_mut(card).unwrap().layout = Some(AutoLayout { mode: LayoutMode::Grid, grid_columns: 2, grid_rows: 1, ..AutoLayout::default() }); + let nested = self.instance(comp, card, BTreeMap::new()); + self.doc.node_mut(nested).unwrap().child_layout.grid_column = Some(1); + let outer = self.instance(card, page, BTreeMap::new()); + let deep = self.doc.node(outer).unwrap().children.first().and_then(|c| self.doc.node(*c)).and_then(|c| c.children.first().copied()); + if let Some(d) = deep { + let n = self.doc.node_mut(d).unwrap(); + n.opacity = 0.25; + n.overrides.insert("opacity".into()); + } + // A component set with two variants and an instance of the second. + let set = self.node(page, NodeKind::ComponentSet { description: "States".into() }, "Toggle"); + let mut variants = Vec::new(); + for name in ["State=Off", "State=On"] { + let v = self.node(set, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name); + self.node(v, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Knob"); + variants.push(v); + } + if let Some(on) = variants.last() { + self.instance(*on, page, BTreeMap::new()); + } + self.frames.extend(variants); + } + + /// Prototype interactions between random layers and frames. + fn interactions(&mut self) { + if self.frames.is_empty() { + return; + } + let layers = self.layers.clone(); + for id in layers { + // Not on component layers: their instances, made already, would miss them. + let in_component = self + .doc + .ancestors(id) + .iter() + .chain([&id]) + .any(|a| matches!(self.doc.node(*a).map(|n| &n.kind), Some(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))); + if !self.r.chance(25) || in_component || self.doc.node(id).is_some_and(|n| n.source.is_some()) { + continue; + } + let dest = self.r.pick(&self.frames); + let trigger = match self.r.below(6) { + 0 => Trigger::OnClick, + 1 => Trigger::OnHover, + 2 => Trigger::AfterDelay { ms: 10 * self.r.below(300) as u32 }, + 3 => Trigger::KeyPress { key: KeyMods { shift: true, ..KeyMods::default() }.combo("K") }, + 4 => Trigger::KeyPress { key: "Gamepad A".into() }, + _ => [Trigger::MouseEnter, Trigger::MouseLeave, Trigger::MouseDown, Trigger::MouseUp, Trigger::OnPress, Trigger::OnDrag] + [self.r.below(6) as usize] + .clone(), + }; + let action = match self.r.below(8) { + 0 | 1 => Action::Navigate { dest, preserve_scroll: self.r.chance(50) }, + 2 => Action::OpenOverlay { + dest, + // One setting per overlay (its frame carries it). + position: [OverlayPosition::Center, OverlayPosition::TopLeft, OverlayPosition::BottomRight, OverlayPosition::Manual] + [(dest.0 % 4) as usize], + close_on_outside: true, + background: Some(Color::new(0.0, 0.0, 0.0, 0.5)), + }, + 3 => Action::SwapOverlay { dest }, + 4 => Action::ChangeTo { dest }, + 5 => Action::Back, + 6 => Action::CloseOverlay, + _ => Action::OpenUrl { url: "https://example.com".into() }, + }; + let d = self.r.pick(&[Direction::Left, Direction::Right, Direction::Top, Direction::Bottom]); + let transition = self.r.pick(&[ + Transition::Instant, + Transition::Dissolve, + Transition::SmartAnimate, + Transition::MoveIn { direction: d }, + Transition::MoveOut { direction: d }, + Transition::Push { direction: d }, + Transition::SlideIn { direction: d }, + Transition::SlideOut { direction: d }, + ]); + let easing = self.r.pick(&[ + Easing::Linear, + Easing::EaseIn, + Easing::EaseOut, + Easing::EaseInOut, + Easing::EaseInBack, + Easing::EaseOutBack, + Easing::EaseInOutBack, + Easing::Gentle, + Easing::CustomBezier { x1: 0.25, y1: 0.125, x2: 0.5, y2: 1.0 }, + ]); + let it = Interaction { trigger, actions: vec![action], transition, duration_ms: 10 * (1 + self.r.below(100) as u32), easing }; + let fixed = self.r.chance(20); + let n = self.doc.node_mut(id).unwrap(); + n.interactions.push(it); + if fixed { + n.scroll_position = ScrollPosition::Fixed; + } + } + } +} + +/// A random document: one or two pages of random layers, components and instances, flows and +/// interactions. +fn generate(seed: u64) -> Document { + let mut doc = Document::new(format!("Generated {seed}")); + let (image, _, _) = crate::add_image(&mut doc, super::tests::png()).unwrap(); + let mut g = Gen { r: Lcg(seed.wrapping_mul(0x9E37_79B9).wrapping_add(17)), doc, image, frames: Vec::new(), layers: Vec::new() }; + let mut pages = vec![g.doc.first_page().unwrap()]; + if g.r.chance(40) { + pages.push(g.doc.add_page("Second")); + } + for (i, page) in pages.iter().enumerate() { + let bg = rgb(&mut g.r); + if let Some(NodeKind::Page { background, .. }) = g.doc.node_mut(*page).map(|n| &mut n.kind) { + *background = bg; + } + for _ in 0..1 + g.r.below(5) { + g.layer(*page, 0); + } + if i == 0 { + g.components(*page); + } + let start = g.doc.node(*page).unwrap().children.iter().copied().find(|c| g.frames.contains(c)); + if let (Some(start), Some(NodeKind::Page { flows, .. })) = (start, g.doc.node_mut(*page).map(|n| &mut n.kind)) { + flows.push(Flow { name: "Main flow".into(), start, description: String::new() }); + } + } + g.interactions(); + g.doc +} + +// ---- comparing documents ---- + +/// What a round trip can't bring back as written, made the same on both sides: ids become +/// positions in the layer order; instance layers lose their links (the engine relinks them); +/// values the format holds another way are converted (solid alpha into the paint's opacity, +/// letter spacing in percent into pixels, list styles spelled out per paragraph). +fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) { + n.id = ix(n.id); + n.parent = n.parent.map(ix); + n.children = n.children.iter().map(|c| ix(*c)).collect(); + n.source = None; + n.overrides.clear(); + n.constrain_proportions = false; + let fold = |ps: &mut Vec| { + for p in ps.iter_mut() { + if let PaintKind::Solid { color } = &mut p.kind { + p.opacity *= color.a; + color.a = 1.0; + } + if p.blend == BlendMode::PassThrough { + p.blend = BlendMode::Normal; + } + } + }; + fold(&mut n.fills); + fold(&mut n.strokes); + for e in &mut n.effects { + match &mut e.kind { + EffectKind::DropShadow(s) | EffectKind::InnerShadow(s) if s.blend == BlendMode::PassThrough => s.blend = BlendMode::Normal, + // A grain as tall as it is wide has no height of its own. + EffectKind::Noise(Noise { size, size_y, .. }) | EffectKind::Texture(Texture { size, size_y, .. }) if *size_y == Some(*size) => { + *size_y = None + } + _ => {} + } + } + if let Some(l) = &mut n.layout { + if matches!(l.main_align, MainAlign::SpaceAround | MainAlign::SpaceEvenly) { + l.main_align = MainAlign::SpaceBetween; + } + // Track counts only matter (and are only written) for grids. + if l.mode != LayoutMode::Grid { + let d = AutoLayout::default(); + (l.grid_columns, l.grid_rows) = (d.grid_columns, d.grid_rows); + } + } + n.child_layout.grid_align_h = None; + n.child_layout.grid_align_v = None; + if n.stroke.cap_start == StrokeCap::ReversedTriangle { + n.stroke.cap_start = StrokeCap::TriangleArrow; + } + if n.stroke.cap_end == StrokeCap::ReversedTriangle { + n.stroke.cap_end = StrokeCap::TriangleArrow; + } + let mut ends = true; + match &mut n.kind { + NodeKind::Instance { component, props } => { + *component = ix(*component); + for v in props.values_mut() { + if let Some(c) = v.as_u64() { + *v = Value::from(ix(NodeId(c)).0); + } + } + } + NodeKind::Component { props, .. } => { + for d in props.values_mut() { + if let PropDef::InstanceSwap { default, preferred } = d { + *default = ix(*default); + preferred.clear(); + } + } + } + NodeKind::Page { flows, .. } => { + for f in flows { + f.start = ix(f.start); + } + } + NodeKind::Text(td) => { + // The text's own fills are the layer's; runs without fills keep showing the layer's. + if let Some(f) = td.style.fills.take() { + let layer = std::mem::replace(&mut n.fills, f); + for r in &mut td.runs { + r.style.fills.get_or_insert_with(|| layer.clone()); + } + } + let px = |s: &mut TextStyle| { + if let LetterSpacing::Percent(p) = s.letter_spacing { + s.letter_spacing = if p == 0.0 { LetterSpacing::default() } else { LetterSpacing::Pixels(p / 100.0 * s.font_size) }; + } + s.style_id = None; + if let Some(f) = &mut s.fills { + fold(f); + } + }; + px(&mut td.style); + for r in &mut td.runs { + px(&mut r.style); + } + td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { list: td.para_list(p).list, indent: 0 }).collect(); + td.list = ListStyle::None; + } + NodeKind::Vector { network } => { + let mut degree = vec![0usize; network.vertices.len()]; + for s in &network.segments { + for i in [s.a, s.b] { + if let Some(d) = degree.get_mut(i) { + *d += 1; + } + } + } + ends = degree.contains(&1); + for v in &mut network.vertices { + v.cap = None; + v.join = None; + } + } + _ => {} + } + // One cap for both ends, except where a vector's end points carry them. + if !ends || !matches!(n.kind, NodeKind::Vector { .. }) { + n.stroke.cap_end = n.stroke.cap_start; + } + for it in &mut n.interactions { + figcraft_doc::proto::walk_mut(&mut it.actions, &mut |a| match a { + Action::Navigate { dest, .. } + | Action::OpenOverlay { dest, .. } + | Action::SwapOverlay { dest } + | Action::ChangeTo { dest } + | Action::ScrollTo { dest, .. } => *dest = ix(*dest), + _ => {} + }); + let animated = it.actions.iter().any(|a| { + matches!( + a, + Action::Navigate { .. } | Action::OpenOverlay { .. } | Action::SwapOverlay { .. } | Action::ChangeTo { .. } | Action::ScrollTo { .. } + ) + }); + if !animated || it.transition == Transition::Instant { + it.transition = Transition::Instant; + it.duration_ms = 300; + it.easing = Easing::default(); + } + } +} + +/// Every layer (pages first, depth-first) as JSON, normalized. +fn canon(doc: &Document) -> Vec { + let order: Vec = doc.pages.iter().flat_map(|p| doc.subtree(*p)).collect(); + let index: BTreeMap = order.iter().zip(0..).map(|(id, i)| (*id, i)).collect(); + let ix = |id: NodeId| NodeId(index.get(&id).copied().unwrap_or(u64::MAX)); + order + .iter() + .map(|id| { + let mut n = doc.node(*id).unwrap().clone(); + normalize(&mut n, &ix); + let mut v = serde_json::to_value(&n).unwrap(); + // Written even when it's the identity: near-identity read back must compare. + v["transform"] = serde_json::json!(n.transform.as_coeffs()); + v + }) + .collect() +} + +/// `a` and `b` agree: the same structure, numbers within a relative tolerance. +fn close(a: &Value, b: &Value, path: &str) -> Result<(), String> { + match (a, b) { + (Value::Number(x), Value::Number(y)) => { + let (x, y) = (x.as_f64().unwrap(), y.as_f64().unwrap()); + if (x - y).abs() <= 2e-3 * x.abs().max(y.abs()).max(1.0) { Ok(()) } else { Err(format!("{path}: {x} vs {y}")) } + } + (Value::Object(x), Value::Object(y)) => { + for k in x.keys().chain(y.keys()).collect::>() { + let null = Value::Null; + close(x.get(k).unwrap_or(&null), y.get(k).unwrap_or(&null), &format!("{path}.{k}"))?; + } + Ok(()) + } + (Value::Array(x), Value::Array(y)) if x.len() == y.len() => { + x.iter().zip(y).enumerate().try_for_each(|(i, (p, q))| close(p, q, &format!("{path}[{i}]"))) + } + _ if a == b => Ok(()), + _ => Err(format!("{path}: {a} vs {b}")), + } +} + +fn assert_same(a: &Document, b: &Document, what: &str) { + let (x, y) = (canon(a), canon(b)); + let names = |c: &[Value]| c.iter().map(|v| v["name"].as_str().unwrap_or("").to_string()).collect::>(); + assert_eq!(names(&x), names(&y), "{what}: layer trees differ"); + for (p, q) in x.iter().zip(&y) { + if let Err(e) = close(p, q, p["name"].as_str().unwrap_or("?")) { + panic!("{what}: {e}\nwritten: {p}\nread back: {q}"); + } + } +} + +/// Export `doc`, import the file, and compare. +fn round_trip(doc: &Document, what: &str) -> (Document, Vec) { + let (bytes, warnings) = export(doc, None).unwrap(); + let (back, _) = import(&bytes, "x").unwrap_or_else(|e| panic!("{what}: {e}")); + assert_same(doc, &back, what); + (back, warnings) +} + +#[test] +fn generated_documents_round_trip() { + for seed in 0..60 { + let doc = generate(seed); + round_trip(&doc, &format!("seed {seed}")); + } +} + +#[test] +fn round_trip_keeps_the_details() { + let doc = generate(3); + let (back, warnings) = round_trip(&doc, "seed 3"); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back.name, doc.name); + assert_eq!(back.images.len(), doc.images.len(), "the image comes back with its bytes"); + let inst = + back.nodes().find(|n| n.name == "Instance" && matches!(&n.kind, NodeKind::Instance { props, .. } if props.contains_key("Label"))).unwrap(); + let NodeKind::Instance { component, props } = &inst.kind else { panic!() }; + assert_eq!(back.node(*component).map(|c| c.name.as_str()), Some("Button")); + assert_eq!(props.get("Label"), Some(&Value::String("Hi".into()))); + let label = inst.children.iter().filter_map(|c| back.node(*c)).find_map(|c| c.kind.text()).unwrap(); + assert_eq!(label.characters, "Hi"); +} + +#[test] +fn written_files_decode_and_list_parents_first() { + for seed in 0..20 { + let doc = generate(seed); + let (bytes, _) = export(&doc, Some(&super::tests::png())).unwrap(); + let archive = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&archive.canvas).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let guid = |v: &Kv| format!("{}:{}", v.get("sessionID").unwrap().f64().unwrap(), v.get("localID").unwrap().f64().unwrap()); + let blobs = msg.get("blobs").unwrap().list().len(); + let mut seen = BTreeSet::new(); + let changes = msg.get("nodeChanges").unwrap().list(); + for nc in changes { + let id = guid(nc.get("guid").unwrap()); + if nc.get("type").and_then(Kv::str) == Some("DOCUMENT") { + assert!(nc.get("parentIndex").is_none()); + } else { + let parent = guid(nc.get("parentIndex").unwrap().get("guid").unwrap()); + assert!(seen.contains(&parent), "seed {seed}: {id} is listed before its parent {parent}"); + } + assert!(seen.insert(id), "seed {seed}: GUIDs are unique"); + for p in nc.get("fillGeometry").map(Kv::list).unwrap_or_default() { + assert!((p.get("commandsBlob").unwrap().f64().unwrap() as usize) < blobs); + } + for p in nc.get("fillPaints").map(Kv::list).unwrap_or_default() { + if let Some(h) = p.get("image").and_then(|i| i.get("hash")).and_then(Kv::bytes) { + let hex: String = h.iter().map(|b| format!("{b:02x}")).collect(); + assert!(archive.image(&hex).is_some(), "seed {seed}: image {hex} is in the file"); + } + } + } + // Every layer once, except instance layers (their main says them). + let inside = |id: NodeId| doc.ancestors(id).iter().any(|a| matches!(doc.node(*a).map(|n| &n.kind), Some(NodeKind::Instance { .. }))); + let expected = doc.pages.iter().flat_map(|p| doc.subtree(*p)).filter(|id| !inside(*id)).count() + 1; + assert_eq!(changes.len(), expected, "seed {seed}"); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap(); + assert!(crate::read_member(&mut z, "thumbnail.png", 1 << 20).is_ok()); + } +} + +#[test] +fn the_written_schema_parses_and_round_trips() { + let schema = Schema::parse(export::SCHEMA).unwrap(); + let back = Schema::decode(&encode::schema(&schema)).unwrap(); + assert_eq!(back.defs.len(), schema.defs.len()); + for (a, b) in schema.defs.iter().zip(&back.defs) { + assert_eq!(a.name, b.name); + assert_eq!( + a.fields.iter().map(|f| (&f.name, f.ty, f.array, f.value)).collect::>(), + b.fields.iter().map(|f| (&f.name, f.ty, f.array, f.value)).collect::>() + ); + } + assert!(Schema::parse("message M nope x").is_err()); + assert!(Schema::parse("thing M").is_err()); +} + +/// A random value of type `ty` as the decoder would read it back. +fn random_value<'s>(s: &'s Schema, ty: Type, r: &mut Lcg, depth: usize) -> Kv<'s> { + match ty { + Type::Bool => Kv::Bool(r.chance(50)), + Type::Byte => Kv::Uint(r.below(256)), + Type::Int => Kv::Int(r.next() as i32 as i64), + Type::Uint => Kv::Uint(u64::from(r.next() as u32)), + Type::Float => Kv::Float(r.pick(&[0.0, 1.5, -2.25, 1e-3, 3.0e7, -1e20])), + Type::Str => Kv::Str((0..r.below(6)).map(|_| r.pick(&['a', 'é', '😀', ' '])).collect()), + Type::Int64 => Kv::Int(r.next() as i64 - (1 << 30)), + Type::Uint64 => Kv::Uint(r.next()), + Type::Def(i) => random_def(s, i, r, depth + 1), + } +} + +fn random_def<'s>(s: &'s Schema, i: usize, r: &mut Lcg, depth: usize) -> Kv<'s> { + let d = &s.defs[i]; + let field = |f: &'s super::kiwi::Field, r: &mut Lcg| -> Kv<'s> { + let ty = f.ty.unwrap(); + if !f.array { + return random_value(s, ty, r, depth); + } + let n = if depth > 3 { 0 } else { r.below(3) }; + if ty == Type::Byte { + return Kv::Bytes((0..n * 3).map(|_| r.next() as u8).collect()); + } + Kv::Array((0..n).map(|_| random_value(s, ty, r, depth)).collect()) + }; + match d.kind { + Kind::Enum => Kv::Enum(d.fields[r.below(d.fields.len() as u64) as usize].name.as_str()), + Kind::Struct => Kv::Object(d.fields.iter().map(|f| (f.name.as_str(), field(f, r))).collect()), + Kind::Message => { + let mut out = Vec::new(); + if depth <= 4 { + for f in &d.fields { + if r.chance(30) { + out.push((f.name.as_str(), field(f, r))); + } + } + } + Kv::Object(out) + } + } +} + +#[test] +fn encoder_output_always_decodes_to_the_same_values() { + let schema = Schema::parse(export::SCHEMA).unwrap(); + let mut r = Lcg(5); + for (i, d) in schema.defs.iter().enumerate() { + if d.kind != Kind::Message { + continue; + } + for _ in 0..20 { + let v = random_def(&schema, i, &mut r, 0); + let bytes = encode::message(&schema, i, &v).unwrap(); + assert_eq!(schema.decode_message(i, &bytes).unwrap(), v, "{}", d.name); + } + } +} + +#[test] +fn the_encoder_refuses_what_the_schema_cant_hold() { + let schema = Schema::parse("struct V float x, float y\nmessage M V v, uint n, string s, bool b").unwrap(); + let bad = [ + Kv::Object(vec![("v", Kv::Object(vec![("x", Kv::Float(f32::NAN)), ("y", Kv::Float(0.0))]))]), + Kv::Object(vec![("v", Kv::Object(vec![("x", Kv::Float(1.0))]))]), + Kv::Object(vec![("n", Kv::Int(-1))]), + Kv::Object(vec![("n", Kv::Uint(1 << 40))]), + Kv::Object(vec![("nope", Kv::Bool(true))]), + Kv::Object(vec![("s", Kv::Bool(true))]), + Kv::Array(vec![]), + ]; + for v in &bad { + assert!(encode::message(&schema, 1, v).is_err(), "{v:?}"); + } + // NULs inside strings are dropped rather than ending them early. + let ok = Kv::Object(vec![("s", Kv::Str("a\0b".into())), ("b", Kv::Bool(true))]); + let back = schema.decode_message(1, &encode::message(&schema, 1, &ok).unwrap()).unwrap(); + assert_eq!(back.get("s"), Some(&Kv::Str("ab".into()))); + assert_eq!(back.get("b"), Some(&Kv::Bool(true))); +} + +#[test] +fn junk_documents_never_panic() { + for seed in 0..40 { + let mut doc = generate(seed); + let mut r = Lcg(seed + 99); + let ids: Vec = doc.ids().collect(); + let junk = [f64::NAN, f64::INFINITY, -1e300, 1e300, -5.0, 0.0]; + for id in ids { + if !r.chance(40) { + continue; + } + let Some(n) = doc.node_mut(id) else { continue }; + n.width = r.pick(&junk); + n.transform = Affine::new([r.pick(&junk), 0.0, 0.0, 1.0, r.pick(&junk), 0.0]); + n.opacity = f32::NAN; + n.name.push('\0'); + n.stroke.weight = r.pick(&junk); + n.stroke.dashes.push(f64::NAN); + n.corners.radii[1] = f64::INFINITY; + n.child_layout.grid_column = Some(u32::MAX); + for p in &mut n.fills { + match &mut p.kind { + PaintKind::Gradient(g) => g.from = (f64::NAN, 0.0), + PaintKind::Image(img) => img.image = "missing".into(), + _ => p.opacity = f32::INFINITY, + } + } + if let Some(l) = &mut n.layout { + l.grid_columns = u32::MAX; + l.gap = f64::NAN; + } + n.interactions.push(Interaction { + trigger: Trigger::KeyPress { key: "Hyper+\u{0}".into() }, + actions: vec![Action::Navigate { dest: NodeId(u64::MAX), preserve_scroll: false }], + transition: Transition::Instant, + duration_ms: u32::MAX, + easing: Easing::default(), + }); + match &mut n.kind { + NodeKind::Vector { network } => { + network.segments.push(figcraft_doc::Segment::line(0, 99)); + if let Some(v) = network.vertices.first_mut() { + v.x = f64::NAN; + } + } + NodeKind::Text(td) => { + td.characters.push_str("\0\u{FFFF}"); + td.runs.push(StyleRun { start: 5, end: 1, style: TextStyle::default() }); + } + NodeKind::Instance { component, props } => { + *component = NodeId(u64::MAX - 1); + props.insert("x".into(), Value::from(-1)); + } + NodeKind::Polygon { count } => *count = u32::MAX, + NodeKind::Ellipse { sweep, .. } => *sweep = f64::NAN, + _ => {} + } + } + if let Ok((bytes, _)) = export(&doc, None) { + let _ = import(&bytes, "x"); + } + } +} + +/// Link each instance's layers to its main's by position, as they are imported (the engine +/// links them by path after loading). +fn link(doc: &mut Document) { + fn zip(doc: &mut Document, mine: NodeId, theirs: NodeId) { + let pairs: Vec<(NodeId, NodeId)> = match (doc.node(mine), doc.node(theirs)) { + (Some(m), Some(t)) => m.children.iter().copied().zip(t.children.iter().copied()).collect(), + _ => return, + }; + for (m, t) in pairs { + if let Some(n) = doc.node_mut(m) { + n.source.get_or_insert(t); + } + zip(doc, m, t); + } + } + let insts: Vec<(NodeId, NodeId)> = doc + .nodes() + .filter_map(|n| match &n.kind { + NodeKind::Instance { component, .. } => Some((n.id, *component)), + _ => None, + }) + .collect(); + for (i, c) in insts { + zip(doc, i, c); + } +} + +/// Local only (`FIGCRAFT_FIG_SAMPLES`): each sample imported, exported and imported again comes +/// back the same, apart from what the export leaves out (shared style links, variables). +#[test] +fn local_samples_round_trip() { + for p in super::tests::samples() { + let bytes = std::fs::read(&p).unwrap(); + let (mut a, _) = import(&bytes, "sample").unwrap(); + link(&mut a); + let (out, warnings) = export(&a, None).unwrap_or_else(|e| panic!("{}: {e}", p.display())); + let (mut b, _) = import(&out, "sample").unwrap_or_else(|e| panic!("{}: {e}", p.display())); + for d in [&mut a, &mut b] { + let ids: Vec = d.ids().collect(); + for id in ids { + if let Some(n) = d.node_mut(id) { + n.styles = StyleRefs::default(); + n.bound.clear(); + n.var_modes.clear(); + } + } + } + assert_same(&a, &b, &format!("{} (export warnings: {warnings:?})", p.display())); + } +} diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 0be212e..897eaa1 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -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 { + /// 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, 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 { + 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 { + let lines: Vec> = text.lines().map(|l| l.split_whitespace().collect::>()).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 }) } } diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 585de9c..0cb8f1d 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -37,6 +37,9 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { 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, styles: Obj, + /// Descriptions of components and component sets (REST `components` / `componentSets`). components: Obj, + sets: Obj, warnings: Vec, 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) { @@ -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 = k.get("keyCodes").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).collect(); @@ -1577,7 +1596,7 @@ fn transition(a: &Kv) -> Option { 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); } diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index c16623e..417b01e 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -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; diff --git a/crates/format/src/fig/network.rs b/crates/format/src/fig/network.rs index cc456af..112a767 100644 --- a/crates/format/src/fig/network.rs +++ b/crates/format/src/fig/network.rs @@ -96,8 +96,7 @@ pub(super) fn decode(b: &[u8]) -> Option { 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 { let mut out = Vec::new(); let u = |v: u32, out: &mut Vec| out.extend_from_slice(&v.to_le_bytes()); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 8636bae..fc0c581 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -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 { diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 5c609a4..8f910f9 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -76,47 +76,7 @@ message Message NodeChange[] nodeChanges, Blob[] blobs "; fn schema() -> Schema { - let lines: Vec> = SCHEMA.lines().map(|l| l.split_whitespace().collect()).filter(|l: &Vec<&str>| !l.is_empty()).collect(); - let index = |name: &str| lines.iter().position(|l| l[1] == name); - let builtin = |t: &str| match t { - "bool" => Some(Type::Bool), - "byte" => Some(Type::Byte), - "int" => Some(Type::Int), - "uint" => Some(Type::Uint), - "float" => Some(Type::Float), - "string" => Some(Type::Str), - "int64" => Some(Type::Int64), - "uint64" => Some(Type::Uint64), - _ => None, - }; - let defs = lines - .iter() - .map(|l| { - let kind = match l[0] { - "enum" => Kind::Enum, - "struct" => Kind::Struct, - _ => Kind::Message, - }; - let fields = if kind == Kind::Enum { - l[2..].iter().enumerate().map(|(i, n)| Field { name: n.to_string(), ty: None, array: false, value: i as u32 }).collect() - } else { - l[2..] - .join(" ") - .split(',') - .enumerate() - .map(|(i, f)| { - let (t, n) = f.trim().split_once(' ').unwrap(); - let array = t.ends_with("[]"); - let t = t.trim_end_matches("[]"); - let ty = builtin(t).unwrap_or_else(|| Type::Def(index(t).unwrap_or_else(|| panic!("type {t}")))); - Field { name: n.to_string(), ty: Some(ty), array, value: i as u32 + 1 } - }) - .collect() - }; - Def { name: l[1].to_string(), kind, fields } - }) - .collect(); - Schema { defs } + Schema::parse(SCHEMA).unwrap() } fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { @@ -188,7 +148,7 @@ fn triangle_blob() -> Vec { } /// A 2×1 PNG. -fn png() -> Vec { +pub(super) fn png() -> Vec { let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255])); let mut out = Vec::new(); img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); @@ -399,7 +359,7 @@ fn canvas(schema: &Schema, msg: &Kv) -> Vec { 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 { +pub(super) fn samples() -> Vec { let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() }; let dir = std::fs::read_dir(dir).into_iter().flatten().flatten(); dir.map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "fig")).collect() diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 9b3bd01..ff0c86d 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -316,12 +316,17 @@ fn parse_json(text: &str) -> Result<(Document, Vec, 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(z: &mut zip::ZipWriter, name: &str, opts: zip::write::SimpleFileOptions, bytes: &[u8]) -> Result<(), FormatError> { +pub(crate) fn put( + z: &mut zip::ZipWriter, + 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) } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 558df7a..8f03b7e 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -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)] diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 9b1aeb2..b0b43df 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -70,7 +70,7 @@ pub(super) fn transform(v: &Value) -> Option { // ---- enums ---- -pub(super) fn blend_mode(s: &str) -> Option { +pub(crate) fn blend_mode(s: &str) -> Option { Some(match s { "PASS_THROUGH" => BlendMode::PassThrough, "NORMAL" => BlendMode::Normal, diff --git a/crates/format/src/rest/proto.rs b/crates/format/src/rest/proto.rs index 33a8a7a..91c3e4c 100644 --- a/crates/format/src/rest/proto.rs +++ b/crates/format/src/rest/proto.rs @@ -516,7 +516,7 @@ fn easing_type(ty: Option<&str>, e: Option<&Value>) -> Easing { } } -fn overlay_position(s: &str) -> Option { +pub(crate) fn overlay_position(s: &str) -> Option { Some(match s { "CENTER" => OverlayPosition::Center, "TOP_LEFT" => OverlayPosition::TopLeft, @@ -537,7 +537,7 @@ fn modifier(code: f64) -> Option { } /// Our key name for a keyboard key code. -fn key_name(code: f64) -> Option { +pub(crate) fn key_name(code: f64) -> Option { if !(code.is_finite() && (0.0..=255.0).contains(&code)) { return None; } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 5c2ebf6..8e1ec73 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -196,11 +196,11 @@ pub fn tool_definitions() -> Vec { 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) -> Result) -> Result 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)) } } } diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c8dd47..3393477 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -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" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 6cae1d0..8ce8430 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -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" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7149f19..40df7a9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 { diff --git a/docs/cli.md b/docs/cli.md index ca38145..3f5d6e5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -31,12 +31,16 @@ page 1 "Screens": 380 nodes, 4 top-level … ``` -### `export -o [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]` +### `export -o [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME] [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]` - `` 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. - `` 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 `` 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 ``'s extension, else png. `--scale` (default 1, max 64) applies to raster formats. - A `.pdf` `` 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 ` @@ -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 -o [--scale N] [--page NAME]` diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2ca9ac0..b2ec551 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 -o .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/`. 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 ``), 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`. | diff --git a/docs/gaps.md b/docs/gaps.md index 6b05b5f..2f9d59c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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 | diff --git a/docs/mcp.md b/docs/mcp.md index 02fda98..eb588d0 100644 --- a/docs/mcp.md +++ b/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 ``, `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 `.`, 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 ``, `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 `.`, 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7c3ab2e..33d175c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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 |