From 0e6dc6871b75945d88bf4832fbca29b9e35def66 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 16:35:36 +0700 Subject: [PATCH] M7: .fig export that opens and draws in other software - The document and its pages now say they are visible and opaque, and a page carries its background opacity and enabled flag. Left out, a reader took them as hidden and drew nothing: every frame was blank and the page background transparent. - The schema written into the file uses the format's own names, kinds (struct or message), field ids and enum values for everything the export writes. The schema text parser takes `NAME=n` and `type field=n`. - Rectangles are written as rounded rectangles with their four radii. Frames, components, instances and sections carry their fill outline; plain strokes carry their stroke outline (a ring around boxes and whole ellipses, the widened path of other centred strokes). - Text carries its layout settings and one line entry per paragraph. Font styles are written under their standard names ("SemiBold" becomes "Semi Bold", "Regular Italic" becomes "Italic"); the import reads both spellings as the same style. - A line height that is a share of the font size is written as a raw multiplier: percent units count from the font's own line height, so 140 % was laid out taller than in FigCraft. - Masks keep their type (alpha, outline, luminance), written and read back. - The import reads per-side stroke weights whose zero sides are left out (a bottom-only border no longer comes back on all four sides). - `meta.json` gives the page colour and the thumbnail size; archive members are in the usual order. Texture effects are written under the format's grain type; WebP export settings are written as PNG with a warning (the format has none). - Docs: `docs/file-format-parity.md`, `docs/gaps.md` (gap 1), `ROADMAP.md`, `docs/target-app-parity.md` (wording only; no number moves). --- ROADMAP.md | 15 +- crates/format/src/fig/export.rs | 395 ++++++++++++++-------- crates/format/src/fig/interop_tests.rs | 451 +++++++++++++++++++++++++ crates/format/src/fig/kiwi.rs | 23 +- crates/format/src/fig/map.rs | 16 +- crates/format/src/fig/mod.rs | 23 ++ crates/format/src/fig/tests.rs | 2 +- crates/format/src/rest/mod.rs | 5 + docs/file-format-parity.md | 55 ++- docs/gaps.md | 13 +- docs/target-app-parity.md | 9 +- 11 files changed, 837 insertions(+), 170 deletions(-) create mode 100644 crates/format/src/fig/interop_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 183537b9..15a8b224 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 (a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (.fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; 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 @@ -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 | ~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) | +| 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 rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (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 | 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) | +| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 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) | @@ -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; then checking the experimental `.fig` export (landed 2026-10-12) on real files: 5–15 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 rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and 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). @@ -137,6 +137,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). idle expiry, reports (Share ▸ Report this file…), `block-ip` and `files list --sort`, `GET /healthz`, and `packaging/server/install.sh` that sets up a Debian/Ubuntu machine with Caddy, ufw and backups ([server.md](docs/server.md#running-a-public-server)). Numbers unchanged (hosting, not a Figma feature). +- **2026-10-12** · `.fig` export opens elsewhere (gap 1): written files were checked by opening them in other + software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft. The + export now says that the document and its pages are visible and opaque, numbers its schema as the format does, + and writes outlines, text settings, standard font style names and line height units; effects such as glass and + noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers + unchanged. - **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 @@ -257,6 +263,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | `.fig` export checked by opening written files in other software (gap 1): layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after fixes to what the export writes (visible document and pages, the format's schema numbering, outlines, text settings, font style names, line height units); effects such as glass and noise, images and prototype interactions still unchecked there. Numbers unchanged | | 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged | | 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | | 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 | diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index dbb45f4b..32c9aeb6 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -8,8 +8,9 @@ //! 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. +//! data, …) is left out and listed in the warnings. Round trips through our own importer are +//! tested, and written files were opened in other software (`docs/file-format-parity.md` says what +//! was checked); other apps may not open every file. use std::collections::BTreeMap; use std::io::Write; @@ -52,100 +53,107 @@ const VERSION: u32 = 101; 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. +/// The schema written into every file: `enum Name A=value …`, `struct Name type field, …` (every +/// field, in order) and `message Name type field=id, …`, with the names, kinds and numbers the +/// format gives them. 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 +enum MessageType JOIN_START=0 NODE_CHANGES=1 +enum NodePhase CREATED=0 REMOVED=1 +enum NodeType NONE=0 DOCUMENT=1 CANVAS=2 GROUP=3 FRAME=4 BOOLEAN_OPERATION=5 VECTOR=6 STAR=7 LINE=8 ELLIPSE=9 RECTANGLE=10 REGULAR_POLYGON=11 ROUNDED_RECTANGLE=12 TEXT=13 SLICE=14 SYMBOL=15 INSTANCE=16 SECTION=25 +enum BlendMode PASS_THROUGH=0 NORMAL=1 DARKEN=2 MULTIPLY=3 LINEAR_BURN=4 COLOR_BURN=5 LIGHTEN=6 SCREEN=7 LINEAR_DODGE=8 COLOR_DODGE=9 OVERLAY=10 SOFT_LIGHT=11 HARD_LIGHT=12 DIFFERENCE=13 EXCLUSION=14 HUE=15 SATURATION=16 COLOR=17 LUMINOSITY=18 +enum PaintType SOLID=0 GRADIENT_LINEAR=1 GRADIENT_RADIAL=2 GRADIENT_ANGULAR=3 GRADIENT_DIAMOND=4 IMAGE=5 +enum ImageScaleMode STRETCH=0 FIT=1 FILL=2 TILE=3 +enum WindingRule NONZERO=0 ODD=1 +enum StrokeAlign CENTER=0 INSIDE=1 OUTSIDE=2 +enum StrokeCap NONE=0 ROUND=1 SQUARE=2 ARROW_LINES=3 ARROW_EQUILATERAL=4 TRIANGLE_FILLED=6 DIAMOND_FILLED=5 CIRCLE_FILLED=14 +enum StrokeJoin MITER=0 BEVEL=1 ROUND=2 +enum ConstraintType MIN=0 CENTER=1 MAX=2 STRETCH=3 SCALE=4 +enum StackMode NONE=0 HORIZONTAL=1 VERTICAL=2 GRID=3 +enum StackAlign MIN=0 CENTER=1 MAX=2 BASELINE=3 +enum StackJustify MIN=0 CENTER=1 MAX=2 SPACE_EVENLY=3 SPACE_BETWEEN=4 SPACE_AROUND=5 +enum StackCounterAlign MIN=0 CENTER=1 MAX=2 STRETCH=3 AUTO=4 BASELINE=5 +enum StackSize FIXED=0 RESIZE_TO_FIT=1 +enum StackWrap NO_WRAP=0 WRAP=1 +enum StackPositioning AUTO=0 ABSOLUTE=1 +enum EffectType INNER_SHADOW=0 DROP_SHADOW=1 FOREGROUND_BLUR=2 BACKGROUND_BLUR=3 NOISE=7 GRAIN=6 GLASS=8 +enum BlurOpType NORMAL=0 PROGRESSIVE=1 +enum NoiseType MONOTONE=1 DUOTONE=2 MULTITONE=0 +enum TextAlignHorizontal LEFT=0 CENTER=1 RIGHT=2 JUSTIFIED=3 +enum TextAlignVertical TOP=0 CENTER=1 BOTTOM=2 +enum TextAutoResize NONE=0 WIDTH_AND_HEIGHT=1 HEIGHT=2 +enum TextTruncation DISABLED=0 ENDING=1 +enum TextCase ORIGINAL=0 UPPER=1 LOWER=2 TITLE=3 SMALL_CAPS=4 +enum TextDecoration NONE=0 UNDERLINE=1 STRIKETHROUGH=2 +enum NumberUnits RAW=0 PIXELS=1 PERCENT=2 +enum LineType PLAIN=0 ORDERED_LIST=1 UNORDERED_LIST=2 +enum SourceDirectionality AUTO=0 LTR=1 RTL=2 +enum DocumentColorProfile LEGACY=0 SRGB=1 DISPLAY_P3=2 +enum EmojiImageSet APPLE=0 NOTO=1 +enum MaskType ALPHA=0 OUTLINE=1 LUMINANCE=2 +enum VariableResolvedDataType BOOLEAN=0 FLOAT=1 STRING=2 COLOR=4 SYMBOL_ID=6 TEXT_DATA=8 +enum VectorMirror NONE=0 ANGLE=1 ANGLE_AND_LENGTH=2 +enum BooleanOperation UNION=0 INTERSECT=1 SUBTRACT=2 XOR=3 +enum LayoutGridType MIN=0 CENTER=1 STRETCH=2 MAX=3 +enum LayoutGridPattern STRIPES=0 GRID=1 +enum Axis X=0 Y=1 +enum ImageType PNG=0 JPEG=1 SVG=2 PDF=3 +enum ExportConstraintType CONTENT_SCALE=0 CONTENT_WIDTH=1 CONTENT_HEIGHT=2 +enum ComponentPropType BOOL=0 TEXT=1 INSTANCE_SWAP=3 VARIANT=4 +enum VariableField MISSING=0 VISIBLE=10 TEXT_DATA=11 OVERRIDDEN_SYMBOL_ID=37 +enum VariableDataType BOOLEAN=0 FLOAT=1 STRING=2 ALIAS=3 COLOR=4 PROP_REF=13 +enum InteractionType ON_CLICK=0 DRAG=9 ON_HOVER=4 ON_PRESS=7 MOUSE_IN=2 MOUSE_OUT=3 MOUSE_DOWN=5 MOUSE_UP=6 AFTER_TIMEOUT=1 ON_KEY_DOWN=10 +enum TriggerDevice KEYBOARD=0 UNKNOWN_CONTROLLER=1 +enum ConnectionType NONE=0 INTERNAL_NODE=1 URL=2 BACK=3 CLOSE=4 +enum NavigationType NAVIGATE=0 OVERLAY=1 SWAP=2 SWAP_STATE=3 SCROLL_TO=4 +enum TransitionType INSTANT_TRANSITION=0 DISSOLVE=1 SMART_ANIMATE=24 SLIDE_FROM_LEFT=3 SLIDE_FROM_RIGHT=4 SLIDE_FROM_TOP=5 SLIDE_FROM_BOTTOM=6 PUSH_FROM_LEFT=7 PUSH_FROM_RIGHT=8 PUSH_FROM_TOP=9 PUSH_FROM_BOTTOM=10 MOVE_FROM_LEFT=11 MOVE_FROM_RIGHT=12 MOVE_FROM_TOP=13 MOVE_FROM_BOTTOM=14 SLIDE_OUT_TO_LEFT=15 SLIDE_OUT_TO_RIGHT=16 SLIDE_OUT_TO_TOP=17 SLIDE_OUT_TO_BOTTOM=18 MOVE_OUT_TO_LEFT=19 MOVE_OUT_TO_RIGHT=20 MOVE_OUT_TO_TOP=21 MOVE_OUT_TO_BOTTOM=22 +enum EasingType IN_CUBIC=0 OUT_CUBIC=1 INOUT_CUBIC=2 LINEAR=3 IN_BACK_CUBIC=4 OUT_BACK_CUBIC=5 INOUT_BACK_CUBIC=6 CUSTOM_CUBIC=7 GENTLE_SPRING=9 +enum ScrollDirection NONE=0 HORIZONTAL=1 VERTICAL=2 BOTH=3 +enum ScrollBehavior SCROLLS=0 FIXED_WHEN_CHILD_OF_SCROLLING_FRAME=1 STICKY_SCROLLS=2 +enum OverlayPositionType CENTER=0 TOP_LEFT=1 TOP_CENTER=2 TOP_RIGHT=3 BOTTOM_LEFT=4 BOTTOM_CENTER=5 BOTTOM_RIGHT=6 MANUAL=7 +enum OverlayBackgroundInteraction NONE=0 CLOSE_ON_CLICK_OUTSIDE=1 +enum OverlayBackgroundType NONE=0 SOLID_COLOR=1 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 +struct ParentIndex GUID guid, string position +message Image byte[] hash=1, string name=2 +message Paint PaintType type=1, Color color=2, float opacity=3, bool visible=4, BlendMode blendMode=5, ColorStop[] stops=6, Matrix transform=7, Image image=8, ImageScaleMode imageScaleMode=10, float rotation=11, float scale=12 +message Effect EffectType type=1, Color color=2, Vector offset=3, float radius=4, bool visible=5, BlendMode blendMode=6, float spread=7, bool showShadowBehindNode=8, BlurOpType blurOpType=19, float startRadius=21, Vector startOffset=20, Vector endOffset=28, NoiseType noiseType=26, Vector noiseSize=22, float density=25, float opacity=27, Color secondaryColor=24, bool clipToShape=23 +struct FontName string family, string style, string postscript +struct Number float value, NumberUnits units +message Hyperlink string url=1 +message TextLineData LineType lineType=1, int styleId=10, int indentationLevel=2, SourceDirectionality sourceDirectionality=9, int listStartOffset=7, bool isFirstLineOfList=8 +message TextData string characters=1, uint[] characterStyleIDs=2, NodeChange[] styleOverrideTable=3, TextLineData[] lines=12 +message Path WindingRule windingRule=1, uint commandsBlob=2, uint styleID=3 +message VectorData uint vectorNetworkBlob=1, Vector normalizedSize=2, NodeChange[] styleOverrideTable=3 +message ArcData float startingAngle=1, float endingAngle=2, float innerRadius=3 +message OptionalVector Vector value=1 +message LayoutGrid LayoutGridType type=1, Axis axis=2, bool visible=3, int numSections=4, float offset=5, float sectionSize=6, float gutterSize=7, Color color=8, LayoutGridPattern pattern=9 +struct ExportConstraint ExportConstraintType type, float value +message ExportSettings string suffix=1, ImageType imageType=2, ExportConstraint constraint=3 +message GUIDPath GUID[] guids=1 +message SymbolData GUID symbolID=1, NodeChange[] symbolOverrides=2 +message ComponentPropValue bool boolValue=1, TextData textValue=2, GUID guidValue=3 +message ComponentPropDef GUID id=1, string name=2, ComponentPropValue initialValue=3, ComponentPropType type=6 +message ComponentPropAssignment GUID defID=1, ComponentPropValue value=2 +message PropRefValue GUID defId=1 +message VariableAnyValue PropRefValue propRefValue=13 +message VariableData VariableAnyValue value=1, VariableDataType dataType=2, VariableResolvedDataType resolvedDataType=3 +message VariableDataMapEntry VariableData variableData=2, VariableField variableField=3 +message VariableDataMap VariableDataMapEntry[] entries=1 +message KeyTrigger int[] keyCodes=1, TriggerDevice triggerDevice=2 +message PrototypeEvent InteractionType interactionType=1, float transitionTimeout=6, KeyTrigger keyTrigger=4 +message PrototypeAction GUID transitionNodeID=1, TransitionType transitionType=2, float transitionDuration=3, EasingType easingType=4, float[] easingFunction=12, ConnectionType connectionType=7, NavigationType navigationType=10, string connectionURL=8, bool transitionPreserveScroll=11 +message PrototypeInteraction PrototypeEvent event=2, PrototypeAction[] actions=3 +message PrototypeStartingPoint string name=1, string position=3 +message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Color backgroundColor=2 +message GUIDPositionMapEntry GUID id=1, string position=2 +message GUIDPositionMap GUIDPositionMapEntry[] entries=1 +message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, SymbolData symbolData=113, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 +struct Blob byte[] bytes +message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6 "; /// Write `doc` as a `.fig` file, with `thumbnail` (a PNG) when given. Returns the bytes and the @@ -158,7 +166,11 @@ pub fn export(doc: &Document, thumbnail: Option<&[u8]>) -> Result<(Vec, Vec< 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)?; + let background = doc.pages.first().and_then(|p| doc.node(*p)).and_then(|p| match &p.kind { + NodeKind::Page { background, .. } => Some(*background), + _ => None, + }); + let bytes = archive(&doc.name, background.unwrap_or(Color::WHITE), &canvas, &x.files, thumbnail)?; Ok((bytes, x.warnings)) } @@ -185,17 +197,29 @@ fn canvas(schema: &[u8], message: &[u8]) -> Result, FormatError> { } /// The zip around `canvas.fig`. -fn archive(name: &str, canvas: &[u8], images: &[(String, Arc>)], thumbnail: Option<&[u8]>) -> Result, FormatError> { +fn archive( + name: &str, + background: Color, + 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 client = serde_json::json!({"background_color": {"r": background.r, "g": background.g, "b": background.b, "a": background.a}}); + // A PNG says its size right after its signature and header chunk name. + let be = |at: usize| thumbnail.and_then(|t| t.get(at..at + 4)).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes); + if let (Some(w), Some(h)) = (be(16), be(20)) { + client["thumbnail_size"] = serde_json::json!({"width": w, "height": h}); + } + let meta = serde_json::to_vec(&serde_json::json!({"client_meta": client, "file_name": name})).map_err(crate::io_err)?; + crate::put(&mut z, "meta.json", crate::json_options(), &meta)?; let mut total = canvas.len() as u64; for (hash, bytes) in images { total += bytes.len() as u64; @@ -249,6 +273,18 @@ 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))]) } +/// What the document and its pages say besides their names: left out, a reader takes them as +/// hidden and fully transparent, and draws nothing below them. +fn root_fields() -> Fields { + vec![ + ("visible", b(true)), + ("opacity", fl(1.0)), + ("transform", matrix(Affine::IDENTITY)), + ("strokeWeight", fl(0.0)), + ("strokeAlign", e("CENTER")), + ("strokeJoin", e("BEVEL")), + ] +} /// 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 } @@ -402,7 +438,11 @@ impl<'d> Exporter<'d> { 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))])); + let profile = if doc.color_profile == figcraft_doc::color::ColorProfile::DisplayP3 { "DISPLAY_P3" } else { "SRGB" }; + let mut root: Fields = vec![("guid", guid(0, 0)), ("phase", e("CREATED")), ("type", e("DOCUMENT")), ("name", s(&doc.name))]; + root.extend(root_fields()); + root.push(("documentColorProfile", e(profile))); + self.nodes.push(o(root)); for (i, p) in doc.pages.iter().enumerate() { self.emit(*p, guid(0, 0), i, None, 0); } @@ -493,7 +533,9 @@ impl<'d> Exporter<'d> { ("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))]); + f.extend([("type", e("CANVAS")), ("name", s(&n.name))]); + f.extend(root_fields()); + f.extend([("backgroundColor", color(*background)), ("backgroundOpacity", fl(background.a.into())), ("backgroundEnabled", b(true))]); if !guides.is_empty() { self.warn("ruler guides were left out"); } @@ -531,7 +573,7 @@ impl<'d> Exporter<'d> { NodeKind::Page { .. } | NodeKind::Frame | NodeKind::Group | NodeKind::ComponentSet { .. } => "FRAME", NodeKind::Instance { .. } if main.is_none() => "FRAME", NodeKind::Section { .. } => "SECTION", - NodeKind::Rectangle => "RECTANGLE", + NodeKind::Rectangle => "ROUNDED_RECTANGLE", NodeKind::Ellipse { .. } => "ELLIPSE", NodeKind::Polygon { .. } => "REGULAR_POLYGON", NodeKind::Star { .. } => "STAR", @@ -595,11 +637,13 @@ impl<'d> Exporter<'d> { } _ => {} } - if matches!(n.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. }) + let boxy = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle | NodeKind::Section { .. }); + if (boxy || matches!(n.kind, NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. })) && let Some(p) = figcraft_doc::geometry::shape_path(n) { - self.geometry(&p, WindingRule::NonZero, f); + self.geometry("fillGeometry", &p, WindingRule::NonZero, f); } + self.stroke_geometry(n, boxy, f); main.is_none() } @@ -614,10 +658,12 @@ impl<'d> Exporter<'d> { ("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"); - } + let ty = match n.mask_type { + MaskType::Alpha => "ALPHA", + MaskType::Vector => "OUTLINE", + MaskType::Luminance => "LUMINANCE", + }; + f.extend([("mask", b(true)), ("maskType", e(ty))]); } // Text whose own style sets fills paints with them (the layer's fills only show through runs). let fills = match &n.kind { @@ -635,9 +681,10 @@ impl<'d> Exporter<'d> { 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]) { + let differ = r.iter().any(|x| *x != r[0]); + if n.kind.has_independent_corners() && (differ || r[0] > 0.0) { + f.extend(differ.then(|| ("rectangleCornerRadiiIndependent", b(true)))); f.extend([ - ("rectangleCornerRadiiIndependent", b(true)), ("rectangleTopLeftCornerRadius", fl(r[0])), ("rectangleTopRightCornerRadius", fl(r[1])), ("rectangleBottomRightCornerRadius", fl(r[2])), @@ -676,6 +723,9 @@ impl<'d> Exporter<'d> { } self.child_layout(n, parent, f); if !n.exports.is_empty() { + if n.exports.iter().any(|x| x.format == figcraft_doc::ExportFormat::Webp) { + self.warn("WebP export settings were written as PNG"); + } f.push(("exportSettings", arr(n.exports.iter().map(export_setting).collect()))); } self.prototype(n, f); @@ -888,7 +938,7 @@ impl<'d> Exporter<'d> { } EffectKind::Texture(t) => { f.extend([ - ("type", e("TEXTURE")), + ("type", e("GRAIN")), ("noiseSize", grain(t.size, t.size_y)), ("radius", fl(t.radius)), ("clipToShape", b(t.clip_to_shape)), @@ -1033,6 +1083,16 @@ impl<'d> Exporter<'d> { ("textAutoResize", e(resize)), ("paragraphSpacing", fl(td.paragraph_spacing)), ("paragraphIndent", fl(td.paragraph_indent)), + ("autoRename", b(false)), + ("fontVariantCommonLigatures", b(true)), + ("fontVariantContextualLigatures", b(true)), + ("fontVersion", s("")), + ("textUserLayoutVersion", u(5)), + ("textExplicitLayoutVersion", u(1)), + ("textBidiVersion", u(1)), + ("textTracking", fl(0.0)), + ("textDecorationSkipInk", b(true)), + ("emojiImageSet", e("APPLE")), ]); if td.auto_resize == TextAutoResize::Truncate { f.push(("textTruncation", e("ENDING"))); @@ -1090,19 +1150,25 @@ impl<'d> Exporter<'d> { 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", - }), - )]) + // One entry per paragraph, each with every field a reader expects. + let line = |(i, l): (usize, &ListStyle)| { + let ty = match l { + ListStyle::None => "PLAIN", + ListStyle::Numbered => "ORDERED_LIST", + ListStyle::Bulleted => "UNORDERED_LIST", }; - data.push(("lines", arr(lists.iter().map(line).collect()))); - } + let listed = *l != ListStyle::None; + let first = listed && (i == 0 || lists.get(i - 1) != Some(l)); + o(vec![ + ("lineType", e(ty)), + ("styleId", int(0)), + ("indentationLevel", int(i64::from(listed))), + ("sourceDirectionality", e("AUTO")), + ("listStartOffset", int(0)), + ("isFirstLineOfList", b(first)), + ]) + }; + data.push(("lines", arr(lists.iter().enumerate().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"); @@ -1216,18 +1282,77 @@ impl<'d> Exporter<'d> { 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); + self.geometry("fillGeometry", &net.fill_path(), winding, f); } } - /// `fillGeometry` from a local path. - fn geometry(&mut self, p: &BezPath, winding: WindingRule, f: &mut Fields) { + /// `fillGeometry` or `strokeGeometry` (`key`) from a local path. + fn geometry(&mut self, key: &'static str, 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))])]))); + f.push((key, arr(vec![o(vec![("windingRule", e(rule)), ("commandsBlob", u(i)), ("styleID", u(0))])]))); + } + } + + /// `strokeGeometry`, the outline of a plain stroke: a ring around boxes and whole ellipses, the + /// widened path of other centred strokes. Dashed, per-side and other strokes get none (a reader + /// builds theirs from the stroke settings). + fn stroke_geometry(&mut self, n: &Node, boxy: bool, f: &mut Fields) { + let sp = &n.stroke; + let (w, h, weight) = (n.width, n.height, sp.weight); + let plain = n.strokes.iter().any(|p| p.visible) && sp.dashes.is_empty() && sp.sides.is_none(); + if !plain || !(weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) { + return; + } + let whole = matches!(n.kind, NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && sweep.abs() >= std::f64::consts::TAU - 1e-9); + if boxy || whole { + let (out, inset) = match sp.align { + StrokeAlign::Inside => (0.0, weight), + StrokeAlign::Center => (weight / 2.0, weight / 2.0), + StrokeAlign::Outside => (weight, 0.0), + }; + // The shape grown by `d` (shrunk when negative), or nothing when it shrinks away. + let grown = |d: f64| -> Option { + let (gw, gh) = (w + 2.0 * d, h + 2.0 * d); + if gw <= 0.0 || gh <= 0.0 { + return None; + } + let mut p = if whole { + figcraft_doc::geom::ellipse(gw, gh, 0.0, std::f64::consts::TAU, 0.0) + } else { + let radii = n.corners.radii.map(|r| if r > 0.0 { (r + d).max(0.0) } else { 0.0 }); + figcraft_doc::geom::rounded_rect(gw, gh, radii, n.corners.smoothing) + }; + p.apply_affine(Affine::translate((-d, -d))); + Some(p) + }; + let Some(mut ring) = grown(out) else { return }; + ring.extend(grown(-inset).iter().flat_map(|p| p.elements().iter().copied())); + self.geometry("strokeGeometry", &ring, WindingRule::EvenOdd, f); + } else if sp.align == StrokeAlign::Center { + let path = match &n.kind { + NodeKind::Vector { network } => Some(network.stroke_path()), + NodeKind::Text(_) => None, + _ => figcraft_doc::geometry::shape_path(n), + }; + let Some(path) = path.filter(|p| !p.elements().is_empty()) else { return }; + use figcraft_doc::geom::kurbo::{Cap, Join, Stroke, StrokeOpts, stroke}; + let cap = match sp.cap_start { + StrokeCap::Round => Cap::Round, + StrokeCap::Square => Cap::Square, + _ => Cap::Butt, + }; + let join = match sp.join { + StrokeJoin::Miter => Join::Miter, + StrokeJoin::Bevel => Join::Bevel, + StrokeJoin::Round => Join::Round, + }; + let limit = if sp.miter_limit.is_finite() { sp.miter_limit.max(1.0) } else { 4.0 }; + let style = Stroke::new(weight).with_caps(cap).with_join(join).with_miter_limit(limit); + self.geometry("strokeGeometry", &stroke(path, &style, &StrokeOpts::default(), 0.05), WindingRule::NonZero, f); } } @@ -1266,17 +1391,21 @@ impl<'d> Exporter<'d> { /// 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"), + let (var_field, ty, resolved) = match field { + "visible" => ("VISIBLE", "BOOL", "BOOLEAN"), + "characters" => ("TEXT_DATA", "TEXT", "TEXT_DATA"), + "mainComponent" => ("OVERRIDDEN_SYMBOL_ID", "INSTANCE_SWAP", "SYMBOL_ID"), _ => { 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"))]); + let data = o(vec![ + ("value", o(vec![("propRefValue", o(vec![("defId", guid(AUX, id))]))])), + ("dataType", e("PROP_REF")), + ("resolvedDataType", e(resolved)), + ]); Some(o(vec![("variableData", data), ("variableField", e(var_field))])) } @@ -1590,10 +1719,10 @@ fn key_codes(key: &str) -> Option<(Vec, bool)> { /// 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. + // Percent units count from the font's own line height (100 % is "auto"); a share of the + // font size is a raw multiplier. 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::Percent(p) => o(vec![("value", fl(p / 100.0)), ("units", e("RAW"))]), LineHeight::Pixels(v) => o(vec![("value", fl(v)), ("units", e("PIXELS"))]), }; let letter_spacing = match st.letter_spacing { @@ -1613,7 +1742,7 @@ fn style_fields(st: &TextStyle, f: &mut Fields) { TextDecoration::Strikethrough => "STRIKETHROUGH", }; f.extend([ - ("fontName", o(vec![("family", s(&st.font_family)), ("style", s(&st.font_style)), ("postscript", s(""))])), + ("fontName", o(vec![("family", s(&st.font_family)), ("style", s(&super::style_name(&st.font_style))), ("postscript", s(""))])), ("fontSize", fl(st.font_size)), ("lineHeight", line_height), ("letterSpacing", letter_spacing), @@ -1652,11 +1781,11 @@ fn layout_grid(g: &figcraft_doc::LayoutGrid) -> Kv<'static> { fn export_setting(x: &figcraft_doc::ExportSetting) -> Kv<'static> { let format = match x.format { - figcraft_doc::ExportFormat::Png => "PNG", + // The format has no WebP export setting. + figcraft_doc::ExportFormat::Png | figcraft_doc::ExportFormat::Webp => "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), diff --git a/crates/format/src/fig/interop_tests.rs b/crates/format/src/fig/interop_tests.rs new file mode 100644 index 00000000..b51760c5 --- /dev/null +++ b/crates/format/src/fig/interop_tests.rs @@ -0,0 +1,451 @@ +//! What other readers of `.fig` files need from the export: the document and its pages say they +//! are visible and opaque, every drawn layer carries its outline, text carries its layout +//! settings, and the schema written into the file numbers its definitions, fields and enum values +//! the way the format does. + +use figcraft_doc::color::{Color, Paint}; +use figcraft_doc::*; + +use super::kiwi::{Kind, Kv, Schema, Type}; +use super::*; + +/// One page with a filled frame holding a rectangle, a stroked ellipse, a centred line and text. +fn sample() -> Document { + let mut doc = Document::new("Interop"); + let page = doc.first_page().unwrap(); + let add = |doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str, w: f64, h: f64, edit: &dyn Fn(&mut Node)| { + let id = doc.create(parent, kind, name, 10.0, 10.0, w, h).unwrap(); + let n = doc.node_mut(id).unwrap(); + n.fills = vec![Paint::solid(Color::new(1.0, 0.0, 0.0, 1.0))]; + n.strokes.clear(); + edit(n); + id + }; + let frame = add(&mut doc, page, NodeKind::Frame, "Frame", 200.0, 200.0, &|_| {}); + add(&mut doc, frame, NodeKind::Rectangle, "Rect", 80.0, 60.0, &|n| n.corners.radii = [6.0; 4]); + add(&mut doc, frame, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Ellipse", 70.0, 70.0, &|n| { + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 4.0; + n.stroke.align = StrokeAlign::Inside; + }); + add(&mut doc, frame, NodeKind::Line, "Line", 50.0, 0.0, &|n| { + n.fills.clear(); + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 2.0; + n.stroke.align = StrokeAlign::Center; + }); + add(&mut doc, frame, NodeKind::Text(TextData::new("One\nTwo")), "Text", 60.0, 40.0, &|_| {}); + doc +} + +/// The node changes of the written file, by type name. +fn with_changes(doc: &Document, check: impl Fn(&dyn Fn(&str) -> Kv<'static>, &Kv<'static>)) { + let (bytes, _) = export(doc, Some(&super::tests::png())).unwrap(); + let archive = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&archive.canvas).unwrap(); + // Decoded values borrow their names from the schema. + let schema: &'static Schema = Box::leak(Box::new(schema)); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let of = |ty: &str| { + let found = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("type").and_then(Kv::str) == Some(ty)); + found.unwrap_or_else(|| panic!("no {ty} was written")).clone() + }; + check(&of, &msg); +} + +fn num(v: &Kv, key: &str) -> f64 { + v.get(key).and_then(Kv::f64).unwrap_or_else(|| panic!("{key} is missing")) +} + +fn flag(v: &Kv, key: &str) -> bool { + v.get(key).and_then(Kv::bool).unwrap_or_else(|| panic!("{key} is missing")) +} + +fn name<'a>(v: &'a Kv, key: &str) -> &'a str { + v.get(key).and_then(Kv::str).unwrap_or_else(|| panic!("{key} is missing")) +} + +#[test] +fn the_document_and_its_pages_are_visible_and_opaque() { + with_changes(&sample(), |of, _| { + for ty in ["DOCUMENT", "CANVAS"] { + let n = of(ty); + assert!(flag(&n, "visible"), "{ty}"); + assert_eq!(num(&n, "opacity"), 1.0, "{ty}"); + assert_eq!(num(n.get("transform").unwrap(), "m00"), 1.0, "{ty}"); + assert_eq!(num(n.get("transform").unwrap(), "m11"), 1.0, "{ty}"); + assert_eq!(num(&n, "strokeWeight"), 0.0, "{ty}"); + assert_eq!((name(&n, "strokeAlign"), name(&n, "strokeJoin")), ("CENTER", "BEVEL"), "{ty}"); + } + assert_eq!(name(&of("DOCUMENT"), "documentColorProfile"), "SRGB"); + let page = of("CANVAS"); + assert!(flag(&page, "backgroundEnabled")); + assert_eq!(num(&page, "backgroundOpacity"), 1.0); + assert_eq!(num(page.get("backgroundColor").unwrap(), "a"), 1.0); + }); +} + +#[test] +fn the_document_colour_profile_and_page_opacity_follow_the_document() { + let mut doc = sample(); + doc.color_profile = figcraft_doc::color::ColorProfile::DisplayP3; + let page = doc.first_page().unwrap(); + if let Some(NodeKind::Page { background, .. }) = doc.node_mut(page).map(|n| &mut n.kind) { + *background = Color::new(0.5, 0.5, 0.5, 0.25); + } + with_changes(&doc, |of, _| { + assert_eq!(name(&of("DOCUMENT"), "documentColorProfile"), "DISPLAY_P3"); + assert_eq!(num(&of("CANVAS"), "backgroundOpacity"), 0.25); + }); +} + +#[test] +fn drawn_layers_carry_their_outlines() { + with_changes(&sample(), |of, msg| { + let blobs = msg.get("blobs").unwrap().list(); + let path = |n: &Kv, key: &str| -> (String, Vec) { + let list = n.get(key).map(Kv::list).unwrap_or_default(); + let p = list.first().unwrap_or_else(|| panic!("{key} is missing")); + assert_eq!(num(p, "styleID"), 0.0); + let blob = blobs.get(num(p, "commandsBlob") as usize).and_then(|b| b.get("bytes")).and_then(Kv::bytes).unwrap(); + (name(p, "windingRule").to_string(), blob.to_vec()) + }; + // Sub-paths (move commands) of a geometry blob. + let moves = |blob: &[u8]| { + let (mut i, mut n) = (0, 0); + while let Some(cmd) = blob.get(i) { + n += usize::from(*cmd == 1); + i += 1 + 8 * [0, 1, 1, 2, 3].get(usize::from(*cmd)).copied().unwrap_or(0); + } + n + }; + for ty in ["FRAME", "ROUNDED_RECTANGLE", "ELLIPSE"] { + let (rule, blob) = path(&of(ty), "fillGeometry"); + assert_eq!(rule, "NONZERO", "{ty}"); + assert_eq!(moves(&blob), 1, "{ty}"); + } + // An inside stroke is the ring between the shape and the shape moved in by the weight. + let (rule, ring) = path(&of("ELLIPSE"), "strokeGeometry"); + assert_eq!((rule.as_str(), moves(&ring)), ("ODD", 2)); + let (rule, line) = path(&of("LINE"), "strokeGeometry"); + assert_eq!(rule, "NONZERO"); + assert!(moves(&line) >= 1); + // Nothing is stroked: no stroke outline. + assert!(of("FRAME").get("strokeGeometry").is_none()); + assert!(of("LINE").get("fillGeometry").is_none()); + }); +} + +#[test] +fn strokes_without_a_simple_outline_get_none() { + let mut doc = sample(); + let ids: Vec = doc.ids().collect(); + for id in ids { + if let Some(n) = doc.node_mut(id).filter(|n| !matches!(n.kind, NodeKind::Page { .. })) { + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 3.0; + n.stroke.dashes = vec![4.0, 2.0]; + } + } + with_changes(&doc, |of, _| { + for ty in ["FRAME", "ROUNDED_RECTANGLE", "ELLIPSE", "LINE", "TEXT"] { + assert!(of(ty).get("strokeGeometry").is_none(), "{ty}"); + } + }); + // A stroke wider than the shape fills it; sizes that aren't numbers write nothing. + let mut doc = sample(); + let ids: Vec = doc.ids().collect(); + for (i, id) in ids.into_iter().enumerate() { + if let Some(n) = doc.node_mut(id).filter(|n| !matches!(n.kind, NodeKind::Page { .. })) { + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = if i % 2 == 0 { 1e6 } else { f64::NAN }; + n.stroke.align = [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside][i % 3]; + if i % 4 == 0 { + n.width = f64::INFINITY; + } + } + } + export(&doc, None).unwrap(); +} + +#[test] +fn rectangles_say_their_corners() { + with_changes(&sample(), |of, _| { + let r = of("ROUNDED_RECTANGLE"); + assert_eq!(num(&r, "cornerRadius"), 6.0); + for key in + ["rectangleTopLeftCornerRadius", "rectangleTopRightCornerRadius", "rectangleBottomRightCornerRadius", "rectangleBottomLeftCornerRadius"] + { + assert_eq!(num(&r, key), 6.0, "{key}"); + } + assert!(r.get("rectangleCornerRadiiIndependent").is_none()); + }); +} + +#[test] +fn text_carries_its_layout_settings_and_a_line_per_paragraph() { + with_changes(&sample(), |of, _| { + let t = of("TEXT"); + assert!(!flag(&t, "autoRename")); + assert!(flag(&t, "fontVariantCommonLigatures") && flag(&t, "fontVariantContextualLigatures") && flag(&t, "textDecorationSkipInk")); + assert_eq!(name(&t, "fontVersion"), ""); + assert_eq!((num(&t, "textUserLayoutVersion"), num(&t, "textExplicitLayoutVersion"), num(&t, "textBidiVersion")), (5.0, 1.0, 1.0)); + assert_eq!(num(&t, "textTracking"), 0.0); + assert_eq!(name(&t, "emojiImageSet"), "APPLE"); + let lines = t.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap_or_default(); + assert_eq!(lines.len(), 2); + for l in lines { + assert_eq!((name(l, "lineType"), name(l, "sourceDirectionality")), ("PLAIN", "AUTO")); + assert_eq!((num(l, "styleId"), num(l, "indentationLevel"), num(l, "listStartOffset")), (0.0, 0.0, 0.0)); + assert!(!flag(l, "isFirstLineOfList")); + } + }); +} + +#[test] +fn font_styles_are_written_as_words_and_both_spellings_read_back_the_same() { + for (ours, written) in [ + ("SemiBold", "Semi Bold"), + ("Semi Bold", "Semi Bold"), + ("semi-bold", "Semi Bold"), + ("ExtraBold", "Extra Bold"), + ("ExtraLight", "Extra Light"), + ("UltraLight", "Ultra Light"), + ("DemiBold", "Demi Bold"), + ("SemiBoldItalic", "Semi Bold Italic"), + ("SemiBold Italic", "Semi Bold Italic"), + ("BoldItalic", "Bold Italic"), + ("Regular Italic", "Italic"), + ("Italic", "Italic"), + ("Regular", "Regular"), + ("Thin", "Thin"), + ("Black", "Black"), + // Names that aren't the standard weights stay as written. + ("Condensed SemiBold", "Condensed SemiBold"), + ("Heavy", "Heavy"), + ("Oblique", "Oblique"), + ("", ""), + ("Ünïcode Ⅻ", "Ünïcode Ⅻ"), + ] { + assert_eq!(style_name(ours), written, "{ours}"); + assert_eq!(style_name(written), written, "{written} stays"); + } + // The layer's own style and a styled run are both written as words, and read back as them. + let mut doc = sample(); + let text = doc.ids().find(|id| doc.node(*id).is_some_and(|n| n.kind.text().is_some())).unwrap(); + if let Some(NodeKind::Text(td)) = doc.node_mut(text).map(|n| &mut n.kind) { + td.style.font_style = "SemiBold".into(); + let mut run = td.style.clone(); + run.font_style = "ExtraBoldItalic".into(); + td.runs = vec![StyleRun { start: 0, end: 3, style: run }]; + } + with_changes(&doc, |of, _| { + let t = of("TEXT"); + let font = |v: &Kv| v.get("fontName").map(|f| (name(f, "style").to_string(), name(f, "postscript").to_string())).unwrap(); + assert_eq!(font(&t), ("Semi Bold".to_string(), String::new())); + let table = t.get("textData").and_then(|d| d.get("styleOverrideTable")).map(Kv::list).unwrap_or_default(); + assert_eq!(table.iter().map(font).collect::>(), [("Extra Bold Italic".to_string(), String::new())]); + }); + let (bytes, _) = export(&doc, None).unwrap(); + let (back, _) = import(&bytes, "x").unwrap(); + let td = back.ids().find_map(|id| back.node(id).and_then(|n| n.kind.text().cloned())).unwrap(); + assert_eq!(td.style.font_style, "Semi Bold"); + assert_eq!(td.runs.iter().map(|r| r.style.font_style.as_str()).collect::>(), ["Extra Bold Italic"]); + // A file that spells the style compactly reads as the same style. + fn respell(v: &mut Kv) { + match v { + Kv::Str(s) if s == "Semi Bold" => *s = "SemiBold".into(), + Kv::Array(a) => a.iter_mut().for_each(respell), + Kv::Object(f) => f.iter_mut().for_each(|(_, x)| respell(x)), + _ => {} + } + } + let (compact, _) = import(&rewritten(&doc, &|msg| respell(msg)), "x").unwrap(); + let td = compact.ids().find_map(|id| compact.node(id).and_then(|n| n.kind.text().cloned())).unwrap(); + assert_eq!(td.style.font_style, "Semi Bold"); +} + +/// The exported file with its message changed by `change`, as a bare canvas. +fn rewritten(doc: &Document, change: &dyn Fn(&mut Kv)) -> Vec { + let (bytes, _) = export(doc, None).unwrap(); + let (schema, message) = decode_canvas(&Archive::open(&bytes).unwrap().canvas).unwrap(); + let mut msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + change(&mut msg); + super::tests::canvas(&schema, &msg) +} + +type NodeFields<'a, 's> = &'a mut Vec<(&'s str, Kv<'s>)>; + +fn each_node(msg: &mut Kv, f: &dyn Fn(NodeFields)) { + if let Kv::Object(top) = msg { + for (k, v) in top.iter_mut() { + if let (true, Kv::Array(nodes)) = (*k == "nodeChanges", v) { + for n in nodes.iter_mut() { + if let Kv::Object(fields) = n { + f(fields); + } + } + } + } + } +} + +#[test] +fn line_heights_say_what_they_count_from() { + for (ours, value, units) in [ + (LineHeight::Auto, 100.0, "PERCENT"), + (LineHeight::Percent(140.0), 1.4, "RAW"), + (LineHeight::Percent(100.0), 1.0, "RAW"), + (LineHeight::Pixels(20.0), 20.0, "PIXELS"), + ] { + let mut doc = sample(); + let text = doc.ids().find(|id| doc.node(*id).is_some_and(|n| n.kind.text().is_some())).unwrap(); + if let Some(NodeKind::Text(td)) = doc.node_mut(text).map(|n| &mut n.kind) { + td.style.line_height = ours; + } + with_changes(&doc, |of, _| { + let t = of("TEXT"); + let lh = t.get("lineHeight").unwrap(); + assert!((num(lh, "value") - value).abs() < 1e-6, "{ours:?}"); + assert_eq!(name(lh, "units"), units, "{ours:?}"); + }); + let (bytes, _) = export(&doc, None).unwrap(); + let (back, _) = import(&bytes, "x").unwrap(); + let td = back.ids().find_map(|id| back.node(id).and_then(|n| n.kind.text().cloned())).unwrap(); + match (td.style.line_height, ours) { + (LineHeight::Percent(a), LineHeight::Percent(b)) => assert!((a - b).abs() < 1e-3, "{ours:?}"), + (a, b) => assert_eq!(a, b), + } + } +} + +#[test] +fn sides_without_a_weight_have_no_border() { + let mut doc = sample(); + let frame = doc.ids().find(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Frame))).unwrap(); + if let Some(n) = doc.node_mut(frame) { + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 1.0; + n.stroke.sides = Some([0.0, 0.0, 1.0, 0.0]); + } + let sides = |bytes: &[u8]| { + let (back, _) = import(bytes, "x").unwrap(); + back.ids().find_map(|id| back.node(id).filter(|n| matches!(n.kind, NodeKind::Frame)).and_then(|n| n.stroke.sides)) + }; + assert_eq!(sides(&export(&doc, None).unwrap().0), Some([0.0, 0.0, 1.0, 0.0])); + // Other writers leave the sides without a border out. + let sparse = + rewritten(&doc, &|msg| each_node(msg, &|f| f.retain(|(k, _)| !matches!(*k, "borderTopWeight" | "borderRightWeight" | "borderLeftWeight")))); + assert_eq!(sides(&sparse), Some([0.0, 0.0, 1.0, 0.0])); +} + +#[test] +fn mask_types_round_trip() { + for ty in [MaskType::Alpha, MaskType::Vector, MaskType::Luminance] { + let mut doc = sample(); + let rect = doc.ids().find(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Rectangle))).unwrap(); + if let Some(n) = doc.node_mut(rect) { + (n.is_mask, n.mask_type) = (true, ty); + } + with_changes(&doc, |of, _| { + let want = ["ALPHA", "OUTLINE", "LUMINANCE"][ty as usize]; + assert_eq!(name(&of("ROUNDED_RECTANGLE"), "maskType"), want); + assert!(of("FRAME").get("maskType").is_none()); + }); + let (bytes, warnings) = export(&doc, None).unwrap(); + assert!(!warnings.iter().any(|w| w.contains("mask")), "{warnings:?}"); + let (back, _) = import(&bytes, "x").unwrap(); + let masks: Vec = back.ids().filter_map(|id| back.node(id)).filter(|n| n.is_mask).map(|n| n.mask_type).collect(); + assert_eq!(masks, [ty]); + } +} + +#[test] +fn the_archive_lists_its_members_and_describes_the_file() { + let (bytes, _) = export(&sample(), Some(&super::tests::png())).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap(); + let names: Vec = (0..z.len()).map(|i| z.by_index(i).unwrap().name().to_string()).collect(); + assert_eq!(names, ["canvas.fig", "thumbnail.png", "meta.json"]); + let meta: serde_json::Value = serde_json::from_slice(&crate::read_member(&mut z, "meta.json", 1 << 20).unwrap()).unwrap(); + assert_eq!(meta["file_name"], "Interop"); + assert!(meta["client_meta"]["background_color"]["a"].is_number()); + assert!(meta["client_meta"]["thumbnail_size"]["width"].as_u64().is_some_and(|w| w > 0)); + // No thumbnail, or one that isn't a PNG: no size. + for thumb in [None, Some(&b"png"[..])] { + let (bytes, _) = export(&sample(), thumb).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap(); + let meta: serde_json::Value = serde_json::from_slice(&crate::read_member(&mut z, "meta.json", 1 << 20).unwrap()).unwrap(); + assert!(meta["client_meta"]["thumbnail_size"].is_null()); + } +} + +#[test] +fn the_schema_numbers_what_the_format_numbers() { + let s = Schema::parse(export::SCHEMA).unwrap(); + let def = |n: &str| s.defs.iter().find(|d| d.name == n).unwrap_or_else(|| panic!("no {n}")); + for n in ["GUID", "Color", "Vector", "Matrix", "ColorStop", "ParentIndex", "FontName", "Number", "ExportConstraint", "Blob"] { + assert_eq!(def(n).kind, Kind::Struct, "{n}"); + } + let id = |d: &str, f: &str| def(d).fields.iter().find(|x| x.name == f).unwrap_or_else(|| panic!("no {d}.{f}")).value; + assert_eq!((id("NodeChange", "guid"), id("NodeChange", "size"), id("NodeChange", "transform")), (1, 11, 12)); + assert_eq!((id("NodeChange", "fillPaints"), id("NodeChange", "fillGeometry"), id("NodeChange", "strokeGeometry")), (38, 51, 52)); + assert_eq!((id("NodeType", "ROUNDED_RECTANGLE"), id("NodeType", "SECTION"), id("EffectType", "GLASS")), (12, 25, 8)); + assert_eq!((id("Message", "nodeChanges"), id("Message", "blobs")), (4, 6)); + // Ids are unique within a definition, and no message field is numbered 0 (the end marker). + for d in &s.defs { + let mut ids: Vec = d.fields.iter().map(|f| f.value).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), d.fields.len(), "{}", d.name); + assert!(d.kind != Kind::Message || !ids.contains(&0), "{}", d.name); + } +} + +#[test] +fn schema_text_takes_numbers_and_refuses_bad_ones() { + let s = Schema::parse("enum E A=3 B C=9\nmessage M uint a=7, E b, string c=2").unwrap(); + let values = |i: usize| s.defs[i].fields.iter().map(|f| (f.name.as_str(), f.value)).collect::>(); + assert_eq!(values(0), [("A", 3), ("B", 1), ("C", 9)]); + assert_eq!(values(1), [("a", 7), ("b", 2), ("c", 2)]); + for bad in ["enum E A=", "enum E A=x", "enum E A=-1", "message M uint a=1.5", "message M uint a=99999999999", "enum E =", "message M uint ="] { + assert!(Schema::parse(bad).is_err() || bad.ends_with('='), "{bad}"); + } + // Junk never panics. + let mut seed = 7u64; + let alphabet: Vec = "enum struct message uint E M a= =1 ,[] \n9x".chars().collect(); + for _ in 0..2000 { + let text: String = (0..40) + .map(|_| { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + alphabet[(seed >> 33) as usize % alphabet.len()] + }) + .collect(); + let _ = Schema::parse(&text); + } +} + +/// Local only (`FIGCRAFT_FIG_SAMPLES`): every definition, field and enum value the export writes +/// has the kind, type and number it has in each sample's own schema. +#[test] +fn local_samples_agree_with_the_written_schema() { + let ours = Schema::parse(export::SCHEMA).unwrap(); + let type_name = |s: &Schema, f: &super::kiwi::Field| match f.ty { + Some(Type::Def(i)) => s.defs[i].name.clone(), + t => format!("{t:?}"), + }; + for p in super::tests::samples() { + let bytes = std::fs::read(&p).unwrap(); + let (theirs, _) = decode_canvas(&Archive::open(&bytes).unwrap().canvas).unwrap(); + for d in &ours.defs { + let t = theirs.defs.iter().find(|x| x.name == d.name).unwrap_or_else(|| panic!("{}: no {}", p.display(), d.name)); + assert_eq!(t.kind, d.kind, "{}", d.name); + if d.kind == Kind::Struct { + assert_eq!(t.fields.len(), d.fields.len(), "{}", d.name); + } + for f in &d.fields { + let tf = t.fields.iter().find(|x| x.name == f.name).unwrap_or_else(|| panic!("{}: no {}.{}", p.display(), d.name, f.name)); + assert_eq!((tf.value, tf.array, type_name(&theirs, tf)), (f.value, f.array, type_name(&ours, f)), "{}.{}", d.name, f.name); + } + } + } +} diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 897eaa1f..528dbbf0 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -555,7 +555,8 @@ const BUILTIN_NAMES: [&str; 8] = ["bool", "byte", "int", "uint", "float", "strin 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'. + /// types being built-in names or other definitions'. `A=3` or `type field=7` gives a value or + /// a field its own number. 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)); @@ -564,7 +565,14 @@ impl Schema { 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()), + "enum" => { + let mut fields = Vec::new(); + for (n, i) in rest.iter().zip(0..) { + let (name, value) = numbered(n, i)?; + fields.push(Field { name, ty: None, array: false, value }); + } + (Kind::Enum, fields) + } "struct" | "message" => { let mut fields = Vec::new(); for (f, id) in rest.join(" ").split(',').zip(1..) { @@ -575,7 +583,8 @@ impl Schema { 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 }); + let (name, value) = numbered(n.trim(), id)?; + fields.push(Field { name, ty: Some(ty), array: t.ends_with("[]"), value }); } (if *kind == "struct" { Kind::Struct } else { Kind::Message }, fields) } @@ -586,3 +595,11 @@ impl Schema { Ok(Schema { defs }) } } + +/// `name=number` as its parts, or `name` with `default`. +fn numbered(token: &str, default: u32) -> Result<(String, u32), KiwiError> { + match token.split_once('=') { + None => Ok((token.to_string(), default)), + Some((name, n)) => Ok((name.to_string(), n.parse().map_err(|_| KiwiError(format!("schema text: bad number in `{token}`")))?)), + } +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0cb8f1df..b3a9587c 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -475,6 +475,9 @@ impl<'a> Mapper<'a> { o.insert(to.into(), json!(b)); } } + if let Some(m) = v.str("maskType") { + o.insert("maskType".into(), json!(if m == "OUTLINE" { "VECTOR" } else { m })); + } copy_num(o, v, "opacity", "opacity"); if let Some(b) = v.str("blendMode").filter(|b| *b != "UNSET") { o.insert("blendMode".into(), json!(b)); @@ -1196,8 +1199,8 @@ fn stroke(o: &mut Obj, v: &View) { o.insert("strokeMiterAngle".into(), json!((2.0 * (1.0 / l).asin()).to_degrees())); } if v.flag("borderStrokeWeightsIndependent") == Some(true) { - let w = v.num("strokeWeight").unwrap_or(0.0); - let side = |k: &str| v.num(k).unwrap_or(w); + // A side that isn't written has no border. + let side = |k: &str| v.num(k).unwrap_or(0.0); o.insert( "individualStrokeWeights".into(), json!({"top": side("borderTopWeight"), "right": side("borderRightWeight"), "bottom": side("borderBottomWeight"), "left": side("borderLeftWeight")}), @@ -1432,7 +1435,8 @@ fn text_style(v: &View, size: Option) -> Obj { if let Some(f) = v.get("fontName") { for (from, to) in [("family", "fontFamily"), ("style", "fontStyle"), ("postscript", "fontPostScriptName")] { if let Some(x) = f.get(from).and_then(Kv::str).filter(|x| !x.is_empty()) { - s.insert(to.into(), json!(x)); + // Compact and spaced style names ("SemiBold", "Semi Bold") are the same style. + s.insert(to.into(), if from == "style" { json!(super::style_name(x)) } else { json!(x) }); } } } @@ -1458,8 +1462,9 @@ fn text_style(v: &View, size: Option) -> Obj { s.insert("lineHeightUnit".into(), json!("PIXELS")); s.insert("lineHeightPx".into(), json!(val)); } - // shortcut: 100 % reads as the font's own line height (new text saves "auto" so) and - // other percentages as % of the font size; revisit with files that set 100 % explicitly. + // shortcut: percent units count from the font's own line height (100 % is "auto"), which + // the document can only say for 100 %; other values read as % of the font size. Upgrade + // when line heights can be a share of the font's own. Some("PERCENT") if (val - 100.0).abs() < 1e-6 => { s.insert("lineHeightUnit".into(), json!("INTRINSIC_%")); } @@ -1482,6 +1487,7 @@ fn text_style(v: &View, size: Option) -> Obj { fn effect(e: &Kv) -> Option { let ty = match e.get("type").and_then(Kv::str)? { "FOREGROUND_BLUR" => "LAYER_BLUR", + "GRAIN" => "TEXTURE", t => t, }; let mut o = Obj::new(); diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 417b01ee..ec2a0e23 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -15,6 +15,8 @@ mod export; #[cfg(test)] mod export_tests; +#[cfg(test)] +mod interop_tests; pub mod kiwi; mod map; mod network; @@ -67,6 +69,27 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma Ok((doc, warnings)) } +/// A font style under the name the format uses: the standard weight names as words ("SemiBold" +/// → "Semi Bold", "ExtraBoldItalic" → "Extra Bold Italic", "Regular Italic" → "Italic"). Other +/// names stay as they are. +fn style_name(style: &str) -> String { + const WEIGHTS: [&str; 12] = + ["Thin", "Extra Light", "Ultra Light", "Light", "Regular", "Medium", "Semi Bold", "Demi Bold", "Bold", "Extra Bold", "Ultra Bold", "Black"]; + let compact = |s: &str| s.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).flat_map(char::to_lowercase).collect::(); + let key = compact(style); + let (base, italic) = match key.strip_suffix("italic") { + Some(b) => (b, true), + None => (key.as_str(), false), + }; + let weight = if base.is_empty() && italic { Some("Regular") } else { WEIGHTS.into_iter().find(|w| compact(w) == base) }; + match (weight, italic) { + (None, _) => style.to_string(), + (Some("Regular"), true) => "Italic".into(), + (Some(w), true) => format!("{w} Italic"), + (Some(w), false) => w.into(), + } +} + fn corrupt(msg: impl Into) -> FormatError { FormatError::Corrupt(format!(".fig file: {}", msg.into())) } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 8f910f91..541739c7 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -354,7 +354,7 @@ fn message() -> Kv<'static> { } /// `canvas.fig`: magic, version, deflated schema, zstd message. -fn canvas(schema: &Schema, msg: &Kv) -> Vec { +pub(super) fn canvas(schema: &Schema, msg: &Kv) -> Vec { let root = schema.find("Message").unwrap(); let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); z.write_all(&encode::schema(schema)).unwrap(); diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 8f03b7e0..57214de9 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -468,6 +468,11 @@ impl<'a> Ctx<'a> { n.blend = b; } n.is_mask = flag(j, "isMask").unwrap_or(false); + n.mask_type = match text(j, "maskType") { + Some("VECTOR") => figcraft_doc::MaskType::Vector, + Some("LUMINANCE") => figcraft_doc::MaskType::Luminance, + _ => figcraft_doc::MaskType::Alpha, + }; // The JSON lists every paint; a missing list means none (text keeps its default black). match props::paints(self, j, "fills") { Some(f) if !matches!(n.kind, NodeKind::Group) => n.fills = f, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b2ec551c..1c968604 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -8,8 +8,10 @@ help center (image and video types, export formats), and FigCraft's source. **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) 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. +files) and can be written, experimentally: written files round trip through FigCraft's own +importer and were checked by opening them in other software (layers, fills, gradients, strokes, +shadows, vectors, components and text draw as in FigCraft); effects such as glass and noise, images +and prototype interactions are still unchecked there. ## Documents @@ -47,7 +49,7 @@ verified, and nothing yet shows that other apps open the files. | 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 | **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) | +| `.fig` ("Save local copy") | yes | **experimental** | the whole file; see [Export to `.fig`](#export-to-fig-experimental). Round trips through our own importer; written files checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft | 5–15 (checks of effects, images and prototypes, and against real files) | ### Export to `.fig` (experimental) @@ -56,21 +58,29 @@ verified, and nothing yet shows that other apps open the files. 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 +`images/`; `meta.json` names the file and gives the page colour and the thumbnail size. The +schema written into the file describes only the fields written, under the names, kinds (struct or +message), field ids and enum values the format gives them. The document and each page say they are +visible and opaque (a page also its background opacity): a reader takes what is left out as off, and +draws nothing below a hidden page. 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; +- **Written:** pages (name, background); frames, groups, sections, rectangles (as rounded + rectangles, with their four radii), 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; the fill outline of frames and shapes and the + stroke outline of plain strokes (a ring around boxes and whole ellipses, the widened path of other + centred strokes); 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, + smoothing, constraints, clip content, masks with their type; 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, + (font, with styles under their standard names such as "Semi Bold"; size; line height, a share of + the font size as a raw multiplier; 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 @@ -82,23 +92,33 @@ each. - **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, + strokes (written plain), pattern fills, WebP export settings (written as PNG), 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 + or content size, different stroke ends on lines. Not written at all: stroke outlines of dashed and + per-side strokes and of inside or outside strokes on polygons, stars and vectors, glyph outlines + of text (a reader lays text out with its own fonts), version history and branches. +- **Checked by opening written files in other software:** the example documents and a small test + file open there, and layers, fills, gradients, strokes, shadows, vectors, components and text draw + as in FigCraft. A file saved again there and read back keeps the layer tree and properties; two + differences it showed were fixed (font styles are written under their standard names, and a line + height that is a share of the font size is written as a raw multiplier, since percent units count + from the font's own line height); the import now reads per-side stroke weights that leave the + sides without a border out. Still unchecked there: effects such as glass and noise, images, + prototype interactions; variables are not written. Text boxes sized to their content can come out + a pixel wider or narrower there. +- **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. + `crates/engine/src/fig_export_tests.rs`). What other readers need (visible document and pages, + outlines, text settings, schema numbering, font style names, line height units, mask types) has + its own tests (`crates/format/src/fig/interop_tests.rs`). ## Fonts @@ -113,6 +133,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | | 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 | diff --git a/docs/gaps.md b/docs/gaps.md index 2f9d59c7..9463b58d 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 (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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (.fig export checked by opening written files in other software; 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, 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) | +| 1 | `.fig` files: best-effort import with editable vector networks and stand-ins for non-design layers landed, and an experimental export (round trips through our own importer; written files opened in other software, where layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft); 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) | @@ -51,7 +51,13 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe 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. + The import gained ellipse arcs, boolean operation types and component set descriptions. Then + (2026-10-12): written files were checked by opening them in other software: layers, fills, + gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after the export + learned to say that the document and its pages are visible and opaque, to number its schema as + the format does, to write outlines, text settings, standard font style names and line height + units; effects such as glass and noise, images and prototype interactions are still unchecked + there, and variables are not written. - **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 @@ -367,6 +373,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 1: the experimental `.fig` export now opens elsewhere: written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Hours and the ranking unchanged | | 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 59b0b2aa..7f2c04ca 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 (a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (.fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; 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 @@ -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 | 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 | +| 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 rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and 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, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | @@ -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 | 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) | +| 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; written files open and draw in other software); 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) | @@ -195,7 +195,7 @@ 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) 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)) | +| 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 (written files open and draw in other software; effects, images and prototypes unchecked there); 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.7% × 0.90 × 0.90 × 0.85 = **~50%** (estimated; 50.03%, rounded down). @@ -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 | `.fig` export (gap 1): written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Wording only; no number moves | | 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | | 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 |