From ed74fc67d07c4ef27e872edac92a601d28d26ae7 Mon Sep 17 00:00:00 2001 From: Dubblbindong <48181403+Dubblbindong@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:12:54 +0200 Subject: [PATCH 01/87] feat(plugin): add saveFile permission with host-owned save dialog Plugins can hand the host a file name, MIME type and bytes through the new `save_file` import (plugin API 4, `permissions.saveFile`). The host asks the user where to save (browser download on the web), so the plugin never sees a path. Includes the SDK wrapper and the `export-names` example plugin. Co-Authored-By: Claude Sonnet 5.5 Assisted-by: Claude (Anthropic) --- README.md | 2 +- apps/figcraft-server/src/directory.rs | 1 + crates/collab/src/message.rs | 2 + crates/engine/src/cmd/file.rs | 5 +- crates/engine/src/plugin_directory.rs | 2 +- crates/engine/src/plugins.rs | 75 ++++++++- crates/engine/src/plugins_tests.rs | 126 ++++++++++++++++ crates/plugin/src/lib.rs | 2 + crates/plugin/src/manifest.rs | 11 +- crates/plugin/src/sandbox.rs | 26 +++- crates/plugin/src/save_file_tests.rs | 142 ++++++++++++++++++ crates/plugin/src/tests.rs | 2 +- crates/plugin/src/widget_tests.rs | 2 +- crates/ui-egui/locales/de.toml | 4 + crates/ui-egui/locales/en.toml | 4 + crates/ui-egui/locales/es.toml | 4 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/plugin_save_ui_tests.rs | 71 +++++++++ crates/ui-egui/src/plugins_ui.rs | 36 +++++ docs/plugins.md | 23 ++- plugins/Cargo.lock | 7 + plugins/examples/export-names/Cargo.toml | 15 ++ .../export-names/figcraft-plugin.json | 22 +++ plugins/examples/export-names/src/lib.rs | 55 +++++++ plugins/sdk/src/lib.rs | 16 ++ xtask/src/main.rs | 2 +- 26 files changed, 638 insertions(+), 22 deletions(-) create mode 100644 crates/plugin/src/save_file_tests.rs create mode 100644 crates/ui-egui/src/plugin_save_ui_tests.rs create mode 100644 plugins/examples/export-names/Cargo.toml create mode 100644 plugins/examples/export-names/figcraft-plugin.json create mode 100644 plugins/examples/export-names/src/lib.rs diff --git a/README.md b/README.md index 0910bf9e..c232df08 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. - **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). -- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). +- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and four example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). - **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session. diff --git a/apps/figcraft-server/src/directory.rs b/apps/figcraft-server/src/directory.rs index 0443c0f4..cdf18364 100644 --- a/apps/figcraft-server/src/directory.rs +++ b/apps/figcraft-server/src/directory.rs @@ -245,6 +245,7 @@ impl Directory { }, commands: m.permissions.commands.clone(), widget: m.widget, + save_file: m.permissions.save_file, bytes: bytes.len() as u64, sha256: sha.clone(), published: now(), diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index d035042b..734e4076 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -537,6 +537,8 @@ pub struct PluginEntry { pub commands: Vec, /// It draws a widget. pub widget: bool, + /// It may offer files to save (`permissions.saveFile`). + pub save_file: bool, /// The bundle's size in bytes and its SHA-256 (hex). pub bytes: u64, pub sha256: String, diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 5e27c572..97be8439 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1103,9 +1103,12 @@ fn export_path(name: &str) -> String { if parts.is_empty() { "Untitled".into() } else { parts.join("/") } } +/// Characters no file name may contain (path separators and ones file systems reject). +pub(crate) const FORBIDDEN_FILE_NAME_CHARS: &str = "/\\:*?\"<>|"; + /// `name` without path separators and characters file systems reject. fn safe_file_name(name: &str) -> String { - name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect() + name.chars().take(200).map(|c| if c.is_control() || FORBIDDEN_FILE_NAME_CHARS.contains(c) { '_' } else { c }).collect() } /// Layers on `page` that have export settings (document order). diff --git a/crates/engine/src/plugin_directory.rs b/crates/engine/src/plugin_directory.rs index c3bf7dc4..5ddc2a81 100644 --- a/crates/engine/src/plugin_directory.rs +++ b/crates/engine/src/plugin_directory.rs @@ -281,7 +281,7 @@ pub fn specs() -> Vec { spec!( "plugin.directory.list", "Browse plugin directory", - "{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}", + "{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, save_file, bytes, sha256, published, versions, installed, update}]}", false, list ), diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index f1c4075e..1fab5145 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -153,6 +153,39 @@ fn writes_widget_data(params: &Value, depth: usize) -> bool { struct EngineHost<'s> { s: &'s mut Session, manifest: &'s Manifest, + /// Files offered for saving in this call. + saves: u32, +} + +/// Longest file name a plugin may suggest, in characters. +const MAX_SAVE_NAME: usize = 128; +/// Longest MIME type a plugin may name, in bytes. +const MAX_SAVE_MIME: usize = 100; +/// Largest file a plugin may offer for saving, in bytes (the sandbox's message limit). +const MAX_SAVE_BYTES: usize = 16 * 1024 * 1024; +/// Files a plugin may offer per call. +const MAX_SAVES: u32 = 1; + +/// A file name a plugin suggested, cleaned up: a bare name without folders, drive letters, `..` or +/// characters no file system takes. `None` when nothing usable is left. +pub(crate) fn sanitize_save_name(name: &str) -> Option { + let name = name.trim(); + let bad = name.is_empty() + || name.chars().count() > MAX_SAVE_NAME + || name == "." + || name.contains("..") + || name.chars().any(|c| c.is_control() || crate::cmd::file::FORBIDDEN_FILE_NAME_CHARS.contains(c)); + if bad { None } else { Some(name.to_string()) } +} + +/// `mime` when it looks like `type/subtype` in ASCII, else the generic binary type. +fn sanitize_mime(mime: &str) -> String { + let mime = mime.trim(); + let token = |t: &str| !t.is_empty() && t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-' | '_')); + match mime.split_once('/') { + Some((a, b)) if mime.len() <= MAX_SAVE_MIME && token(a) && token(b) => mime.to_ascii_lowercase(), + _ => "application/octet-stream".into(), + } } impl figcraft_plugin::Host for EngineHost<'_> { @@ -226,6 +259,27 @@ impl figcraft_plugin::Host for EngineHost<'_> { } Ok(()) } + + fn save_file(&mut self, name: &str, mime: &str, data: &[u8]) -> std::result::Result<(), String> { + if !self.manifest.permissions.save_file { + return Err(format!("{} didn't ask permission to save files (`permissions.saveFile`)", self.manifest.name)); + } + let name = sanitize_save_name(name).ok_or_else(|| { + format!("not a usable file name: use a plain name of 1–{MAX_SAVE_NAME} characters without folders or `/ \\ : .. < > \" | ? *`") + })?; + if data.len() > MAX_SAVE_BYTES { + return Err(format!("a file to save is limited to {} MB", MAX_SAVE_BYTES / (1024 * 1024))); + } + if self.saves >= MAX_SAVES { + return Err("a plugin can offer one file per run".into()); + } + self.saves += 1; + use base64::Engine as _; + let data = base64::engine::general_purpose::STANDARD.encode(data); + // The app asks the user where it goes; programmatic callers drop the request. + self.s.request_ui("dialog.pluginSave", json!({"plugin": self.manifest.id, "name": name, "mime": sanitize_mime(mime), "data": data})); + Ok(()) + } } /// The installed plugin `id`. @@ -424,14 +478,21 @@ pub(crate) fn call_plugin( input: &str, ) -> std::result::Result { let mut usable = false; - s.grouped(label, |s| { - let mut host = EngineHost { s, manifest: m }; - instance.call(input, &mut host).map_err(|e| { - usable = matches!(e, PluginError::Failed(_)); - plugin_error(&m.name, e) + let ui_before = s.ui_requests.len(); + let r = s + .grouped(label, |s| { + let mut host = EngineHost { s, manifest: m, saves: 0 }; + instance.call(input, &mut host).map_err(|e| { + usable = matches!(e, PluginError::Failed(_)); + plugin_error(&m.name, e) + }) }) - }) - .map_err(|e| (e, usable)) + .map_err(|e| (e, usable)); + // A failed run opens no dialog (a file it offered to save is dropped with it). + if r.is_err() { + s.ui_requests.truncate(ui_before); + } + r } /// What `plugin.run` and `plugin.event` return. diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 102d675a..ebabc09e 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -397,3 +397,129 @@ fn a_full_undo_history_keeps_its_steps_and_tampered_manifests_are_refused() { s.prefs.plugins[0].manifest.main = "../../elsewhere.wasm".into(); assert!(matches!(s.execute("plugin.run", &json!({"id": "org.test.many"})), Err(EngineError::BadParams { .. }))); } + +/// A module that offers `files` (name, mime, data) with `save_file` in order, outputs the last +/// refusal and returns `exit`. +fn saver(files: &[(&str, &str, &[u8])], exit: i32) -> Vec { + let hex = |b: &[u8]| b.iter().map(|b| format!("\\{b:02x}")).collect::(); + let mut data = String::new(); + let mut body = String::new(); + let mut at = 0usize; + for (name, mime, bytes) in files { + let (n, m) = (at, at + name.len()); + let d = m + mime.len(); + data += &format!( + "(data (i32.const {n}) \"{}\") (data (i32.const {m}) \"{}\") (data (i32.const {d}) \"{}\")\n", + hex(name.as_bytes()), + hex(mime.as_bytes()), + hex(bytes) + ); + body += &format!( + "(local.set $st (call $save_file (i32.const {n}) (i32.const {}) (i32.const {m}) (i32.const {}) (i32.const {d}) (i32.const {}))) + (if (local.get $st) (then (call $set_output (i32.const 30000) (call $reply_read (i32.const 30000) (i32.const 500)))))\n", + name.len(), + mime.len(), + bytes.len() + ); + at = d + bytes.len(); + } + let wat = format!( + r#"(module + (import "figcraft" "save_file" (func $save_file (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32))) + (import "figcraft" "set_output" (func $set_output (param i32 i32))) + (memory (export "memory") 1) + {data} + (func (export "figcraft_run") (param $n i32) (result i32) (local $st i32) + {body} + (i32.const {exit})))"# + ); + wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}")) +} + +fn save_manifest(id: &str, save_file: bool) -> Value { + json!({"id": id, "name": "Saver", "version": "1.0.0", "main": "plugin.wasm", "api": 4, "permissions": {"access": "read", "saveFile": save_file}}) +} + +fn saves(s: &Session) -> Vec<&crate::UiRequest> { + s.ui_requests.iter().filter(|r| r.kind == "dialog.pluginSave").collect() +} + +#[test] +fn a_plugin_without_the_permission_cannot_save() { + let mut s = Session::new(); + install(&mut s, save_manifest("org.test.nosave", false), &saver(&[("a.txt", "text/plain", b"hi")], 0)); + let out = ex(&mut s, "plugin.run", json!({"id": "org.test.nosave"})); + assert!(out["output"].as_str().unwrap_or_default().contains("permission"), "{out}"); + assert!(saves(&s).is_empty(), "no dialog without the permission"); +} + +#[test] +fn a_plugin_with_the_permission_requests_one_save_dialog_with_its_bytes() { + let mut s = Session::new(); + install(&mut s, save_manifest("org.test.save", true), &saver(&[("out.jsx", "TEXT/Javascript", b"\x00hi\xff")], 0)); + ex(&mut s, "plugin.run", json!({"id": "org.test.save"})); + let reqs = saves(&s); + let [r] = reqs.as_slice() else { panic!("one request, got {}", reqs.len()) }; + assert_eq!(r.data["plugin"], "org.test.save"); + assert_eq!(r.data["name"], "out.jsx"); + assert_eq!(r.data["mime"], "text/javascript"); + assert_eq!(r.data["data"], b64(b"\x00hi\xff")); + // Nothing in the request is a path. + assert!(r.data.get("path").is_none()); +} + +#[test] +fn a_second_file_in_one_run_and_bad_names_are_refused() { + let mut s = Session::new(); + install(&mut s, save_manifest("org.test.two", true), &saver(&[("a.txt", "text/plain", b"1"), ("b.txt", "text/plain", b"2")], 0)); + let out = ex(&mut s, "plugin.run", json!({"id": "org.test.two"})); + assert_eq!(saves(&s).len(), 1, "only the first file"); + assert!(out["output"].as_str().unwrap_or_default().contains("one file"), "{out}"); + s.ui_requests.clear(); + install(&mut s, save_manifest("org.test.bad", true), &saver(&[("../etc/passwd", "text/plain", b"1")], 0)); + let out = ex(&mut s, "plugin.run", json!({"id": "org.test.bad"})); + assert!(saves(&s).is_empty()); + assert!(out["output"].as_str().unwrap_or_default().contains("file name"), "{out}"); +} + +#[test] +fn an_odd_mime_type_becomes_generic_binary() { + let mut s = Session::new(); + install(&mut s, save_manifest("org.test.mime", true), &saver(&[("a.bin", "not a type", b"1")], 0)); + ex(&mut s, "plugin.run", json!({"id": "org.test.mime"})); + assert_eq!(saves(&s)[0].data["mime"], "application/octet-stream"); +} + +#[test] +fn a_run_that_fails_afterwards_leaves_no_dialog_behind() { + let mut s = Session::new(); + install(&mut s, save_manifest("org.test.late", true), &saver(&[("a.txt", "text/plain", b"1")], 1)); + run_err(&mut s, "org.test.late"); + assert!(saves(&s).is_empty(), "a failed run opens no dialog"); +} + +#[test] +fn file_commands_stay_closed_to_plugins() { + let mut s = Session::new(); + let calls = [("file.saveAs", json!({"path": "/tmp/x.figcraft"}))]; + install(&mut s, manifest("org.test.files", "read", &[]), &script(&calls, OnRefusal::Report, "")); + let out = ex(&mut s, "plugin.run", json!({"id": "org.test.files"})); + assert!(out["output"].as_str().unwrap_or_default().contains("isn't available to plugins"), "{out}"); +} + +#[test] +fn save_names_are_plain_file_names() { + use crate::plugins::sanitize_save_name as clean; + let long = "a".repeat(129); + let max = "a".repeat(128); + // 128 two-byte characters are within the limit even though they take 256 bytes. + let wide = "é".repeat(128); + for bad in ["", " ", ".", "..", "../x", "a/b", "a\\b", "C:x", "a..b", "a\nb", "a\u{0}b", "a\u{7f}b", "a*b", long.as_str()] { + assert_eq!(clean(bad), None, "{bad:?}"); + } + assert_eq!(clean(" out.jsx ").as_deref(), Some("out.jsx")); + assert_eq!(clean(&max).as_deref(), Some(max.as_str())); + assert_eq!(clean(&wide).as_deref(), Some(wide.as_str())); + assert_eq!(clean("Entwurf – Büro.jsx").as_deref(), Some("Entwurf – Büro.jsx")); +} diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 582b0e64..80a26295 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -16,6 +16,8 @@ pub use widget::{WidgetKind, WidgetNode, WidgetRender}; #[cfg(test)] mod bundle_tests; #[cfg(test)] +mod save_file_tests; +#[cfg(test)] mod tests; #[cfg(test)] mod v2_tests; diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index 48e44e88..6af92a95 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -8,7 +8,7 @@ use serde_json::{Map, Value}; /// The manifest's file name. pub const MANIFEST_FILE: &str = "figcraft-plugin.json"; /// The plugin API version this build implements (`api` in the manifest). -pub const API_VERSION: u32 = 3; +pub const API_VERSION: u32 = 4; /// Largest manifest read. pub const MAX_MANIFEST_BYTES: usize = 256 * 1024; @@ -43,7 +43,7 @@ pub struct Manifest { /// The WebAssembly module, relative to the manifest (`plugin.wasm`). pub main: String, /// The plugin API version the plugin was written for (1 when not given; panels and settings - /// need 2, widgets 3). + /// need 2, widgets 3, `saveFile` 4). #[serde(default = "api_version")] pub api: u32, #[serde(default)] @@ -71,6 +71,10 @@ pub struct Permissions { /// command its access allows. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub commands: Vec, + /// API 4: the plugin may hand the host a file to save. The host asks the user where; the plugin + /// never sees a path. + #[serde(default, rename = "saveFile", skip_serializing_if = "std::ops::Not::not")] + pub save_file: bool, } impl Permissions { @@ -314,6 +318,9 @@ impl Manifest { if self.widget && self.api < 3 { return Err("a widget needs plugin API 3 (`\"api\": 3`)".into()); } + if self.permissions.save_file && self.api < 4 { + return Err("saveFile needs plugin API 4 (`\"api\": 4`)".into()); + } if self.commands.is_empty() && !self.widget { self.commands.push(PluginCommand { id: "run".into(), label: "Run".into(), params: Vec::new() }); } diff --git a/crates/plugin/src/sandbox.rs b/crates/plugin/src/sandbox.rs index 22eb0af0..59d66118 100644 --- a/crates/plugin/src/sandbox.rs +++ b/crates/plugin/src/sandbox.rs @@ -2,7 +2,8 @@ //! memory cap, and sees the outside world only through the host functions below. There is no WASI: //! no file system, network, clock or randomness. //! -//! # ABI (version 3; version 1 is the first seven imports, version 2 adds panels and settings) +//! # ABI (version 4; version 1 is the first seven imports, version 2 adds panels and settings, +//! version 3 widgets, version 4 `save_file`) //! //! The module exports its `memory` and `figcraft_run(input_len: i32) -> i32`. Host functions are //! imported from the module `figcraft`; strings are UTF-8 `(ptr, len)` pairs in the plugin's memory. @@ -22,6 +23,7 @@ //! | `setting_set` | `(key_ptr, key_len, value_ptr, value_len) -> i32` | Stores JSON under the key. 1: refused, the reply says why. | //! | `setting_delete` | `(key_ptr, key_len)` | Forgets a setting. | //! | `widget_render` | `(ptr, len) -> i32` | Draws the widget being called back: a [`WidgetRender`] as JSON. 1: invalid, the reply says why. | +//! | `save_file` | `(name_ptr, name_len, mime_ptr, mime_len, data_ptr, data_len) -> i32` | Offers a file to save (needs `permissions.saveFile`). The host asks the user where it goes; the plugin never gets a path. 0: accepted, 1: refused, the reply says why. | //! //! When `figcraft_run` starts, the reply holds the input JSON (`input_len` bytes): //! `{"api", "command", "params", "selection"}`, plus `event` and `values` when a panel calls the @@ -58,6 +60,7 @@ const IMPORTS: &[(&str, usize, usize)] = &[ ("setting_set", 4, 1), ("setting_delete", 2, 0), ("widget_render", 2, 1), + ("save_file", 6, 1), ]; /// What a run may use. @@ -136,6 +139,11 @@ pub trait Host { fn set_setting(&mut self, _key: &str, _value: Option<&str>) -> Result<(), String> { Err("settings aren't available here".into()) } + /// Offer the file `name` (MIME type `mime`) for saving, or say why not. The host picks the + /// destination; the plugin never learns it. + fn save_file(&mut self, _name: &str, _mime: &str, _data: &[u8]) -> Result<(), String> { + Err("saving files isn't available here".into()) + } } /// What a run asked of the panel. @@ -172,6 +180,7 @@ enum Request { SettingGet(String), SettingSet(String, String), SettingDelete(String), + SaveFile(String, String, Vec), } struct State { @@ -237,6 +246,13 @@ impl State { let _ = host.set_setting(&key, None); None } + Request::SaveFile(name, mime, data) => Some(match host.save_file(&name, &mime, &data) { + Ok(()) => 0, + Err(m) => { + self.set_reply(m.into_bytes()); + 1 + } + }), } } } @@ -575,5 +591,13 @@ fn linker(engine: &Engine) -> Result, PluginError> { Err(ask(&mut c, Request::SettingDelete(key))) }) .map_err(err)?; + l.func_wrap(IMPORT_MODULE, "save_file", |mut c: Ctx<'_>, np: i32, nl: i32, mp: i32, ml: i32, dp: i32, dl: i32| -> Result { + charge(&mut c)?; + let name = read_str(&mut c, np, nl)?; + let mime = read_str(&mut c, mp, ml)?; + let data = read_bytes(&mut c, dp, dl)?; + Err(ask(&mut c, Request::SaveFile(name, mime, data))) + }) + .map_err(err)?; Ok(l) } diff --git a/crates/plugin/src/save_file_tests.rs b/crates/plugin/src/save_file_tests.rs new file mode 100644 index 00000000..34241bb7 --- /dev/null +++ b/crates/plugin/src/save_file_tests.rs @@ -0,0 +1,142 @@ +//! `save_file` (API 4): the manifest permission, the host call with its bytes, and the limits. + +use serde_json::json; + +use crate::{Host, Instance, Limits, Manifest, PluginError}; + +/// A host that records the files offered to it (and refuses names starting with `no`). +#[derive(Default)] +struct Saves(Vec<(String, String, Vec)>); + +impl Host for Saves { + fn call(&mut self, _command: &str, _params: &str) -> Result { + Ok("{}".into()) + } + fn selection(&mut self) -> String { + "[]".into() + } + fn notify(&mut self, _message: &str) {} + fn save_file(&mut self, name: &str, mime: &str, data: &[u8]) -> Result<(), String> { + if name.starts_with("no") { + return Err("nope".into()); + } + self.0.push((name.into(), mime.into(), data.to_vec())); + Ok(()) + } +} + +/// A host that keeps the default `save_file`. +struct Plain; + +impl Host for Plain { + fn call(&mut self, _command: &str, _params: &str) -> Result { + Ok("{}".into()) + } + fn selection(&mut self) -> String { + "[]".into() + } + fn notify(&mut self, _message: &str) {} +} + +/// Memory: name at 0, MIME at 100, data at 200. `call` is the arguments of `save_file`; its status +/// is the run's status, and its reply is the output. +fn module(name: &str, mime: &str, data: &[u8], call: &str) -> Vec { + let bytes = |b: &[u8]| b.iter().map(|b| format!("\\{b:02x}")).collect::(); + let wat = format!( + r#"(module + (import "figcraft" "save_file" (func $save_file (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32))) + (import "figcraft" "set_output" (func $set_output (param i32 i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{}") (data (i32.const 100) "{}") (data (i32.const 200) "{}") + (func (export "figcraft_run") (param $n i32) (result i32) (local $st i32) + (local.set $st {call}) + (call $set_output (i32.const 1000) (call $reply_read (i32.const 1000) (i32.const 100))) + (local.get $st)))"#, + bytes(name.as_bytes()), + bytes(mime.as_bytes()), + bytes(data) + ); + wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}")) +} + +fn call(name: &str, mime: &str, data: &[u8]) -> String { + format!( + "(call $save_file (i32.const 0) (i32.const {}) (i32.const 100) (i32.const {}) (i32.const 200) (i32.const {}))", + name.len(), + mime.len(), + data.len() + ) +} + +fn manifest(api: u32, save_file: bool) -> String { + json!({"id": "org.test.save", "name": "Save", "main": "p.wasm", "api": api, "permissions": {"saveFile": save_file}}).to_string() +} + +#[test] +fn the_host_gets_the_name_the_type_and_the_exact_bytes() { + let data = [0u8, 1, 2, 255, 0x80, b'x']; + let wasm = module("out.jsx", "text/plain", &data, &call("out.jsx", "text/plain", &data)); + let mut host = Saves::default(); + Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut host).unwrap(); + assert_eq!(host.0, vec![("out.jsx".to_string(), "text/plain".to_string(), data.to_vec())]); +} + +#[test] +fn a_refusal_returns_status_one_with_the_reason() { + let wasm = module("no.txt", "text/plain", b"x", &call("no.txt", "text/plain", b"x")); + // The module returns the status as its own, so the run fails with the reason as its message. + let err = Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut Saves::default()).unwrap_err(); + assert_eq!(err, PluginError::Failed("nope".into())); +} + +#[test] +fn a_host_without_saving_refuses_by_default() { + let wasm = module("a.txt", "text/plain", b"x", &call("a.txt", "text/plain", b"x")); + let err = Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut Plain).unwrap_err(); + assert_eq!(err, PluginError::Failed("saving files isn't available here".into())); +} + +#[test] +fn too_much_data_is_a_limit_error_and_bad_utf8_a_trap() { + let limits = Limits { max_message: 4, ..Limits::default() }; + let wasm = module("a.txt", "text/plain", b"12345", &call("a.txt", "text/plain", b"12345")); + let mut host = Saves::default(); + let err = Instance::new(&wasm, &limits).unwrap().call("{}", &mut host).unwrap_err(); + assert!(matches!(err, PluginError::Limit(_)), "{err:?}"); + assert!(host.0.is_empty()); + + // The name points at a lone 0xff byte: not UTF-8. + let wasm = module( + "a", + "text/plain", + &[0xff], + "(call $save_file (i32.const 200) (i32.const 1) (i32.const 100) (i32.const 1) (i32.const 200) (i32.const 1))", + ); + let err = Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut host).unwrap_err(); + assert!(matches!(err, PluginError::Trap(_)), "{err:?}"); + assert!(host.0.is_empty()); +} + +#[test] +fn a_wrong_signature_is_rejected() { + let wat = r#"(module (import "figcraft" "save_file" (func (param i32 i32) (result i32))) + (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) (i32.const 0)))"#; + let wasm = wat::parse_str(wat).unwrap(); + assert!(matches!(crate::check_module(&wasm, &Limits::default()), Err(PluginError::InvalidModule(m)) if m.contains("wrong signature"))); +} + +#[test] +fn the_permission_needs_api_four_and_survives_a_round_trip() { + let err = Manifest::parse(&manifest(3, true)).unwrap_err(); + assert!(err.contains("saveFile needs plugin API 4"), "{err}"); + let m = Manifest::parse(&manifest(4, true)).unwrap(); + assert!(m.permissions.save_file); + let again = Manifest::parse(&serde_json::to_string(&m).unwrap()).unwrap(); + assert_eq!(m, again); + assert!(serde_json::to_string(&m).unwrap().contains("\"saveFile\":true")); + // Older manifests stay valid and don't grow the key. + let old = Manifest::parse(&manifest(3, false)).unwrap(); + assert!(!old.permissions.save_file); + assert!(!serde_json::to_string(&old).unwrap().contains("saveFile")); +} diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs index 5d094ab4..27d7ac23 100644 --- a/crates/plugin/src/tests.rs +++ b/crates/plugin/src/tests.rs @@ -441,7 +441,7 @@ fn bad_manifests_are_refused() { json!({"main": "C:\\plugin.wasm"}), json!({"main": "plugin.js"}), json!({"main": "a//b.wasm"}), - json!({"api": 4}), + json!({"api": 5}), json!({"api": 0}), json!({"permissions": {"access": "root"}}), json!({"permissions": {"commands": ["*.x"]}}), diff --git a/crates/plugin/src/widget_tests.rs b/crates/plugin/src/widget_tests.rs index 0d4ec2df..35418866 100644 --- a/crates/plugin/src/widget_tests.rs +++ b/crates/plugin/src/widget_tests.rs @@ -110,7 +110,7 @@ fn widgets_need_api_3() { assert!(m(2).unwrap_err().contains("API 3")); let ok = m(3).unwrap(); assert!(ok.widget && ok.commands.is_empty(), "a widget-only plugin has no menu command"); - assert!(Manifest::from_value(&json!({"id": "w", "name": "W", "main": "w.wasm", "api": 4})).is_err()); + assert!(Manifest::from_value(&json!({"id": "w", "name": "W", "main": "w.wasm", "api": 5})).is_err()); let v2 = Manifest::from_value(&json!({"id": "p", "name": "P", "main": "p.wasm", "api": 2})).unwrap(); assert!(!v2.widget && v2.commands.len() == 1, "v2 manifests are unchanged"); } diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index cd1e3482..6d8c9128 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -305,6 +305,7 @@ status = "draft — needs native review" "Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden" "Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat." "Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}" +"Couldn't save the file" = "Die Datei konnte nicht gespeichert werden" "Count" = "Anzahl" "Create" = "Erstellen" "Create a variable collection first" = "Erstellen Sie zuerst eine Variablensammlung" @@ -1082,6 +1083,7 @@ status = "draft — needs native review" "Save" = "Speichern" "Save as" = "Speichern unter" "Save as preset…" = "Als Vorgabe speichern…" +"Save file" = "Datei speichern" "Save files automatically" = "Dateien automatisch speichern" "Save version {key}" = "Version speichern {key}" "Saved" = "Gespeichert" @@ -1435,6 +1437,7 @@ status = "draft — needs native review" "call any command its access allows" = "jeden Befehl aufrufen, den sein Zugriff erlaubt" "call only: {commands}" = "nur aufrufen: {commands}" "can edit the document" = "kann das Dokument bearbeiten" +"can save files" = "kann Dateien speichern" "crop marks" = "Schnittmarken" "development: {path}" = "Entwicklung: {path}" "e.g. count + 1" = "z. B. count + 1" @@ -1452,6 +1455,7 @@ status = "draft — needs native review" "read and edit the document" = "das Dokument lesen und bearbeiten" "read the document" = "das Dokument lesen" "reads the document" = "liest das Dokument" +"save files you choose" = "Dateien an einem von Ihnen gewählten Ort speichern" "the plugin" = "das Plugin" "this session only" = "nur diese Sitzung" "{instances} in {files}" = "{instances} in {files}" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..91bf2ae1 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -314,6 +314,7 @@ name = "English" "Couldn't download the variables" = "Couldn't download the variables" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." "Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" +"Couldn't save the file" = "Couldn't save the file" "Count" = "Count" "Create" = "Create" "Create a variable collection first" = "Create a variable collection first" @@ -1124,6 +1125,7 @@ name = "English" "Rulers" = "Rulers" "Run" = "Run" "Run {label}" = "Run {label}" +"Save file" = "Save file" "Save files automatically" = "Save files automatically" "Search templates" = "Search templates" "See all templates" = "See all templates" @@ -1506,6 +1508,7 @@ name = "English" "call any command its access allows" = "call any command its access allows" "call only: {commands}" = "call only: {commands}" "can edit the document" = "can edit the document" +"can save files" = "can save files" "crop marks" = "crop marks" "development: {path}" = "development: {path}" "e.g. count + 1" = "e.g. count + 1" @@ -1524,6 +1527,7 @@ name = "English" "read and edit the document" = "read and edit the document" "read the document" = "read the document" "reads the document" = "reads the document" +"save files you choose" = "save files you choose" "the plugin" = "the plugin" "this session only" = "this session only" "yesterday" = "yesterday" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..f551146c 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -315,6 +315,7 @@ status = "draft — needs native review" "Corner smoothing" = "Suavizado de esquinas" "Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda." "Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}" +"Couldn't save the file" = "No se pudo guardar el archivo" "Count" = "Número" "Create" = "Crear" "Create a variable collection first" = "Crea primero una colección de variables" @@ -1125,6 +1126,7 @@ status = "draft — needs native review" "Rulers" = "Reglas" "Run" = "Ejecutar" "Run {label}" = "Ejecutar {label}" +"Save file" = "Guardar archivo" "Save files automatically" = "Guardar archivos automáticamente" "Search templates" = "Buscar plantillas" "See all templates" = "Ver todas las plantillas" @@ -1507,6 +1509,7 @@ status = "draft — needs native review" "call any command its access allows" = "llamar a cualquier comando que su acceso permita" "call only: {commands}" = "llamar solo a: {commands}" "can edit the document" = "puede editar el documento" +"can save files" = "puede guardar archivos" "crop marks" = "marcas de corte" "development: {path}" = "desarrollo: {path}" "e.g. count + 1" = "p. ej. count + 1" @@ -1525,6 +1528,7 @@ status = "draft — needs native review" "read and edit the document" = "leer y editar el documento" "read the document" = "leer el documento" "reads the document" = "lee el documento" +"save files you choose" = "guardar archivos donde tú elijas" "the plugin" = "el plugin" "this session only" = "solo esta sesión" "yesterday" = "ayer" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..3ee02891 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -148,6 +148,8 @@ mod perf_tests; #[cfg(test)] mod plugin_panel_tests; #[cfg(test)] +mod plugin_save_ui_tests; +#[cfg(test)] mod plugins_browse_tests; #[cfg(test)] mod popup_width_tests; @@ -891,6 +893,7 @@ impl FigApp { } "dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")), "dialog.plugins" => self.ui.plugins_window = true, + "dialog.pluginSave" => plugins_ui::save_file(self, &r.data), "dialog.cursorChat" => live::open_chat(self), "window.new" | "window.focus" | "window.fullscreen" => self.window_requests.push(r), _ => {} diff --git a/crates/ui-egui/src/plugin_save_ui_tests.rs b/crates/ui-egui/src/plugin_save_ui_tests.rs new file mode 100644 index 00000000..6302e2d2 --- /dev/null +++ b/crates/ui-egui/src/plugin_save_ui_tests.rs @@ -0,0 +1,71 @@ +//! A plugin's `dialog.pluginSave` request: the browser downloads the file, a platform with save +//! dialogs asks the user (and writes nothing when they cancel). + +use std::sync::{Arc, Mutex}; + +use base64::Engine as _; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, NoServices, Services}; + +type Downloads = Arc)>>>; + +/// Services that save by downloads and keep them (`deliver` false: the browser refused). +struct Browser { + got: Downloads, + deliver: bool, +} + +impl Services for Browser { + fn downloads(&self) -> bool { + true + } + fn download(&mut self, name: &str, bytes: &[u8]) -> bool { + self.got.lock().unwrap().push((name.to_string(), bytes.to_vec())); + self.deliver + } +} + +fn request(app: &mut FigApp, name: &str, data: &str) { + app.session.request_ui("dialog.pluginSave", json!({"plugin": "org.test.save", "name": name, "mime": "text/plain", "data": data})); + app.handle_ui_requests(); +} + +fn toast(app: &FigApp) -> String { + app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +fn b64(bytes: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +#[test] +fn the_browser_downloads_the_file_with_its_exact_bytes() { + let got = Downloads::default(); + let mut app = FigApp::new(Session::new(), Box::new(Browser { got: got.clone(), deliver: true })); + request(&mut app, "out.jsx", &b64(b"\x00hello\xff")); + let got = got.lock().unwrap(); + assert_eq!(got.as_slice(), [("out.jsx".to_string(), b"\x00hello\xff".to_vec())]); + assert!(toast(&app).contains("out.jsx"), "{}", toast(&app)); +} + +#[test] +fn a_refused_download_and_broken_data_say_so() { + let got = Downloads::default(); + let mut app = FigApp::new(Session::new(), Box::new(Browser { got: got.clone(), deliver: false })); + request(&mut app, "out.jsx", &b64(b"x")); + assert_eq!(toast(&app), "Couldn't save the file"); + app.ui.toast = None; + request(&mut app, "out.jsx", "***not base64***"); + assert_eq!(toast(&app), "Couldn't save the file"); + assert_eq!(got.lock().unwrap().len(), 1, "broken data is never handed over"); +} + +#[test] +fn a_cancelled_save_dialog_writes_nothing() { + // `NoServices` has no downloads and answers the save dialog with "cancelled". + let mut app = FigApp::new(Session::new(), Box::new(NoServices)); + request(&mut app, "out.jsx", &b64(b"x")); + assert!(app.ui.toast.is_none()); +} diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 481b446c..ed00604d 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -110,6 +110,36 @@ pub fn start(app: &mut FigApp, id: &str, command: &str) { } } +/// A plugin offered a file to save (`dialog.pluginSave`: `name`, `data` as base64): the user picks +/// where it goes in FigCraft's own save dialog, or the browser downloads it. The plugin never learns +/// the path. +pub(crate) fn save_file(app: &mut FigApp, data: &Value) { + use base64::Engine as _; + let name = data["name"].as_str().unwrap_or_default().to_string(); + let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data["data"].as_str().unwrap_or_default()) else { + app.toast(tr("Couldn't save the file")); + return; + }; + if app.services.downloads() { + if app.services.download(&name, &bytes) { + app.toast(trf("Saved {name}", &[("name", &name)])); + } else { + app.toast(tr("Couldn't save the file")); + } + return; + } + let ext = std::path::Path::new(&name).extension().and_then(|e| e.to_str()).unwrap_or_default().to_string(); + let exts: Vec<&str> = if ext.is_empty() { Vec::new() } else { vec![ext.as_str()] }; + let shown = name.clone(); + app.pick_save(tr("Save file"), &name, &exts, move |app, path| { + let file = path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or(shown); + match std::fs::write(&path, &bytes) { + Ok(()) => app.toast(trf("Saved {name}", &[("name", &file)])), + Err(_) => app.toast(tr("Couldn't save the file")), + } + }); +} + /// Run `plugin.run` or `plugin.event`; a text output is shown as a toast unless the plugin showed /// a message. fn run(app: &mut FigApp, cmd: &str, params: Value) { @@ -447,6 +477,9 @@ fn confirm_window(app: &mut FigApp, ctx: &Context, t: &Tokens) { if e.widget { asks.push(tr("put a widget on the canvas").to_string()); } + if e.save_file { + asks.push(tr("save files you choose").to_string()); + } for a in asks { ui.add(egui::Label::new(RichText::new(format!("• {a}")).font(regular(11.0)).color(t.text)).wrap()); } @@ -501,6 +534,9 @@ fn summary(p: &InstalledPlugin) -> String { parts.push(m.version.clone()); } parts.push(access.to_string()); + if m.permissions.save_file { + parts.push(tr("can save files").to_string()); + } parts.push(match (&p.source, &p.path) { (Some(server), _) => trf("from {server}", &[("server", server)]), (None, Some(path)) => trf("development: {path}", &[("path", path)]), diff --git a/docs/plugins.md b/docs/plugins.md index 6f23e6ec..f5182930 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -135,7 +135,7 @@ plugins: import them in the script. | `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, output, logs, calls, fuel}`: `targets` are the names of its clickable parts. | | `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. | | `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets}]}`: every widget in the file. | -| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | +| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, save_file, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | | `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. | | `plugin.directory.publish` | `{path \| plugin, server}` | Publishes a plugin folder (or its `figcraft-plugin.json`), or an installed plugin, as a new version. `server` carries the server's key: `ws://host:port/?key=…`. Returns `{published, server}`. | | `plugin.directory.unpublish` | `{id, version?, server}` | Removes one version (default: every version) from the directory; needs the key. Returns `{id, removed}`. | @@ -224,7 +224,8 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p | `version`, `description` | Optional text. A plugin published to a [directory](#plugin-directories) needs a `version` of one to four numbers (`1.2.0`), each version newer than the last. | | `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. | | `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. | -| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings) or `3` (widgets). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels or widgets fails clearly on an older FigCraft. | +| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. | +| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. The install confirmation lists it. | | `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). | | `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. | | `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. | @@ -236,6 +237,9 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p Whatever its manifest says, FigCraft refuses these calls (the plugin gets the refusal as an error it can handle): +- read or write files: a plugin never gets a path or the disk. With `permissions.saveFile` it can + only hand FigCraft a file (one per run, 16 MB at most, a plain file name) for the user to save + through FigCraft's own dialog; the user can cancel it; - files and the app: `file.*`, `app.*`, `pref.*`, `help.*`, `library.add`, `libraries.*`, `image.place` with a `path` (pass the image as base64 `data` instead), `view.theme*`; - other documents and the version history: `doc.*` and `version.*` (except `doc.list` and @@ -262,9 +266,10 @@ allocation or copy. This also applies when the configured per-call overhead is z read is charged for the bytes actually returned, not the requested buffer size. `calls` counts every ABI import, including calls which only query the reply length. -## ABI (version 3) +## ABI (version 4) -Version 2 adds panels and settings to version 1, and version 3 adds widgets; a version 1 or 2 +Version 2 adds panels and settings to version 1, version 3 adds widgets and version 4 adds +`save_file`; a version 1, 2 or 3 module runs unchanged and gets the input it always got. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values are `i32`. @@ -311,6 +316,7 @@ included, is refused when the plugin is imported): | `setting_get` | `(key_ptr, key_len) -> status` | API 2. `0`: the reply holds the setting's JSON. `1`: there is none. | | `setting_set` | `(key_ptr, key_len, value_ptr, value_len) -> status` | API 2. Stores JSON under the key, for this plugin only, kept with the preferences. `1`: refused (not JSON, a bad key, over 64 KB), the reply says why. | | `setting_delete` | `(key_ptr, key_len)` | API 2. Forgets a setting. | +| `save_file` | `(name_ptr, name_len, mime_ptr, mime_len, data_ptr, data_len) -> status` | API 4, needs `permissions.saveFile`. Offers a file to save; FigCraft asks the user where it goes once the run succeeds. `1`: refused (no permission, a bad file name, over 16 MB, a second file in one run), the reply says why. | | `widget_render` | `(ptr, len) -> status` | API 3. Draws the widget being inserted or clicked (the widget description below, as JSON). `1`: the description is invalid, the reply says why. A widget call that doesn't draw keeps the widget as it was (inserting one must draw it). | A pointer or length outside the plugin's memory traps, which fails the run. Host functions can only @@ -369,7 +375,7 @@ image sizes) and refuses it if anything is off. `plugins/` in the repository is a workspace of its own: `plugins/sdk` (the crate `figcraft-plugin-sdk`, `no_std` with `alloc`) wraps the ABI with serde_json, and -`plugins/examples` has three plugins: +`plugins/examples` has four plugins: - **Tidy names** (`tidy-names`): cleans up the selected layers' names (extra spaces, the "copy" left by duplicating) and optionally their letter case, inside the selection too. Two commands: one @@ -382,6 +388,9 @@ image sizes) and refuses it if anything is off. hex colours in the parameters form. - **Counter** (`counter`, API 3): a widget, a count between − and + buttons; each click changes the count kept in the widget's state. It only reads the document. +- **Export names** (`export-names`, API 4): saves the names of the selected layers (optionally the + layers inside them too) as `layer-names.txt`. It reads the document and offers the file with + `save_file`; FigCraft asks where it goes. `cargo xtask plugins` (part of `cargo xtask wasm` and `cargo xtask ci`; it needs the `wasm32-unknown-unknown` target: `rustup target add wasm32-unknown-unknown`) checks, tests and @@ -438,7 +447,9 @@ panel calls back: `control`, `value` and every field's `values`); returning `Err rolls it back. With `"api": 2` in the manifest: `show_ui(&json!({...}))` and `close_ui()` for the panel, `setting(key)`, `set_setting(key, &value)` and `delete_setting(key)` for settings. With `"api": 3` and `"widget": true`: `input.widget` (`id`, `state`, `target`) when the widget is -inserted or clicked, and `render_widget(&json!({"state": ..., "root": ...}))` to draw it. Outside +inserted or clicked, and `render_widget(&json!({"state": ..., "root": ...}))` to draw it. With +`"api": 4` and `"permissions": {"saveFile": true}`: `save_file(name, mime, &bytes)` offers a file; +FigCraft asks the user where it goes and the plugin never learns a path. Outside WebAssembly every host call fails, so the plugin's own logic can be unit-tested with `cargo test`. Any language that compiles to WebAssembly works the same way: export `memory` and `figcraft_run`, diff --git a/plugins/Cargo.lock b/plugins/Cargo.lock index 7738ceb5..87244377 100644 --- a/plugins/Cargo.lock +++ b/plugins/Cargo.lock @@ -9,6 +9,13 @@ dependencies = [ "figcraft-plugin-sdk", ] +[[package]] +name = "export-names" +version = "0.1.0" +dependencies = [ + "figcraft-plugin-sdk", +] + [[package]] name = "figcraft-plugin-sdk" version = "0.1.0" diff --git a/plugins/examples/export-names/Cargo.toml b/plugins/examples/export-names/Cargo.toml new file mode 100644 index 00000000..35b7eb81 --- /dev/null +++ b/plugins/examples/export-names/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "export-names" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lib] +crate-type = ["cdylib"] + +[lints] +workspace = true + +[dependencies] +figcraft-plugin-sdk = { workspace = true } diff --git a/plugins/examples/export-names/figcraft-plugin.json b/plugins/examples/export-names/figcraft-plugin.json new file mode 100644 index 00000000..ab8f943f --- /dev/null +++ b/plugins/examples/export-names/figcraft-plugin.json @@ -0,0 +1,22 @@ +{ + "id": "org.figcraft.examples.export-names", + "name": "Export names", + "version": "1.0.0", + "description": "Saves the names of the selected layers as a text file, one per line.", + "main": "export_names.wasm", + "api": 4, + "permissions": { + "access": "read", + "commands": ["node.get"], + "saveFile": true + }, + "commands": [ + { + "id": "export", + "label": "Export names…", + "params": [ + { "name": "children", "label": "Include layers inside", "type": "boolean", "default": true } + ] + } + ] +} diff --git a/plugins/examples/export-names/src/lib.rs b/plugins/examples/export-names/src/lib.rs new file mode 100644 index 00000000..4525a39c --- /dev/null +++ b/plugins/examples/export-names/src/lib.rs @@ -0,0 +1,55 @@ +//! Export names: saves the names of the selected layers (optionally the layers inside them too) as +//! the text file `layer-names.txt`, through the host's own Save dialog (`save_file`). + +use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json, notify, save_file}; + +/// Layers looked at in one run, at most. +const MAX_LAYERS: usize = 10_000; + +/// The names as the file's text: one per line, a line break at the end, line breaks inside a name +/// turned into spaces. +fn names_text(names: &[String]) -> String { + let mut text = String::new(); + for name in names { + text.extend(name.chars().map(|c| if c == '\n' || c == '\r' { ' ' } else { c })); + text.push('\n'); + } + text +} + +fn run(input: Input) -> Result { + if input.selection.is_empty() { + return Err("Select the layers to export first".into()); + } + let inside = input.bool("children").unwrap_or(true); + // Depth first, in layer order: the stack takes the children reversed. + let mut stack: Vec = input.selection.iter().rev().copied().collect(); + let mut names = Vec::new(); + while let Some(id) = stack.pop() { + if names.len() >= MAX_LAYERS { + break; + } + let node = call("node.get", &json!({"id": id}))?; + names.push(node["name"].as_str().unwrap_or_default().to_string()); + if inside && let Some(children) = node["children"].as_array() { + stack.extend(children.iter().rev().filter_map(|c| c["id"].as_u64())); + } + } + save_file("layer-names.txt", "text/plain", names_text(&names).as_bytes())?; + notify(&format!("Exporting {} names", names.len())); + Ok(json!({"names": names.len()})) +} + +entry!(run); + +#[cfg(test)] +mod tests { + use super::names_text; + + #[test] + fn names_become_lines() { + let names = ["Card".to_string(), "Title\nline".to_string(), String::new()]; + assert_eq!(names_text(&names), "Card\nTitle line\n\n"); + assert_eq!(names_text(&[]), ""); + } +} diff --git a/plugins/sdk/src/lib.rs b/plugins/sdk/src/lib.rs index 967af788..65f70f8d 100644 --- a/plugins/sdk/src/lib.rs +++ b/plugins/sdk/src/lib.rs @@ -187,6 +187,14 @@ pub fn delete_setting(key: &str) { host::setting_delete(key.as_bytes()); } +/// Offer a file to save: a suggested file `name`, its MIME type and the bytes. FigCraft asks the +/// user where it goes (a download in the browser) once the run succeeds; the plugin never learns +/// a path. Needs `"api": 4` and `permissions.saveFile` in the manifest. A refusal (no permission, +/// a bad name, too much data, a second file in one run) is an error. +pub fn save_file(name: &str, mime: &str, data: &[u8]) -> Result<()> { + if host::save_file(name.as_bytes(), mime.as_bytes(), data) == 0 { Ok(()) } else { Err(String::from_utf8_lossy(&host::reply()).into_owned()) } +} + /// The body of `figcraft_run` (use [`entry!`]): read the input, run `f`, hand back its output. pub fn run_plugin(input_len: i32, f: fn(Input) -> Result) -> i32 { let input = host::reply(); @@ -238,6 +246,7 @@ mod host { pub fn setting_set(key: *const u8, key_len: usize, value: *const u8, value_len: usize) -> i32; pub fn setting_delete(key: *const u8, key_len: usize); pub fn widget_render(ptr: *const u8, len: usize) -> i32; + pub fn save_file(name: *const u8, name_len: usize, mime: *const u8, mime_len: usize, data: *const u8, data_len: usize) -> i32; } } @@ -283,6 +292,9 @@ mod host { pub fn widget_render(s: &[u8]) -> i32 { unsafe { sys::widget_render(s.as_ptr(), s.len()) } } + pub fn save_file(name: &[u8], mime: &[u8], data: &[u8]) -> i32 { + unsafe { sys::save_file(name.as_ptr(), name.len(), mime.as_ptr(), mime.len(), data.as_ptr(), data.len()) } + } } /// Outside WebAssembly (a plugin's own unit tests) every host call fails. @@ -316,6 +328,9 @@ mod host { pub fn widget_render(_s: &[u8]) -> i32 { 1 } + pub fn save_file(_name: &[u8], _mime: &[u8], _data: &[u8]) -> i32 { + 1 + } } #[cfg(test)] @@ -339,6 +354,7 @@ mod tests { assert_eq!((e.control.as_str(), &e.value, &e.values), ("go", &Value::Null, &json!({"n": 2}))); assert!(show_ui(&json!({})).is_err() && setting("k").is_none() && set_setting("k", &json!(1)).is_err()); assert!(render_widget(&json!({})).is_err() && i.widget.is_none()); + assert!(save_file("a.txt", "text/plain", b"x").is_err()); let w = Input::parse(r#"{"command":"widget","widget":{"id":9,"state":{"n":1},"event":{"target":"inc"}}}"#) .unwrap_or_default() .widget diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e3f00e8b..bd863dbe 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -81,7 +81,7 @@ fn wasm() -> bool { /// The example plugins in `plugins/examples` (each folder's name is its package; its module is /// `.wasm`). -const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid", "counter"]; +const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid", "counter", "export-names"]; /// The plugin workspace: fmt, clippy and build for wasm32, host tests, then each example's manifest /// and module side by side in `/plugins/dist//`. From a370211b486ba7e36b0efc05d4700adbba731aec Mon Sep 17 00:00:00 2001 From: Dubblbindong <48181403+Dubblbindong@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:11:59 +0200 Subject: [PATCH 02/87] fix(plugin): keep the plugin directory's wire format; refuse oversized save_file data - PluginEntry no longer carries save_file: postcard is positional and existing messages keep their fields (docs/collab-protocol.md), so mixed app/server versions keep reading the directory. Manage plugins still shows "can save files". - save_file checks the data length before reading it and refuses with status 1 and the reason, as documented, instead of failing the run with a limit error. Co-Authored-By: Claude Opus 5.5 --- apps/figcraft-server/src/directory.rs | 1 - crates/collab/src/message.rs | 2 -- crates/engine/src/plugin_directory.rs | 2 +- crates/plugin/src/sandbox.rs | 11 +++++++++++ crates/plugin/src/save_file_tests.rs | 10 ++++++---- crates/ui-egui/locales/de.toml | 1 - crates/ui-egui/locales/en.toml | 1 - crates/ui-egui/locales/es.toml | 1 - crates/ui-egui/src/plugins_ui.rs | 3 --- docs/plugins.md | 4 ++-- 10 files changed, 20 insertions(+), 16 deletions(-) diff --git a/apps/figcraft-server/src/directory.rs b/apps/figcraft-server/src/directory.rs index cdf18364..0443c0f4 100644 --- a/apps/figcraft-server/src/directory.rs +++ b/apps/figcraft-server/src/directory.rs @@ -245,7 +245,6 @@ impl Directory { }, commands: m.permissions.commands.clone(), widget: m.widget, - save_file: m.permissions.save_file, bytes: bytes.len() as u64, sha256: sha.clone(), published: now(), diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 734e4076..d035042b 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -537,8 +537,6 @@ pub struct PluginEntry { pub commands: Vec, /// It draws a widget. pub widget: bool, - /// It may offer files to save (`permissions.saveFile`). - pub save_file: bool, /// The bundle's size in bytes and its SHA-256 (hex). pub bytes: u64, pub sha256: String, diff --git a/crates/engine/src/plugin_directory.rs b/crates/engine/src/plugin_directory.rs index 1d33178f..2f721fea 100644 --- a/crates/engine/src/plugin_directory.rs +++ b/crates/engine/src/plugin_directory.rs @@ -288,7 +288,7 @@ pub fn specs() -> Vec { spec!( "plugin.directory.list", "Browse plugin directory", - "{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, save_file, bytes, sha256, published, versions, installed, update}]}", + "{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}", false, list ), diff --git a/crates/plugin/src/sandbox.rs b/crates/plugin/src/sandbox.rs index 59d66118..c7337dca 100644 --- a/crates/plugin/src/sandbox.rs +++ b/crates/plugin/src/sandbox.rs @@ -195,6 +195,12 @@ struct State { } impl State { + /// Make `message` the reply and return status 1 (refused). + fn refuse(&mut self, message: String) -> i32 { + self.set_reply(message.into_bytes()); + 1 + } + /// Make `bytes` the reply; returns its length. fn set_reply(&mut self, bytes: Vec) -> i32 { let max = self.limits.max_message; @@ -595,6 +601,11 @@ fn linker(engine: &Engine) -> Result, PluginError> { charge(&mut c)?; let name = read_str(&mut c, np, nl)?; let mime = read_str(&mut c, mp, ml)?; + // Too much data is a refusal the plugin can handle, not a limit that fails the run. + let (len, max) = (dl as u32 as usize, c.data().limits.max_message); + if len > max { + return Ok(c.data_mut().refuse(format!("a file to save is limited to {max} bytes, this one has {len}"))); + } let data = read_bytes(&mut c, dp, dl)?; Err(ask(&mut c, Request::SaveFile(name, mime, data))) }) diff --git a/crates/plugin/src/save_file_tests.rs b/crates/plugin/src/save_file_tests.rs index 34241bb7..7915323f 100644 --- a/crates/plugin/src/save_file_tests.rs +++ b/crates/plugin/src/save_file_tests.rs @@ -98,12 +98,14 @@ fn a_host_without_saving_refuses_by_default() { } #[test] -fn too_much_data_is_a_limit_error_and_bad_utf8_a_trap() { - let limits = Limits { max_message: 4, ..Limits::default() }; - let wasm = module("a.txt", "text/plain", b"12345", &call("a.txt", "text/plain", b"12345")); +fn too_much_data_is_refused_and_bad_utf8_a_trap() { + let limits = Limits { max_message: 64, ..Limits::default() }; + let data = [b'x'; 65]; + let wasm = module("a.txt", "text/plain", &data, &call("a.txt", "text/plain", &data)); let mut host = Saves::default(); + // Status 1 with the reason, which the module passes on as its own failure. let err = Instance::new(&wasm, &limits).unwrap().call("{}", &mut host).unwrap_err(); - assert!(matches!(err, PluginError::Limit(_)), "{err:?}"); + assert_eq!(err, PluginError::Failed("a file to save is limited to 64 bytes, this one has 65".into())); assert!(host.0.is_empty()); // The name points at a lone 0xff byte: not UTF-8. diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 08aa5c1a..03de53af 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1533,7 +1533,6 @@ status = "draft — needs native review" "read and edit the document" = "das Dokument lesen und bearbeiten" "read the document" = "das Dokument lesen" "reads the document" = "liest das Dokument" -"save files you choose" = "Dateien an einem von Ihnen gewählten Ort speichern" "set-variable actions on variables that aren't in the variables JSON were skipped" = "Aktionen „Variable festlegen“ für Variablen, die nicht im Variablen-JSON sind, wurden übersprungen" "set-variable actions with values that can't be evaluated were skipped" = "Aktionen „Variable festlegen“ mit nicht auswertbaren Werten wurden übersprungen" "set-variable actions with values that don't fit the variable were skipped" = "Aktionen „Variable festlegen“ mit Werten, die nicht zur Variable passen, wurden übersprungen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 58890934..8a31028e 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1621,7 +1621,6 @@ name = "English" "read and edit the document" = "read and edit the document" "read the document" = "read the document" "reads the document" = "reads the document" -"save files you choose" = "save files you choose" "set-variable actions on variables that aren't in the variables JSON were skipped" = "set-variable actions on variables that aren't in the variables JSON were skipped" "set-variable actions with values that can't be evaluated were skipped" = "set-variable actions with values that can't be evaluated were skipped" "set-variable actions with values that don't fit the variable were skipped" = "set-variable actions with values that don't fit the variable were skipped" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 1f92bf91..08e47492 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1622,7 +1622,6 @@ status = "draft — needs native review" "read and edit the document" = "leer y editar el documento" "read the document" = "leer el documento" "reads the document" = "lee el documento" -"save files you choose" = "guardar archivos donde tú elijas" "set-variable actions on variables that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer variable» sobre variables que no están en el JSON de variables" "set-variable actions with values that can't be evaluated were skipped" = "se omitieron las acciones «Establecer variable» con valores que no se pueden evaluar" "set-variable actions with values that don't fit the variable were skipped" = "se omitieron las acciones «Establecer variable» con valores que no encajan con la variable" diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 919fd484..7933fa7b 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -477,9 +477,6 @@ fn confirm_window(app: &mut FigApp, ctx: &Context, t: &Tokens) { if e.widget { asks.push(tr("put a widget on the canvas").to_string()); } - if e.save_file { - asks.push(tr("save files you choose").to_string()); - } for a in asks { ui.add(egui::Label::new(RichText::new(format!("• {a}")).font(regular(11.0)).color(t.text)).wrap()); } diff --git a/docs/plugins.md b/docs/plugins.md index ed4b9927..a14dd7a4 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -135,7 +135,7 @@ plugins: import them in the script. | `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, output, logs, calls, fuel}`: `targets` are the names of its clickable parts. | | `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. | | `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets}]}`: every widget in the file. | -| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, save_file, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | +| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | | `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. | | `plugin.directory.publish` | `{path \| plugin, server}` | Publishes a plugin folder (or its `figcraft-plugin.json`), or an installed plugin, as a new version. `server` carries the server's key: `ws://host:port/?key=…`. Returns `{published, server}`. | | `plugin.directory.unpublish` | `{id, version?, server}` | Removes one version (default: every version) from the directory; needs the key. Returns `{id, removed}`. | @@ -225,7 +225,7 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p | `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. | | `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. | | `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. | -| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. The install confirmation lists it. | +| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. Manage plugins lists it ("can save files"); a plugin directory's entries don't carry it, so their wire format stays as it is. | | `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). | | `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. | | `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. | From 12e0cdc147fd646e1c4eea550323de0bf8640494 Mon Sep 17 00:00:00 2001 From: Nicek Date: Wed, 21 Oct 2026 22:18:30 +0800 Subject: [PATCH 03/87] =?UTF-8?q?feat:=20=E4=B8=BA=E5=A1=AB=E5=85=85?= =?UTF-8?q?=E3=80=81=E6=8F=8F=E8=BE=B9=E5=92=8C=E6=95=88=E6=9E=9C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=8F=B3=E9=94=AE=E5=A4=8D=E5=88=B6=E7=B2=98=E8=B4=B4?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现了在属性行和分区标题上右键菜单,支持复制粘贴单个填充、描边和效果。新增了相关的命令、测试和国际化文本,完善了剪贴板数据结构以支持这些新功能。 --- crates/engine/src/cmd/edit.rs | 103 +++++++++++++++++++++- crates/engine/src/lib.rs | 8 ++ crates/engine/src/property_copy_tests.rs | 90 +++++++++++++++++++ crates/ui-egui/locales/en.toml | 6 ++ crates/ui-egui/locales/es.toml | 12 +++ crates/ui-egui/locales/zh-Hans.toml | 12 +++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panel_remove_tests.rs | 19 ++++ crates/ui-egui/src/property_menu_tests.rs | 101 +++++++++++++++++++++ crates/ui-egui/src/right.rs | 60 +++++++++++-- crates/ui-egui/src/widgets.rs | 18 +++- docs/parity-checklist.md | 4 +- 12 files changed, 425 insertions(+), 10 deletions(-) create mode 100644 crates/engine/src/property_copy_tests.rs create mode 100644 crates/ui-egui/src/property_menu_tests.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a16fbb38..0791f7e3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -229,6 +229,22 @@ pub fn specs() -> Vec { spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)), spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)), spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)), + spec!("fill.copy", "Copy fill", "{id?, index? (default the top fill)}", false, |s, p| paint_copy(s, p, true)), + spec!("fill.paste", "Paste fill", "{ids?, index? (replace the fill there; without: add one)}", true, |s, p| paint_paste(s, p, true)), + spec!( + "stroke.copy", + "Copy stroke", + "{id?, index? (default the top stroke)} its paint and the stroke's weight, alignment, sides, caps, joins and dashes", + false, + |s, p| paint_copy(s, p, false) + ), + spec!( + "stroke.paste", + "Paste stroke", + "{ids?, index? (replace the stroke there; without: add one)} the copied stroke's settings come with its paint", + true, + |s, p| paint_paste(s, p, false) + ), spec!( "fill.pattern", "Pattern fill", @@ -276,6 +292,8 @@ pub fn specs() -> Vec { } ok() }), + spec!("effect.copy", "Copy effect", "{id?, index? (default the top effect)}", false, effect_copy), + spec!("effect.paste", "Paste effect", "{ids?, index? (replace the effect there; without: add one)}", true, effect_paste), spec!("grid.add", "Add layout grid", "{ids?, grid? {kind: grid|columns|rows, ..}}", true, |s, p| { let g: LayoutGrid = parse("grid.add", p, "grid")?.unwrap_or_default(); for id in req_targets("grid.add", s, p)? { @@ -608,7 +626,7 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result { let props = s.clipboard.props.take(); // No open file has id 0. So paste treats this copy as a copy from a different file. s.clipboard = - crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() }; + crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, ..crate::Clipboard::default() }; let mut out = paste(s, p)?; if !other.issues.is_empty() { out["warnings"] = json!(other.issues); @@ -1386,6 +1404,53 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { ok() } +/// Copy paint `index` (the top one by default) of the first target into the clipboard. A stroke +/// takes its node-wide settings (weight, alignment, sides, caps, joins, dashes) along. +fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result { + let cmd = if fills { "fill.copy" } else { "stroke.copy" }; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; + let at = opt_index(cmd, p, "index")?; + let (paint, stroke) = { + let doc = s.doc()?; + let n = doc.get(id)?; + let list = if fills { &n.fills } else { &n.strokes }; + let at = at.unwrap_or(list.len().saturating_sub(1)); + let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?; + (paint, (!fills).then(|| n.stroke.clone())) + }; + s.clipboard.paint = Some((fills, paint.clone())); + s.clipboard.stroke = stroke; + serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string())) +} + +/// Paste the copied paint onto the targets: replacing paint `index` there, or adding one. A +/// stroke's copied settings go onto each target too. +fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result { + let cmd = if fills { "fill.paste" } else { "stroke.paste" }; + let Some((copied_fills, paint)) = s.clipboard.paint.clone() else { + return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into())); + }; + if copied_fills != fills { + let what = if fills { "stroke" } else { "fill" }; + return Err(EngineError::Disabled(cmd.into(), format!("the copied paint is a {what}"))); + } + let ids = req_targets(cmd, s, p)?; + let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?}); + let out = match opt_index(cmd, p, "index")? { + Some(at) => { + q["index"] = json!(at); + paint_set(s, &q, fills)? + } + None => paint_list(s, &q, fills, true)?, + }; + if let Some(stroke) = s.clipboard.stroke.clone().filter(|_| !fills) { + for id in ids { + s.doc_mut()?.get_mut(id)?.stroke = stroke.clone(); + } + } + Ok(out) +} + fn pattern_fill(s: &mut Session, p: &Value) -> Result { const CMD: &str = "fill.pattern"; let src = req_id(CMD, p, "source")?; @@ -1422,3 +1487,39 @@ fn effect_add(s: &mut Session, p: &Value) -> Result { } ok() } + +/// Copy effect `index` (the top one by default) of the first target into the clipboard. +fn effect_copy(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "effect.copy"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(CMD, "select a layer"))?; + let at = opt_index(CMD, p, "index")?; + let e = { + let doc = s.doc()?; + let list = &doc.get(id)?.effects; + let at = at.unwrap_or(list.len().saturating_sub(1)); + list.get(at).cloned().ok_or_else(|| bad(CMD, format!("no effect at index {at}")))? + }; + s.clipboard.effect = Some(e.clone()); + serde_json::to_value(&e).map_err(|err| bad(CMD, err.to_string())) +} + +/// Paste the copied effect onto the targets: replacing effect `index` there, or adding one. +fn effect_paste(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "effect.paste"; + let Some(e) = s.clipboard.effect.clone() else { + return Err(EngineError::Disabled(CMD.into(), "no copied effect".into())); + }; + let at = opt_index(CMD, p, "index")?; + for id in req_targets(CMD, s, p)? { + let n = s.doc_mut()?.get_mut(id)?; + match at { + Some(i) => { + if let Some(slot) = n.effects.get_mut(i) { + *slot = e.clone(); + } + } + None => n.effects.push(e.clone()), + } + } + ok() +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..3cb7de22 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -759,6 +759,12 @@ pub struct Clipboard { pub images: std::collections::BTreeMap, /// Copied properties (Copy properties / Paste properties). pub props: Option, + /// A copied fill or stroke row (`fill.copy`, `stroke.copy`): whether it's a fill, and the paint. + pub paint: Option<(bool, figcraft_color::Paint)>, + /// The stroke settings (`weight`, alignment, sides…) copied with a stroke's paint. + pub stroke: Option, + /// A copied effect row (`effect.copy`). + pub effect: Option, /// The page-space bounds of the copied selection. pub bounds: Option, pub source_doc: u64, @@ -1787,6 +1793,8 @@ pub(crate) mod plugins_tests; #[cfg(test)] mod progressive_blur_tests; #[cfg(test)] +mod property_copy_tests; +#[cfg(test)] mod proto_behaviour_tests; #[cfg(test)] mod proto_conditional_tests; diff --git a/crates/engine/src/property_copy_tests.rs b/crates/engine/src/property_copy_tests.rs new file mode 100644 index 00000000..f6cc70d7 --- /dev/null +++ b/crates/engine/src/property_copy_tests.rs @@ -0,0 +1,90 @@ +//! Copying one fill, stroke or effect row (`fill.copy`, `stroke.copy`, `effect.copy`) and pasting +//! it onto other layers (`fill.paste`, `stroke.paste`, `effect.paste`): the Design panel's row +//! menus. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Effect, EffectKind, NodeId}; +use serde_json::{Value, json}; + +use crate::Session; + +fn id(v: Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn rect(s: &mut Session) -> NodeId { + id(ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))) +} + +fn node(s: &Session, id: NodeId) -> figcraft_doc::Node { + s.doc().unwrap().node(id).unwrap().clone() +} + +#[test] +fn a_copied_fill_replaces_a_fill_row_or_adds_one() { + let mut s = Session::new(); + let a = rect(&mut s); + let b = rect(&mut s); + let red = Paint::solid(Color::from_hex("#FF0000").unwrap()); + ex(&mut s, "fill.add", json!({"ids": [a], "paint": red})); + let copied = ex(&mut s, "fill.copy", json!({"id": a, "index": 1})); + assert_eq!(copied["type"], "solid", "the copy reports the paint it took"); + // Onto b's fill row: it replaces that paint. + ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0})); + assert_eq!(node(&s, b).fills, vec![red.clone()]); + // With no row: it adds one on top. + ex(&mut s, "fill.paste", json!({"ids": [b]})); + assert_eq!(node(&s, b).fills, [red.clone(), red]); + // Each paste is one undo step. + ex(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, b).fills.len(), 1); +} + +#[test] +fn a_copied_stroke_pastes_as_a_stroke_only() { + let mut s = Session::new(); + let a = rect(&mut s); + let b = rect(&mut s); + let blue = Paint::solid(Color::from_hex("#0000FF").unwrap()); + ex(&mut s, "stroke.add", json!({"ids": [a], "paint": blue})); + ex(&mut s, "node.set", json!({"ids": [a], "props": {"strokeWeight": 3.0, "strokeAlign": "outside"}})); + ex(&mut s, "stroke.copy", json!({"id": a, "index": 0})); + ex(&mut s, "stroke.add", json!({"ids": [b]})); + ex(&mut s, "stroke.paste", json!({"ids": [b], "index": 0})); + assert_eq!(node(&s, b).strokes, [blue]); + assert_eq!(node(&s, b).stroke, node(&s, a).stroke, "the weight and alignment come with the paint"); + assert_eq!((node(&s, b).stroke.weight, node(&s, b).stroke.align), (3.0, figcraft_doc::StrokeAlign::Outside)); + // A copied fill doesn't paste as a stroke, and the target keeps its stroke. + ex(&mut s, "fill.copy", json!({"id": a})); + assert!(s.execute("stroke.paste", &json!({"ids": [b]})).is_err()); + assert_eq!(node(&s, b).strokes.len(), 1); + // A layer with no stroke has nothing to copy. + assert!(s.execute("stroke.copy", &json!({"id": b, "index": 1})).is_err()); +} + +#[test] +fn a_copied_effect_replaces_an_effect_row_or_adds_one() { + let mut s = Session::new(); + let a = rect(&mut s); + let b = rect(&mut s); + ex(&mut s, "effect.add", json!({"ids": [a], "effect": {"type": "dropShadow", "radius": 7, "offset": [2, 3]}})); + ex(&mut s, "effect.copy", json!({"id": a, "index": 0})); + let e = node(&s, a).effects[0].clone(); + // Onto b's effect row: it replaces that effect. + ex(&mut s, "effect.add", json!({"ids": [b], "type": "layerBlur"})); + ex(&mut s, "effect.paste", json!({"ids": [b], "index": 0})); + assert_eq!(node(&s, b).effects, vec![e.clone()]); + // With no row: it adds one on top. + ex(&mut s, "effect.paste", json!({"ids": [b]})); + assert_eq!(node(&s, b).effects, [e.clone(), e]); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, b).effects.len(), 1); + let Effect { kind: EffectKind::DropShadow(shadow), visible } = node(&s, b).effects[0].clone() else { + panic!("the pasted effect is a drop shadow") + }; + assert_eq!((shadow.radius, shadow.offset, visible), (7.0, (2.0, 3.0), true)); +} diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ae566fc8..aa9f97b3 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -313,7 +313,10 @@ name = "English" "Copy as PNG" = "Copy as PNG" "Copy as SVG" = "Copy as SVG" "Copy code" = "Copy code" +"Copy effect" = "Copy effect" +"Copy fill" = "Copy fill" "Copy link" = "Copy link" +"Copy stroke" = "Copy stroke" "Copy the {label}" = "Copy the {label}" "Copy {value}" = "Copy {value}" "Corner radius" = "Corner radius" @@ -971,7 +974,10 @@ name = "English" "Pass through" = "Pass through" "Paste a link (https://…)" = "Paste a link (https://…)" "Paste a team library link (a view link)" = "Paste a team library link (a view link)" +"Paste effect" = "Paste effect" +"Paste fill" = "Paste fill" "Paste here" = "Paste here" +"Paste stroke" = "Paste stroke" "Paste to replace" = "Paste to replace" "Pasted design layers; {n} kinds of content were left out" = { one = "Pasted design layers; {n} kind of content was left out", other = "Pasted design layers; {n} kinds of content were left out" } "Pattern" = "Pattern" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..f47ab965 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -315,7 +315,10 @@ status = "draft — needs native review" "Copy as PNG" = "Copiar como PNG" "Copy as SVG" = "Copiar como SVG" "Copy code" = "Copiar código" +"Copy effect" = "Copiar efecto" +"Copy fill" = "Copiar relleno" "Copy link" = "Copiar enlace" +"Copy stroke" = "Copiar trazo" "Copy the {label}" = "Copiar {label}" "Copy {value}" = "Copiar {value}" "Corner radius" = "Radio de esquina" @@ -972,7 +975,10 @@ status = "draft — needs native review" "Pass through" = "Atravesar" "Paste a link (https://…)" = "Pega un enlace (https://…)" "Paste a team library link (a view link)" = "Pega el enlace de una biblioteca de equipo (de lectura)" +"Paste effect" = "Pegar efecto" +"Paste fill" = "Pegar relleno" "Paste here" = "Pegar aquí" +"Paste stroke" = "Pegar trazo" "Paste to replace" = "Pegar para reemplazar" "Pasted design layers; {n} kinds of content were left out" = { one = "Capas de diseño pegadas; se omitió {n} tipo de contenido", other = "Capas de diseño pegadas; se omitieron {n} tipos de contenido" } "Pattern" = "Patrón" @@ -1894,7 +1900,9 @@ status = "draft — needs native review" "edit.setDefaultProperties" = "Establecer propiedades predeterminadas" "edit.undo" = "Deshacer" "effect.add" = "Añadir efecto" +"effect.copy" = "Copiar efecto" "effect.move" = "Mover efecto" +"effect.paste" = "Pegar efecto" "effect.remove" = "Quitar efecto" "effect.set" = "Establecer efecto" "export.add" = "Añadir ajuste de exportación" @@ -1922,7 +1930,9 @@ status = "draft — needs native review" "file.saveVersion" = "Guardar en el historial de versiones" "file.versionHistory" = "Mostrar historial de versiones" "fill.add" = "Añadir relleno" +"fill.copy" = "Copiar relleno" "fill.move" = "Mover relleno" +"fill.paste" = "Pegar relleno" "fill.pattern" = "Relleno de patrón" "fill.remove" = "Quitar relleno" "fill.set" = "Establecer relleno" @@ -2112,7 +2122,9 @@ status = "draft — needs native review" "shapeBuilder.remove" = "Quitar regiones" "stroke.add" = "Añadir trazo" "stroke.brush" = "Pincel" +"stroke.copy" = "Copiar trazo" "stroke.move" = "Mover trazo" +"stroke.paste" = "Pegar trazo" "stroke.remove" = "Quitar trazo" "stroke.set" = "Establecer trazo" "stroke.widthProfile" = "Perfil de ancho" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 94c91942..befa9685 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -300,7 +300,10 @@ status = "draft — needs native review" "Copy as PNG" = "复制为 PNG" "Copy as SVG" = "复制为 SVG" "Copy code" = "复制代码" +"Copy effect" = "复制效果" +"Copy fill" = "复制填充" "Copy link" = "复制链接" +"Copy stroke" = "复制描边" "Copy the {label}" = "复制{label}" "Copy {value}" = "复制 {value}" "Corner radius" = "圆角半径" @@ -910,7 +913,10 @@ status = "draft — needs native review" "Pass through" = "穿透" "Paste a link (https://…)" = "粘贴链接(https://…)" "Paste a team library link (a view link)" = "粘贴团队库链接(查看链接)" +"Paste effect" = "粘贴效果" +"Paste fill" = "粘贴填充" "Paste here" = "粘贴到此处" +"Paste stroke" = "粘贴描边" "Paste to replace" = "粘贴以替换" "Pasted design layers; {n} kinds of content were left out" = { other = "已粘贴设计图层;有 {n} 类内容未包含" } "Pattern" = "图案" @@ -1777,7 +1783,9 @@ status = "draft — needs native review" "edit.setDefaultProperties" = "设为默认属性" "edit.undo" = "撤销" "effect.add" = "添加效果" +"effect.copy" = "复制效果" "effect.move" = "移动效果" +"effect.paste" = "粘贴效果" "effect.remove" = "移除效果" "effect.set" = "设置效果" "export.add" = "添加导出设置" @@ -1804,7 +1812,9 @@ status = "draft — needs native review" "file.saveVersion" = "保存到版本历史" "file.versionHistory" = "显示版本历史" "fill.add" = "添加填充" +"fill.copy" = "复制填充" "fill.move" = "移动填充" +"fill.paste" = "粘贴填充" "fill.pattern" = "图案填充" "fill.remove" = "移除填充" "fill.set" = "设置填充" @@ -1989,7 +1999,9 @@ status = "draft — needs native review" "shapeBuilder.remove" = "移除区域" "stroke.add" = "添加描边" "stroke.brush" = "画笔" +"stroke.copy" = "复制描边" "stroke.move" = "移动描边" +"stroke.paste" = "粘贴描边" "stroke.remove" = "移除描边" "stroke.set" = "设置描边" "stroke.widthProfile" = "宽度配置" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..6dac06a6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -187,6 +187,8 @@ mod present_tests; #[cfg(test)] mod present_view_tests; #[cfg(test)] +mod property_menu_tests; +#[cfg(test)] mod proto_editor_tests; #[cfg(test)] mod proto_reorder_tests; diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index 3805dca3..bb74ee72 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -10,6 +10,7 @@ use serde_json::json; use crate::colorpicker::PickerTarget; use crate::layers_drag_tests::Ui; use crate::list_drag::grip_id; +use crate::live_tests::{flat, text_center}; fn id(v: serde_json::Value) -> NodeId { NodeId(v["id"].as_u64().unwrap()) @@ -151,6 +152,24 @@ fn adding_a_fill_or_stroke_opens_the_color_picker() { assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: false, index: 0, ids: vec![r] })); } +#[test] +fn an_empty_sections_header_adds_its_item_but_a_full_one_only_adds_from_the_plus() { + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + // Nothing is stroked yet: clicking anywhere on the Stroke header adds one. + let shapes = flat(&u.frame_output(vec![]).shapes); + click(&mut u, text_center(&shapes, "Stroke")); + assert_eq!(u.app.session.doc().unwrap().node(r).unwrap().strokes.len(), 1); + // It has a stroke now: the header is no longer a button of its own. + u.app.ui.picker = None; + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + click(&mut u, text_center(&shapes, "Stroke")); + assert_eq!(u.app.session.doc().unwrap().node(r).unwrap().strokes.len(), 1); +} + #[test] fn adding_a_fill_to_mixed_fills_opens_no_picker() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/property_menu_tests.rs b/crates/ui-egui/src/property_menu_tests.rs new file mode 100644 index 00000000..177c7720 --- /dev/null +++ b/crates/ui-egui/src/property_menu_tests.rs @@ -0,0 +1,101 @@ +//! The Design panel's property row menus: a secondary click on a fill, stroke or effect row +//! copies that one paint or effect, and on another layer's row pastes it there. + +use egui::{Event, Modifiers, PointerButton, Pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; + +fn id(v: &serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session) -> NodeId { + id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()) +} + +/// A click with `button` at `p`, and the frames that follow; what the last frame drew. +fn click(u: &mut Ui, p: Pos2, button: PointerButton) -> Vec { + for _ in 0..5 { + u.frame(vec![Event::PointerMoved(p)]); + } + let mut shapes = Vec::new(); + for pressed in [Some(true), Some(false)].into_iter().chain(std::iter::repeat_n(None, 20)) { + let events = pressed.map(|pressed| Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE }); + shapes = flat(&u.frame_output(events.into_iter().collect()).shapes); + } + shapes +} + +/// The text a row menu offers, clicked. +fn pick(u: &mut Ui, menu: &[egui::Shape], label: &str) { + click(u, text_center(menu, label), PointerButton::Primary); +} + +#[test] +fn a_fill_row_copies_and_pastes_a_fill() { + let mut s = Session::new(); + let a = rect(&mut s); + let b = rect(&mut s); + s.execute("fill.set", &json!({"ids": [a], "color": "#FF0000"})).unwrap(); + s.execute("fill.set", &json!({"ids": [b], "color": "#0000FF"})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + let shapes = flat(&u.frame_output(vec![]).shapes); + let menu = click(&mut u, text_center(&shapes, "FF0000"), PointerButton::Secondary); + pick(&mut u, &menu, crate::i18n::tr("Copy fill")); + let copied = u.app.session.clipboard.paint.clone().expect("the fill is on the clipboard"); + assert_eq!(copied.1, u.app.session.doc().unwrap().node(a).unwrap().fills[0]); + // Onto b's fill row: it replaces that fill. + u.app.run("select.set", json!({"ids": [b]})); + let shapes = flat(&u.frame_output(vec![]).shapes); + let menu = click(&mut u, text_center(&shapes, "0000FF"), PointerButton::Secondary); + pick(&mut u, &menu, crate::i18n::tr("Paste fill")); + assert_eq!(u.app.session.doc().unwrap().node(b).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000"); +} + +#[test] +fn a_stroke_row_copies_and_pastes_a_stroke() { + let mut s = Session::new(); + let a = rect(&mut s); + let b = rect(&mut s); + s.execute("stroke.add", &json!({"ids": [a], "paint": {"type": "solid", "color": "#FF0000"}})).unwrap(); + s.execute("node.set", &json!({"ids": [a], "props": {"strokeWeight": 3.0}})).unwrap(); + s.execute("stroke.add", &json!({"ids": [b]})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + let shapes = flat(&u.frame_output(vec![]).shapes); + let menu = click(&mut u, text_center(&shapes, "FF0000"), PointerButton::Secondary); + pick(&mut u, &menu, crate::i18n::tr("Copy stroke")); + u.app.run("select.set", json!({"ids": [b]})); + let shapes = flat(&u.frame_output(vec![]).shapes); + let menu = click(&mut u, text_center(&shapes, "000000"), PointerButton::Secondary); + pick(&mut u, &menu, crate::i18n::tr("Paste stroke")); + let strokes = u.app.session.doc().unwrap().node(b).unwrap().strokes.clone(); + assert_eq!(strokes.len(), 1, "it replaced b's stroke"); + assert_eq!(strokes[0].solid_color().unwrap().to_hex(), "FF0000"); + assert_eq!(u.app.session.doc().unwrap().node(b).unwrap().stroke.weight, 3.0, "the weight came with it"); +} + +#[test] +fn an_effect_row_copies_and_pastes_an_effect() { + let mut s = Session::new(); + let a = rect(&mut s); + let b = rect(&mut s); + s.execute("effect.add", &json!({"ids": [a], "type": "layerBlur"})).unwrap(); + s.execute("effect.add", &json!({"ids": [b], "type": "dropShadow"})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + let shapes = flat(&u.frame_output(vec![]).shapes); + let menu = click(&mut u, text_center(&shapes, "Layer blur"), PointerButton::Secondary); + pick(&mut u, &menu, crate::i18n::tr("Copy effect")); + u.app.run("select.set", json!({"ids": [b]})); + let shapes = flat(&u.frame_output(vec![]).shapes); + let menu = click(&mut u, text_center(&shapes, "Drop shadow"), PointerButton::Secondary); + pick(&mut u, &menu, crate::i18n::tr("Paste effect")); + let effects = u.app.session.doc().unwrap().node(b).unwrap().effects.clone(); + assert_eq!(effects, u.app.session.doc().unwrap().node(a).unwrap().effects); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index afa884a8..b2babc56 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -253,6 +253,15 @@ pub fn context_popup(resp: &egui::Response) -> egui::Popup<'static> { egui::Popup::context_menu(resp).width(MENU_MIN_W).style(menu_style) } +/// A context menu for a property row: a secondary click anywhere over `rect` opens it, even over +/// the row's fields (which would otherwise take the click), like the incumbent's row menus. +fn row_context_menu(ui: &Ui, rect: Rect, id: Id) -> egui::Popup<'static> { + let resp = ui.interact(rect, id, Sense::hover()); + let open = ui.rect_contains_pointer(rect) && ui.input(|i| i.pointer.secondary_clicked()); + let at_pointer = egui::Popup::menu(&resp).width(MENU_MIN_W).style(menu_style).at_pointer_fixed(); + at_pointer.open_memory(open.then_some(egui::SetOpenCommand::Bool(true))) +} + pub fn menu_item(ui: &mut Ui, label: &str, shortcut: &str) -> bool { menu_row(ui, label, shortcut, 0.0, |_, _, _| {}).clicked() } @@ -1098,12 +1107,15 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: let title = if fills { tr("Fill") } else { tr("Stroke") }; let empty = list.as_ref().is_none_or(|l| l.is_empty()); let slot = if fills { StyleSlot::Fill } else { StyleSlot::Stroke }; - if let Some(i) = widgets::section_header( + // The section's right-click menu adds the copied paint (`fill.paste` / `stroke.paste`). + let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") }; + let (clicked, header) = widgets::section_header_resp( ui, title, &[(Icon::Styles, tr("Styles")), (Icon::Plus, if fills { tr("Add fill") } else { tr("Add stroke") })], empty, - ) { + ); + if let Some(i) = clicked { if i == 1 { // The new paint goes on top; open its picker. A mixed selection has no one index to edit. if app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})).is_some() @@ -1115,6 +1127,12 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: style_picker::open(app, ui, &ids, slot); } } + context_popup(&header).show(|ui| { + if app.session.clipboard.paint.as_ref().is_some_and(|(f, _)| *f == fills) && menu_item(ui, paste, "") { + app.run(if fills { "fill.paste" } else { "stroke.paste" }, json!({"ids": ids})); + ui.close(); + } + }); style_picker::chip(app, ui, doc, &ids, slot); match &list { None => widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary), @@ -1536,7 +1554,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, let t = Tokens::get(ui.ctx()); let ids = ids.to_vec(); let cmd = if fills { "fill.set" } else { "stroke.set" }; - row_rect(ui, 32.0, |ui| { + let r = row_rect(ui, 32.0, |ui| { let w = ui.available_width() - 56.0; let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover()); ui.painter().rect_filled(r, CornerRadius::same(5), t.field); @@ -1619,7 +1637,21 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, None => app.ui.picker = None, } } - }) + }); + let copy = if fills { tr("Copy fill") } else { tr("Copy stroke") }; + let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") }; + let can_paste = app.session.clipboard.paint.as_ref().is_some_and(|(f, _)| *f == fills); + row_context_menu(ui, r, Id::new(("paint_menu", fills, i))).show(|ui| { + if menu_item(ui, copy, "") { + app.run(if fills { "fill.copy" } else { "stroke.copy" }, json!({"ids": ids, "index": i})); + ui.close(); + } + if can_paste && menu_item(ui, paste, "") { + app.run(if fills { "fill.paste" } else { "stroke.paste" }, json!({"ids": ids, "index": i})); + ui.close(); + } + }); + r } /// A paint row's label (also what a drag of it carries): its colour, gradient kind or "Image". @@ -1637,13 +1669,20 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let list = common(doc, sel, |n| n.effects.clone()); let empty = list.as_ref().is_none_or(|l| l.is_empty()); - if let Some(i) = widgets::section_header(ui, tr("Effects"), &[(Icon::Styles, tr("Styles")), (Icon::Plus, tr("Add effect"))], empty) { + let (clicked, header) = widgets::section_header_resp(ui, tr("Effects"), &[(Icon::Styles, tr("Styles")), (Icon::Plus, tr("Add effect"))], empty); + if let Some(i) = clicked { if i == 1 { app.run("effect.add", json!({"ids": ids, "type": "dropShadow"})); } else { style_picker::open(app, ui, &ids, StyleSlot::Effect); } } + context_popup(&header).show(|ui| { + if app.session.clipboard.effect.is_some() && menu_item(ui, tr("Paste effect"), "") { + app.run("effect.paste", json!({"ids": ids})); + ui.close(); + } + }); style_picker::chip(app, ui, doc, &ids, StyleSlot::Effect); let Some(list) = list else { return }; let mut drag = ListDrag::new(ui, "effects"); @@ -1678,6 +1717,17 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { removed = Some(i); } }); + let can_paste = app.session.clipboard.effect.is_some(); + row_context_menu(ui, header, Id::new(("effect_menu", i))).show(|ui| { + if menu_item(ui, tr("Copy effect"), "") { + app.run("effect.copy", json!({"ids": ids, "index": i})); + ui.close(); + } + if can_paste && menu_item(ui, tr("Paste effect"), "") { + app.run("effect.paste", json!({"ids": ids, "index": i})); + ui.close(); + } + }); if open { effect_settings(app, ui, &ids, i, e); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 845dca9d..db83cbe9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -826,9 +826,19 @@ pub fn disclosure(ui: &mut Ui, label: &str, open: bool) -> bool { /// A section header ("Fill", "Stroke"…) with optional right-side icon buttons; returns which /// button was clicked. `dim` greys the title (empty sections). pub fn section_header(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: bool) -> Option { + section_header_resp(ui, title, buttons, dim).0 +} + +/// [`section_header`], with the header's own response for a right-click menu. +pub fn section_header_resp(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: bool) -> (Option, egui::Response) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); - let (rect, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); + let (rect, resp) = ui.allocate_exact_size(vec2(w, 40.0), Sense::click()); + // Only an empty section's header acts as its `+` button; elsewhere it is a right-click target + // alone, so it keeps the normal cursor. + if resp.hovered() && !dim { + ui.ctx().set_cursor_icon(egui::CursorIcon::Default); + } ui.painter().text( pos2(rect.min.x + PAD, rect.center().y), Align2::LEFT_CENTER, @@ -850,7 +860,11 @@ pub fn section_header(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: b } x -= 24.0; } - out + // An empty section's whole header adds its item, not only its `+` button. + if out.is_none() && dim && resp.clicked() { + out = buttons.iter().position(|(icon, _)| *icon == Icon::Plus); + } + (out, resp) } /// An icon button at `rect` (hover highlight, tooltip); true when clicked. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 775a4fcb..13fda27c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,8 +1,8 @@ # Parity checklist -> **Generated:** 2026-10-17 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-21 by `cargo xtask parity`. Do not edit by hand. -FigCraft commands registered for the incumbent's main menu and toolbar (561 commands in the engine). +FigCraft commands registered for the incumbent's main menu and toolbar (575 commands in the engine). **212 / 212 menu items (100.0%)** From 2a3c5b46892a6c930b7947f5bf91d83173826517 Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 00:16:44 +0800 Subject: [PATCH 04/87] =?UTF-8?q?feat:=20=E6=94=B9=E8=BF=9B=E5=8F=8C?= =?UTF-8?q?=E5=87=BB=E8=A1=8C=E4=B8=BA=EF=BC=8C=E7=BC=96=E8=BE=91=E5=BD=A2?= =?UTF-8?q?=E7=8A=B6=E7=82=B9=E6=97=B6=E6=9B=B4=E7=AC=A6=E5=90=88=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E4=B9=A0=E6=83=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trae/rules/git-commit-message.md | 6 ++ .../engine/src/selection_behaviour_tests.rs | 24 +++++ crates/engine/src/tools.rs | 19 +++- crates/ui-egui/src/canvas.rs | 13 ++- .../ui-egui/src/canvas_double_click_tests.rs | 96 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panel_width_tests.rs | 5 +- 7 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 .trae/rules/git-commit-message.md diff --git a/.trae/rules/git-commit-message.md b/.trae/rules/git-commit-message.md new file mode 100644 index 00000000..99f24731 --- /dev/null +++ b/.trae/rules/git-commit-message.md @@ -0,0 +1,6 @@ +--- +alwaysApply: true +scene: git_message +--- + +在此处编写规则,自定义 AI 生成提交信息的风格。 diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index bc961a4a..a54daf23 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -175,6 +175,30 @@ fn double_click_enters_one_level() { assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(k.r), "double-click on a shape edits its points"); } +#[test] +fn a_double_click_anywhere_on_the_selected_shape_edits_its_points() { + let mut s = Session::new(); + let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}})); + ex(&mut s, "stroke.add", json!({"ids": [r]})); + // Selected by its outline; the empty middle is no hit at all (the shape has no fill). + click(&mut s, 0.0, 50.0, NONE); + assert_eq!(s.selection(), vec![r]); + s.pointer(&ev(PointerKind::DoubleClick, 50.0, 50.0, NONE)).unwrap(); + assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(r), "a double-click on the shape edits its points"); +} + +#[test] +fn a_double_click_on_an_unselected_shape_edits_it_without_a_selecting_click() { + let mut s = Session::new(); + let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + ex(&mut s, "select.none", json!({})); + // The press and release of the click that selects it, and the double-click, came in one frame: + // only the double-click reached the tool, with nothing selected. + s.pointer(&ev(PointerKind::DoubleClick, 50.0, 50.0, NONE)).unwrap(); + assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(r)); +} + #[test] fn marquee_selects_what_it_touches() { let mut s = Session::new(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f0115e1f..187e28d4 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1892,7 +1892,17 @@ impl Session { self.tool.overlay.marquee = None; self.cancel_interaction()?; } - let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; + let hit_here = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); + // A double-click anywhere on the one selected shape edits its points, like Enter: also + // inside a hollow (unfilled) shape, where the hit test finds nothing under the pointer. + if let [id] = sel[..] + && doc.node(id).is_some_and(|n| n.kind.is_shape()) + && doc.world_bounds(id).is_some_and(|b| b.contains(ev.p())) + && hit_here.is_none_or(|h| h == id || doc.is_ancestor(id, h)) + { + return crate::vectoredit::begin_many(self, &[id]); + } + let Some(leaf) = hit_here else { return Ok(()) }; let target = select_target(&doc, leaf, &sel, true); // Text: edit at the click. if doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) @@ -1910,8 +1920,11 @@ impl Session { return Ok(()); } // Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active). - if doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) { - let mut ids = crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default(); + // A shape clicked on its own edits even when the click that selects it and the double-click + // arrived in one frame (a press and a release the canvas took together), so no selection was + // made yet; another layer's shapes are reached one level at a time, as below. + if doc.node(target).is_some_and(|n| n.kind.is_shape()) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) { + let mut ids = if sel.contains(&target) { crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default() } else { vec![target] }; ids.retain(|i| *i != target); ids.insert(0, target); return crate::vectoredit::begin_many(self, &ids); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 46451ae0..8126e6f3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -604,7 +604,11 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { open_context_menu(app, pos, mods.command); return true; } - if resp.double_clicked() { + // A third quick click is counted as a triple, not a double (a click elsewhere in between still + // leaves its time in the sequence): it means the same here — edit, enter, open — unless a + // vector or text is being edited, where a triple already has its own meaning. + let editing = app.session.ds().is_ok_and(|d| d.vector_edit.is_some() || d.text_edit.is_some()); + if resp.double_clicked() || (resp.triple_clicked() && !editing) { let ev = pointer_event(app, PointerKind::DoubleClick, pos, &mods); if let Err(e) = app.session.pointer(&ev) { app.toast(e.to_string()); @@ -654,6 +658,13 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { } if let Some(k) = kind { send(app, k, pos, &mods); + // The pointer also let go within this frame (a quick click, or a slow frame): the tools get + // the release too, so the press's gesture ends — a quick click with a shape tool still + // draws and returns to Move, and a double-click right after drawing finds the Move tool. + if matches!(k, PointerKind::Down) && primary_released { + app.canvas.dragging = false; + send(app, PointerKind::Up, pos, &mods); + } } true } diff --git a/crates/ui-egui/src/canvas_double_click_tests.rs b/crates/ui-egui/src/canvas_double_click_tests.rs index 1c89757f..c47faa74 100644 --- a/crates/ui-egui/src/canvas_double_click_tests.rs +++ b/crates/ui-egui/src/canvas_double_click_tests.rs @@ -2,11 +2,107 @@ //! began is dropped, so the tool is idle once the button is up. use egui::{Event, Modifiers, PointerButton, Pos2}; +use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; use crate::layers_drag_tests::Ui; +/// One press and release at `p`. +fn click(u: &mut Ui, p: Pos2) { + u.frame(vec![Event::PointerMoved(p)]); + for pressed in [true, false] { + u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } +} + +/// A double-click at `p`. +fn double_click_at(u: &mut Ui, p: Pos2) { + u.frame(vec![Event::PointerMoved(p)]); + for _ in 0..2 { + for pressed in [true, false] { + u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } + } + u.frame(vec![]); +} + +#[test] +fn double_clicking_a_selected_shape_enters_vector_edit_like_enter() { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap(); + let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let p = u.app.to_screen(250.0, 200.0); + // A click selects it; a double-click right after (three quick clicks) still edits its points. + click(&mut u, p); + assert_eq!(u.app.session.selection(), vec![r]); + double_click_at(&mut u, p); + assert!(u.app.session.ds().unwrap().vector_edit.is_some(), "a double-click enters vector edit"); + // Enter does the same. + u.app.session.execute("vector.edit.end", &json!({})).unwrap(); + u.frame(vec![Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(u.app.session.ds().unwrap().vector_edit.is_some()); +} + +#[test] +fn a_slower_double_click_still_edits_the_points() { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); + let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + let p = u.app.to_screen(200.0, 200.0); + u.frame(vec![Event::PointerMoved(p)]); + let press = |u: &mut Ui, pressed| { + u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + }; + // The first click, then 0.4 s: inside the platform's 0.5 s double-click window, past egui's + // default 0.3 s. + press(&mut u, true); + press(&mut u, false); + for _ in 0..24 { + u.frame(vec![]); + } + press(&mut u, true); + press(&mut u, false); + assert!(u.app.session.ds().unwrap().vector_edit.is_some(), "a double-click the platform takes edits its points"); +} + +#[test] +fn double_clicking_an_unselected_shape_edits_its_points() { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap(); + let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let p = u.app.to_screen(250.0, 200.0); + // Nothing is selected: the double-click selects the shape and edits its points, like Figma. + double_click_at(&mut u, p); + let ve = u.app.session.ds().unwrap().vector_edit.clone(); + assert_eq!(ve.map(|v| v.id), Some(r)); +} + +#[test] +fn a_click_that_leaves_vector_edit_does_not_keep_the_next_double_click_out() { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap(); + let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + let shape = u.app.to_screen(250.0, 200.0); + let empty = Pos2::new(u.app.canvas.rect.left() + 30.0, u.app.canvas.rect.bottom() - 30.0); + double_click_at(&mut u, shape); + assert!(u.app.session.ds().unwrap().vector_edit.is_some()); + // One click on empty canvas leaves the edit mode. + click(&mut u, empty); + assert!(u.app.session.ds().unwrap().vector_edit.is_none(), "a click outside leaves vector edit"); + // The next double-click edits the shape again. + double_click_at(&mut u, shape); + assert!(u.app.session.ds().unwrap().vector_edit.is_some(), "a double-click edits it again"); +} + /// Two presses and releases at the bottom left of the canvas, with `modifiers` held. fn double_click(u: &mut Ui, modifiers: Modifiers) { let pos = Pos2::new(u.app.canvas.rect.left() + 30.0, u.app.canvas.rect.bottom() - 30.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6dac06a6..e24bbf24 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1374,6 +1374,9 @@ impl FigApp { // Cmd/Ctrl+= / - / 0 zoom the canvas (view.zoomIn, view.zoomOut, view.zoom100): egui's // own keyboard zoom would scale the whole interface on the same keys. ctx.options_mut(|o| o.zoom_with_keyboard = false); + // A double-click's window is the platform's (0.5 s, Windows' default), not egui's + // stricter 0.3 s: a double-click the OS takes must enter a shape's points too. + ctx.memory_mut(|m| m.options.input_options.max_double_click_delay = 0.5); theme::install_fonts(ctx, false, 0); self.fonts_installed = true; } else if figcraft_text::system_fonts_scanned() { diff --git a/crates/ui-egui/src/panel_width_tests.rs b/crates/ui-egui/src/panel_width_tests.rs index a5a84d04..8c61a3b1 100644 --- a/crates/ui-egui/src/panel_width_tests.rs +++ b/crates/ui-egui/src/panel_width_tests.rs @@ -51,8 +51,9 @@ fn drag(u: &mut Ui, from: Pos2, to: Pos2) { } fn double_click(u: &mut Ui, p: Pos2) { - // Well after any earlier click (a third quick click isn't a double-click). - for _ in 0..40 { + // Well after any earlier click (a third quick click, within twice the platform's 0.5 s + // double-click window, isn't a double-click). + for _ in 0..70 { u.frame(vec![]); } for _ in 0..2 { From 42bdfcce3494fe78a5a21e986a5f0af37f42f3e4 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:37:55 +0900 Subject: [PATCH 05/87] M4: CJK kinsoku completed to JIS X 4051 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JIS X 4051's line-start (行頭禁則) and line-end (行末禁則) sets were mostly there — closing punctuation and brackets, small kana, the prolonged sound mark, iteration marks and opening brackets already never start or end a line — but a few categories fell through: - the halfwidth small kana ァ–ッ (U+FF67..U+FF6F) and the halfwidth voicing marks ゙゚ (U+FF9E/U+FF9F) were not line-start prohibited, so a wrapped line could begin with them; - the spacing (semi-)voiced sound marks ゛゜ (U+309B/U+309C) and the kana iteration marks 〱〲〳〴〵 (U+3031..U+3035) likewise; - the halfwidth parentheses ⦅⦆ were missing from the line-end/line-start sets. Add them (ranges in `class`, the two parentheses in the OPEN and CLOSE strings), and pin every JIS category in the behaviour suite: the existing 22 cases stay and 9 join them (halfwidth small kana and small tsu, halfwidth and spacing voicing marks, kana and vertical kana iteration marks, both halfwidth parentheses), plus a wrap case where a halfwidth small kana moves down with its base (キャ) and a case showing a manual newline may still start a line with a prohibited character (禁則 is for automatic wrapping only). `break_opportunities` fails on the old sets at the first new halfwidth small-kana case. Latin wrapping, ideograph breaks and the rest of the behaviour suite are unchanged, and the headline numbers stay as they are. Verified: cargo test -p figcraft-text --lib 92 passed; cargo clippy -p figcraft-text --all-targets -- -D warnings clean; rustfmt clean on the touched files. Docs: docs/target-app-parity.md text behaviour row and ROADMAP progress log, revision history and header. --- ROADMAP.md | 4 +++- crates/text/src/behaviour_tests.rs | 26 ++++++++++++++++++++++++-- crates/text/src/breaks.rs | 15 ++++++++------- docs/target-app-parity.md | 7 ++++--- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f43cd015..9ac090df 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-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; previous: minor, community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .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 @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged. - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. @@ -316,6 +317,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged | | 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged | | 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index b3cb4884..3312e69e 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -268,7 +268,7 @@ fn hangul_comes_from_a_korean_font_when_one_is_installed() { #[test] fn break_opportunities() { // (before, after, may break between, derivation) - let cases: [(char, char, bool, &str); 22] = [ + let cases: [(char, char, bool, &str); 32] = [ ('a', 'b', false, "inside a Latin word: never"), (' ', 'b', true, "after a space"), ('a', ' ', false, "before a space: the space hangs at the line's end"), @@ -291,6 +291,16 @@ fn break_opportunities() { ('。', '日', true, "after a full stop"), ('日', '「', true, "before an opening bracket"), ('\u{3000}', '日', true, "after an ideographic space"), + ('キ', 'ャ', false, "kinsoku: nor a halfwidth small kana"), + ('本', 'ッ', false, "kinsoku: nor the halfwidth small tsu"), + ('キ', '゙', false, "kinsoku: nor the halfwidth voiced sound mark"), + ('キ', '゚', false, "kinsoku: nor the halfwidth semi-voiced sound mark"), + ('本', '゛', false, "kinsoku: nor the spacing voiced sound mark"), + ('本', '゜', false, "kinsoku: nor the spacing semi-voiced sound mark"), + ('本', '〱', false, "kinsoku: nor a kana iteration mark"), + ('本', '〳', false, "kinsoku: nor a vertical kana repeat mark"), + ('本', '⦆', false, "kinsoku: nor a halfwidth closing parenthesis"), + ('⦅', '日', false, "kinsoku: a halfwidth opening parenthesis never ends a line"), ]; for (a, b, want, why) in cases { assert_eq!(break_between(a, b), want, "{a}{b}: {why}"); @@ -304,13 +314,14 @@ fn cjk_lines_wrap_between_ideographs_with_kinsoku() { } // Box width cut right after char `fit` (chars 0..fit fit, the next one doesn't): the first line // and how the second starts. (text, fit, first line, second line starts with, derivation) - let cases: [(&str, usize, &str, &str, &str); 6] = [ + let cases: [(&str, usize, &str, &str, &str); 7] = [ ("一二三四五六", 3, "一二三", "四", "ideographs break anywhere"), ("一二三。四五", 3, "一二", "三。", "。 can't start a line: 三 moves down with it"), ("一二「三」", 3, "一二", "「三", "「 can't end a line: it moves down"), ("日本語abc def", 5, "日本語", "abc", "no break inside a Latin word: before it"), ("abc 日本語です", 6, "abc 日本", "語", "a space or an ideograph is a break"), ("한국어 텍스트입니다", 5, "한국어 텍", "스", "Korean breaks between syllables (documentation silent)"), + ("一二キャッド", 3, "一二", "キャ", "a halfwidth small kana stays off the line start: キ moves down with it"), ]; for (s, fit, first, second, why) in cases { let t = text(s, TextAutoResize::Height); @@ -324,6 +335,17 @@ fn cjk_lines_wrap_between_ideographs_with_kinsoku() { } } +#[test] +fn a_manual_newline_may_start_a_line_with_a_prohibited_character() { + if face().is_none() { + return; + } + // The author's own line breaks are kept: 禁則 only applies to automatic wrapping. + let t = text("き\nっく", TextAutoResize::Height); + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["き\n", "っく"], "a manual break is not pushed out by kinsoku"); +} + #[test] fn full_width_space_hangs_and_breaks() { if face().is_none() { diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs index 3f5458ea..be6db8df 100644 --- a/crates/text/src/breaks.rs +++ b/crates/text/src/breaks.rs @@ -21,12 +21,13 @@ enum Class { Other, } -/// Opening brackets and quotes (line-end prohibited). -const OPEN: &str = "([{‘“〈《「『【〔〖〘〚〝([{「"; -/// Closing brackets, quotes and punctuation, iteration marks, small kana and the prolonged sound -/// mark (line-start prohibited; the small katakana extensions U+31F0..U+31FF are a range below). -const CLOSE: &str = - ")]},.:;!?’”‥…‼⁇⁈⁉、。々〉》」』】〕〗〙〛〜〞〟〻ぁぃぅぇぉっゃゅょゎゕゖゝゞ゠ァィゥェォッャュョヮヵヶ・ーヽヾ!),.:;?]}。」、・ー"; +/// Opening brackets and quotes (line-end prohibited, JIS X 4051's 行末禁則). +const OPEN: &str = "([{‘“〈《「『【〔〖〘〚〝([{「⦅"; +/// Closing brackets, quotes and punctuation, iteration marks, small kana, the prolonged sound mark +/// and the (semi-)voiced sound marks (line-start prohibited, JIS X 4051's 行頭禁則). Two blocks +/// are ranges in [`class`] instead: the small katakana extensions U+31F0..U+31FF and the halfwidth +/// small kana U+FF67..U+FF6F plus the halfwidth voicing marks U+FF9E/U+FF9F. +const CLOSE: &str = ")]},.:;!?’”‥…‼⁇⁈⁉、。々〉》」』】〕〗〙〛〜〞〟〻ぁぃぅぇぉっゃゅょゎゕゖゝゞ゠ァィゥェォッャュョヮヵヶ・ーヽヾ!),.:;?]}。」、・ー⦆゛゜〱〲〳〴〵"; fn class(c: char) -> Class { if c.is_ascii_alphanumeric() { @@ -35,7 +36,7 @@ fn class(c: char) -> Class { if OPEN.contains(c) { return Class::Open; } - if CLOSE.contains(c) || ('\u{31F0}'..='\u{31FF}').contains(&c) { + if CLOSE.contains(c) || ('\u{31F0}'..='\u{31FF}').contains(&c) || ('\u{FF67}'..='\u{FF6F}').contains(&c) || matches!(c, '\u{FF9E}' | '\u{FF9F}') { return Class::Close; } match c { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a1a5bf12..e08da2b1 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-17 · **Change:** minor (rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; previous: minor, rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .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 @@ -289,8 +289,9 @@ Hours in this narrative are the builders' estimates; the tables above supersede Auto / % / px line heights with the leading split above and below every line, letter spacing in % and px, paragraph spacing and indent (start-aligned text only), resizing and truncation, decorations from the font's metrics; CJK line breaking (between ideographs, kana and Hangul, no - break inside Latin words, kinsoku for closing and opening punctuation, small kana and the - prolonged sound mark); a style's metrics from its own font, not a fallback; carets, Delete and + break inside Latin words, kinsoku to JIS X 4051 for closing and opening punctuation, small kana + (halfwidth too), the prolonged sound mark, the iteration marks, the (semi-)voiced sound marks + and the halfwidth parentheses); a style's metrics from its own font, not a fallback; carets, Delete and Backspace by grapheme cluster; double-click and word moves by script run; IME composition with CJK; vertical trim (cap height to baseline). The tail suite (`crates/text/src/tail_tests.rs`, `crates/engine/src/text_tail_tests.rs`): lists per paragraph, nested up to 8 levels with Tab and From a30f846ee3e08e7b67f020ff048667c88c14d414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 15:13:22 +0200 Subject: [PATCH 06/87] M6: open Sketch files (48+): detection, pages, version cut, wiring .sketch archives (document.json, meta.json, pages/*.json) open through file.open, file.import, drag and drop, the CLI and MCP; files saved by a Sketch older than 48 are refused with a message saying how to convert them. Layers come in the next commits. Co-Authored-By: Claude Opus 5.5 --- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/file.rs | 14 ++- crates/engine/src/lib.rs | 2 + crates/engine/src/sketch_import_tests.rs | 89 +++++++++++++ crates/engine/src/tests.rs | 3 + crates/format/src/lib.rs | 1 + crates/format/src/rest/mod.rs | 11 ++ crates/format/src/sketch/fixture.rs | 121 ++++++++++++++++++ crates/format/src/sketch/mod.rs | 153 +++++++++++++++++++++++ crates/format/src/sketch/tests.rs | 111 ++++++++++++++++ crates/format/src/sketch/tree.rs | 81 ++++++++++++ crates/ui-egui/src/home_tests.rs | 7 ++ crates/ui-egui/src/lib.rs | 8 +- 13 files changed, 597 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/sketch_import_tests.rs create mode 100644 crates/format/src/sketch/fixture.rs create mode 100644 crates/format/src/sketch/mod.rs create mode 100644 crates/format/src/sketch/tests.rs create mode 100644 crates/format/src/sketch/tree.rs diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 545d872c..f55aae78 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -31,5 +31,6 @@ regex = { workspace = true } web-time = "1.1.0" [dev-dependencies] +zip = { workspace = true } wat = { workspace = true } image = { workspace = true } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7380711f..4f8802b7 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -536,6 +536,9 @@ fn load_with( if let Some(r) = load_fig(bytes, &file_stem(name_hint)) { return Ok((r?, true)); } + if let Some(r) = load_sketch(bytes, &file_stem(name_hint)) { + return Ok((r?, true)); + } if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; link_instances(&mut doc); @@ -551,6 +554,15 @@ fn load_with( } } +/// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try. +fn load_sketch(bytes: &[u8], name: &str) -> Option> { + let r = figcraft_format::sketch::try_import(bytes, name)?; + Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + link_instances(&mut doc); + figcraft_format::Loaded { issues: warnings, ..doc.into() } + })) +} + /// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { let r = figcraft_format::fig::try_import(bytes, name)?; @@ -1293,7 +1305,7 @@ fn import(s: &mut Session, p: &Value) -> Result { } Ok(out) } - "figcraft" | "json" | "fig" => { + "figcraft" | "json" | "fig" | "sketch" => { let resolver = path.as_deref().map(rest_images); let images: figcraft_format::rest::ImageResolver = match &resolver { Some(r) => r, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..edb04bf4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1839,6 +1839,8 @@ mod shapes_behaviour_tests; #[cfg(test)] mod shortcut_parity_tests; #[cfg(test)] +mod sketch_import_tests; +#[cfg(test)] mod slot_tests; #[cfg(test)] mod stroke_style_tests; diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs new file mode 100644 index 00000000..cb3bf2f1 --- /dev/null +++ b/crates/engine/src/sketch_import_tests.rs @@ -0,0 +1,89 @@ +//! `.sketch` files through the file commands. The importer's own tests (fixtures built in code) +//! are in `figcraft_format::sketch`. + +use std::io::Write; + +use base64::Engine; +use serde_json::{Value, json}; + +use crate::Session; + +/// A minimal `.sketch` zip with one page per `(id, name)`, saved by `app_version`. +fn sketch(app_version: &str, pages: &[(&str, &str)]) -> Vec { + let refs: Vec = pages.iter().map(|(id, _)| json!({"_class": "MSJSONFileReference", "_ref": format!("pages/{id}")})).collect(); + let mut out = Vec::new(); + { + let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out)); + let o = zip::write::SimpleFileOptions::default(); + z.start_file("document.json", o).unwrap(); + z.write_all(json!({"_class": "document", "pages": refs}).to_string().as_bytes()).unwrap(); + z.start_file("meta.json", o).unwrap(); + z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": app_version, "version": 103}).to_string().as_bytes()).unwrap(); + for (id, name) in pages { + z.start_file(format!("pages/{id}.json"), o).unwrap(); + z.write_all(json!({"_class": "page", "do_objectID": id, "name": name, "layers": []}).to_string().as_bytes()).unwrap(); + } + z.finish().unwrap(); + } + out +} + +fn temp(name: &str, bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("figcraft-sketch-test-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + (dir, path) +} + +fn page_names(s: &Session) -> Vec { + let doc = s.doc().unwrap(); + doc.pages.iter().map(|p| doc.node(*p).unwrap().name.clone()).collect() +} + +#[test] +fn opened_sketch_files_have_their_pages_and_no_path() { + let (dir, path) = temp("design.sketch", &sketch("50.2", &[("A", "Screens"), ("B", "Symbols")])); + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert!(r.get("summary").is_some()); + assert_eq!(page_names(&s), ["Screens", "Symbols"]); + // Never saved over: the document has no path, so Save asks for one. + assert!(s.docs[s.active].path.is_none()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn sketch_import_appends_pages() { + let (dir, path) = temp("more.sketch", &sketch("83.2", &[("A", "Imported")])); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let before = page_names(&s).len(); + s.execute("file.import", &json!({"path": path.to_string_lossy()})).unwrap(); + let names = page_names(&s); + assert_eq!(names.len(), before + 1); + assert_eq!(names.last().unwrap(), "Imported"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn sketch_data_opens_with_a_summary() { + let data = base64::engine::general_purpose::STANDARD.encode(sketch("50.2", &[("A", "Page")])); + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + assert!(r.get("summary").is_some()); + assert_eq!(page_names(&s), ["Page"]); +} + +#[test] +fn old_and_damaged_sketch_files_fail_cleanly() { + let mut s = Session::new(); + let (dir, path) = temp("old.sketch", &sketch("47.1", &[("A", "P")])); + let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("older than 48"), "{e}"); + let mut broken = sketch("50.2", &[("A", "P")]); + broken.truncate(broken.len() / 2); + std::fs::write(&path, &broken).unwrap(); + assert!(s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 646c7c4b..eeec9f04 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -416,6 +416,9 @@ fn every_command_survives_junk_params() { // Online files: ids, servers, refreshes, actions and names as junk. json!({"file": [1], "server": 7, "refresh": "maybe", "action": "share", "name": {"x": 1}}), json!({"file": "../x", "server": "wss://\u{0}", "refresh": true, "action": "unshare", "name": 5}), + // Files as base64 data: a damaged `.sketch` (zip magic, then junk) and an old one (SQLite). + json!({"data": "UEsDBBQAAAAIAP////8=", "name": "x.sketch"}), + json!({"data": "U1FMaXRlIGZvcm1hdCAzAA==", "name": "x.sketch", "path": null}), // Selection color slots: malformed indexes, ranges and paints. json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}), json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}), diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 9f7e51bc..c61af183 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -20,6 +20,7 @@ pub mod fig; pub mod library; pub mod rest; +pub mod sketch; pub mod thumbnail; pub use library::{is_valid_library_id, read_library, read_library_within}; diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 78279e91..faaf7baf 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -185,6 +185,17 @@ pub(crate) fn import_with_networks( import_parts(v, None, images, networks, max_nodes, true) } +/// [`import_with_networks`] for imports other than `.fig` (no `.fig` source identities): the +/// `.sketch` import. +pub(crate) fn import_with_networks_plain( + v: &Value, + images: ImageResolver, + networks: Vec>, + max_nodes: usize, +) -> Result<(Document, Vec), FormatError> { + import_parts(v, None, images, networks, max_nodes, false) +} + fn import_parts( v: &Value, variables: Option<&Value>, diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs new file mode 100644 index 00000000..fd3b37ce --- /dev/null +++ b/crates/format/src/sketch/fixture.rs @@ -0,0 +1,121 @@ +//! Synthetic `.sketch` files for the tests: the JSON of a document, its pages and `meta.json`, +//! zipped the way Sketch writes them. Layers are built with small helpers and adjusted with +//! [`with`]. + +use std::io::Write; + +use serde_json::{Value, json}; + +/// A `.sketch` zip: `document.json`, `meta.json` saying `app_version` / `version`, one +/// `pages/.json` per page and any other `files` (images, fonts). +pub(crate) fn sketch_zip(app_version: &str, version: u64, document: Value, pages: &[Value], files: &[(&str, &[u8])]) -> Vec { + let meta = json!({ + "app": "com.bohemiancoding.sketch3", + "appVersion": app_version, + "version": version, + "compatibilityVersion": 99, + "pagesAndArtboards": {}, + }); + raw_zip(&document, &meta, pages, files) +} + +/// A `.sketch` zip with the given `meta.json` as is. +pub(crate) fn raw_zip(document: &Value, meta: &Value, pages: &[Value], files: &[(&str, &[u8])]) -> Vec { + let mut out = Vec::new(); + { + let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out)); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + z.start_file("document.json", opts).unwrap(); + z.write_all(document.to_string().as_bytes()).unwrap(); + z.start_file("meta.json", opts).unwrap(); + z.write_all(meta.to_string().as_bytes()).unwrap(); + for p in pages { + let id = p["do_objectID"].as_str().unwrap_or("page"); + z.start_file(format!("pages/{id}.json"), opts).unwrap(); + z.write_all(p.to_string().as_bytes()).unwrap(); + } + for (name, bytes) in files { + z.start_file(*name, opts).unwrap(); + z.write_all(bytes).unwrap(); + } + z.finish().unwrap(); + } + out +} + +/// `document.json` listing `pages` (ids) in order, with empty shared-style containers. +pub(crate) fn document(pages: &[&str]) -> Value { + json!({ + "_class": "document", + "do_objectID": "DOC", + "colorSpace": 1, + "currentPageIndex": 0, + "foreignLayerStyles": [], + "foreignSymbols": [], + "foreignTextStyles": [], + "layerStyles": {"_class": "sharedStyleContainer", "objects": []}, + "layerTextStyles": {"_class": "sharedTextStyleContainer", "objects": []}, + "layerSymbols": {"_class": "symbolContainer", "objects": []}, + "pages": pages.iter().map(|p| json!({ + "_class": "MSJSONFileReference", + "_ref_class": "MSImmutablePage", + "_ref": format!("pages/{p}"), + })).collect::>(), + }) +} + +fn frame(x: f64, y: f64, w: f64, h: f64) -> Value { + json!({"_class": "rect", "constrainProportions": false, "x": x, "y": y, "width": w, "height": h}) +} + +fn layer(class: &str, id: &str, name: &str, x: f64, y: f64, w: f64, h: f64) -> Value { + json!({ + "_class": class, + "do_objectID": id, + "name": name, + "frame": frame(x, y, w, h), + "isVisible": true, + "isLocked": false, + "rotation": 0, + "isFlippedHorizontal": false, + "isFlippedVertical": false, + "resizingConstraint": 63, + "style": {"_class": "style", "fills": [], "borders": [], "shadows": [], "innerShadows": []}, + }) +} + +pub(crate) fn page(id: &str, name: &str, layers: Vec) -> Value { + with(layer("page", id, name, 0.0, 0.0, 0.0, 0.0), "layers", Value::Array(layers)) +} + +#[allow(dead_code)] // used by the layer tree tests +pub(crate) fn artboard(id: &str, name: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + with(layer("artboard", id, name, x, y, w, h), "layers", Value::Array(layers)) +} + +pub(crate) fn group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + with(layer("group", id, id, x, y, w, h), "layers", Value::Array(layers)) +} + +/// A rectangle with its four generated corner points. +pub(crate) fn rect(id: &str, x: f64, y: f64, w: f64, h: f64) -> Value { + let pts = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].map(|(px, py)| point(px, py)); + let mut l = with(layer("rectangle", id, id, x, y, w, h), "points", Value::Array(pts.to_vec())); + l["isClosed"] = json!(true); + l["fixedRadius"] = json!(0); + l +} + +/// A straight curve point at `(x, y)` in 0–1 of the layer's frame. +pub(crate) fn point(x: f64, y: f64) -> Value { + let p = format!("{{{x}, {y}}}"); + json!({"_class": "curvePoint", "cornerRadius": 0, "curveFrom": p, "curveTo": p, "curveMode": 1, "hasCurveFrom": false, "hasCurveTo": false, "point": p}) +} + +/// `l` with `key` set to `value`. +pub(crate) fn with(mut l: Value, key: &str, value: Value) -> Value { + if let Some(m) = l.as_object_mut() { + m.insert(key.to_string(), value); + } + l +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs new file mode 100644 index 00000000..7334606b --- /dev/null +++ b/crates/format/src/sketch/mod.rs @@ -0,0 +1,153 @@ +//! Import of `.sketch` files saved by Sketch 48 or newer (best effort), written from the public +//! description of the format only. +//! +//! A `.sketch` file is a zip archive holding `document.json` (the document: its pages in order, +//! shared styles, swatches and library symbols copied into it), `meta.json` (the app version and +//! format version that saved it), one `pages/.json` per page (the layer tree), `images/` and, +//! in newer files, `fonts/`. Every object carries its kind in `_class`. [`tree`] maps the layers +//! onto the REST API file JSON shape and the REST importer builds the document, so this import +//! shares its mapping, budgets and import report with `.fig` and file JSON imports. Files older +//! than Sketch 48 (a SQLite database before 43, text as binary property lists until 47) are +//! refused with a message saying how to convert them. Anything not understood is skipped with a +//! warning; malformed input never panics. + +#[cfg(test)] +mod fixture; +#[cfg(test)] +mod tests; +mod tree; + +use std::collections::BTreeSet; + +use figcraft_doc::Document; +use serde_json::Value; + +use crate::FormatError; + +/// The oldest Sketch whose files open: from 48 on, text is plain JSON too. +pub const MIN_SKETCH: u32 = 48; +/// The newest `meta.json` format version checked against real files (Sketch 83); newer files +/// open with a warning. +pub const NEWEST_TESTED_VERSION: u64 = 139; +/// What opening a file saved by an older Sketch says. +pub const TOO_OLD: &str = "saved by a Sketch older than 48; open it in a newer Sketch and save it again"; +/// Largest JSON member read (a real page of a few thousand layers takes 33 MB). +const MAX_JSON_MEMBER: u64 = 512 * 1024 * 1024; +/// Largest image member read. +const MAX_IMAGE: u64 = 256 * 1024 * 1024; +/// Most pages read from one file. +const MAX_PAGES: usize = 1000; + +/// Whether `bytes` look like a `.sketch` file: a zip holding `document.json` and `meta.json`, or +/// the SQLite database Sketch wrote before version 43 (so that it gets the "too old" message). +pub fn is_sketch(bytes: &[u8]) -> bool { + bytes.starts_with(b"SQLite format 3\0") + || (bytes.starts_with(b"PK") + && zip::ZipArchive::new(std::io::Cursor::new(bytes)).is_ok_and(|z| { + let names: BTreeSet<&str> = z.file_names().collect(); + names.contains("document.json") && names.contains("meta.json") + })) +} + +/// Import `bytes` when they are a `.sketch` file; `None` when they are something else. +pub fn try_import(bytes: &[u8], name: &str) -> Option), FormatError>> { + is_sketch(bytes).then(|| import(bytes, name)) +} + +/// Build a document from a `.sketch` file. Returns it with the warnings about what was skipped or +/// approximated. `name`: the document name. +pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), FormatError> { + import_within(bytes, name, MAX_JSON_MEMBER) +} + +/// [`import`] reading pages of at most `max_member` bytes of JSON each. +pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result<(Document, Vec), FormatError> { + if bytes.starts_with(b"SQLite format 3\0") { + return Err(FormatError::Io(TOO_OLD.into())); + } + let archive = Archive::open(bytes, max_member)?; + let max_nodes = crate::fig::layer_budget(bytes.len()); + let map = tree::to_rest(&archive, name); + if map.too_old { + return Err(FormatError::Io(TOO_OLD.into())); + } + let mut warnings = archive.warnings.clone(); + warnings.extend(map.warnings); + let images = |r: &str| archive.image(r); + let (doc, more) = crate::rest::import_with_networks_plain(&map.json, &images, map.networks, max_nodes)?; + warnings.extend(more); + Ok((doc, warnings)) +} + +fn corrupt(msg: impl Into) -> FormatError { + FormatError::Corrupt(format!("Sketch file: {}", msg.into())) +} + +/// The parts of a `.sketch` archive: the pages in document order and the images. +pub(crate) struct Archive<'b> { + /// `(id, page JSON)` in the document's order. + pub pages: Vec<(String, Value)>, + /// Problems met while reading (pages skipped). + pub warnings: Vec, + zip: zip::ZipArchive>, +} + +impl<'b> Archive<'b> { + fn open(bytes: &'b [u8], max_member: u64) -> Result { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| corrupt(e.to_string()))?; + let json = |z: &mut zip::ZipArchive>, member: &str, limit: u64| -> Result { + let data = crate::read_member(z, member, limit)?; + serde_json::from_slice(&data).map_err(|e| corrupt(format!("{member}: {e}"))) + }; + let meta = json(&mut z, "meta.json", MAX_JSON_MEMBER)?; + if !meta.is_object() { + return Err(corrupt("meta.json is not an object")); + } + if app_major(&meta).is_some_and(|v| v < MIN_SKETCH) { + return Err(FormatError::Io(TOO_OLD.into())); + } + let document = json(&mut z, "document.json", MAX_JSON_MEMBER)?; + let mut warnings = Vec::new(); + if meta.get("version").and_then(Value::as_u64).is_some_and(|v| v > NEWEST_TESTED_VERSION) { + warnings.push("the file was saved by a newer Sketch than the import was checked with; some layers may differ".to_string()); + } + let refs = document.get("pages").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let mut pages = Vec::new(); + for r in refs.iter().take(MAX_PAGES) { + let Some(id) = r.get("_ref").and_then(Value::as_str).and_then(|s| s.strip_prefix("pages/")).filter(|id| valid_id(id)) else { + warnings.push("pages the document lists without a valid reference were skipped".into()); + continue; + }; + match json(&mut z, &format!("pages/{id}.json"), max_member) { + Ok(page) if page.is_object() => pages.push((id.to_string(), page)), + Ok(_) => warnings.push("pages that aren't JSON objects were skipped".into()), + Err(e) => warnings.push(format!("a page was skipped: {}", e.to_string().trim_start_matches("not a valid FigCraft file: "))), + } + } + if refs.len() > MAX_PAGES { + warnings.push(format!("the file has more than {MAX_PAGES} pages; the rest were skipped")); + } + Ok(Archive { pages, warnings, zip: z }) + } + + /// The bytes of the image a layer references (`images/`). + pub(crate) fn image(&self, r: &str) -> Option> { + let name = r.strip_prefix("images/").unwrap_or(r); + if !valid_id(name.split('.').next().unwrap_or("")) || name.split('.').count() > 2 { + return None; + } + // A clone shares the parsed directory and the bytes; the resolver only has shared access. + let mut z = self.zip.clone(); + crate::read_member(&mut z, &format!("images/{name}"), MAX_IMAGE).ok() + } +} + +/// The major version of the Sketch that saved the file (`meta.json` `appVersion`, "50.2" → 50). +fn app_major(meta: &Value) -> Option { + meta.get("appVersion").and_then(Value::as_str)?.split('.').next()?.trim().parse().ok() +} + +/// Ids name archive members: letters, digits, `-` and `_` only, never a path. +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 128 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} diff --git a/crates/format/src/sketch/tests.rs b/crates/format/src/sketch/tests.rs new file mode 100644 index 00000000..4fb24400 --- /dev/null +++ b/crates/format/src/sketch/tests.rs @@ -0,0 +1,111 @@ +//! `.sketch` import: detection, the archive, the version cut and pages, on fixtures built in code. + +use serde_json::json; + +use super::fixture::*; +use super::*; + +fn page_names(doc: &Document) -> Vec { + doc.pages.iter().map(|p| doc.node(*p).unwrap().name.clone()).collect() +} + +#[test] +fn detects_sketch_and_not_others() { + let z = sketch_zip("50.2", 103, document(&["P1"]), &[page("P1", "Page 1", vec![])], &[]); + assert!(is_sketch(&z)); + assert!(!is_sketch(b"fig-kiwi....")); + assert!(!is_sketch(b"{}")); + assert!(!is_sketch(b"")); + // A zip that isn't a Sketch file. + let mut other = Vec::new(); + { + use std::io::Write; + let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut other)); + w.start_file("canvas.fig", zip::write::SimpleFileOptions::default()).unwrap(); + w.write_all(b"x").unwrap(); + w.finish().unwrap(); + } + assert!(!is_sketch(&other)); +} + +#[test] +fn pages_become_pages_in_document_order() { + let z = sketch_zip("50.2", 103, document(&["B", "A"]), &[page("A", "Alpha", vec![]), page("B", "Beta", vec![])], &[]); + let (doc, _) = import(&z, "f").unwrap(); + assert_eq!(page_names(&doc), ["Beta", "Alpha"]); +} + +#[test] +fn older_than_48_is_refused() { + let z = sketch_zip("47.1", 95, document(&["P"]), &[page("P", "P", vec![])], &[]); + let e = import(&z, "f").unwrap_err().to_string(); + assert!(e.contains(TOO_OLD), "{e}"); + let mut sqlite = b"SQLite format 3\0".to_vec(); + sqlite.resize(512, 0); + assert!(is_sketch(&sqlite)); + assert!(import(&sqlite, "f").unwrap_err().to_string().contains(TOO_OLD)); +} + +#[test] +fn archived_text_without_meta_version_is_refused() { + let text = json!({ + "_class": "text", "do_objectID": "T", "name": "T", + "frame": {"_class": "rect", "x": 0, "y": 0, "width": 10, "height": 10}, + "attributedString": {"_class": "MSAttributedString", "archivedAttributedString": {"_archive": "YnBsaXN0MDA="}}, + }); + let z = raw_zip(&document(&["P"]), &json!({"app": "com.bohemiancoding.sketch3"}), &[page("P", "P", vec![text])], &[]); + let e = import(&z, "f").unwrap_err().to_string(); + assert!(e.contains(TOO_OLD), "{e}"); +} + +#[test] +fn newer_than_tested_imports_with_one_warning() { + let z = sketch_zip("2099.1", NEWEST_TESTED_VERSION + 1, document(&["P"]), &[page("P", "P", vec![])], &[]); + let (_, warnings) = import(&z, "f").unwrap(); + assert_eq!(warnings.iter().filter(|w| w.contains("newer Sketch")).count(), 1, "{warnings:?}"); +} + +#[test] +fn junk_never_panics() { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "P", vec![rect("R", 0.0, 0.0, 10.0, 10.0)])], &[]); + for cut in (0..z.len()).step_by(7) { + let _ = import(&z[..cut], "f"); + } + let mut seed = 0x2545_f491_u32; + for _ in 0..200 { + let mut b = z.clone(); + for _ in 0..8 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = seed as usize % b.len(); + b[i] = (seed >> 8) as u8; + } + let _ = import(&b, "f"); + } + for doc in [json!([]), json!(null), json!({"pages": 5}), json!({"pages": [{"_ref": 7}, {"_ref": "pages/../../x"}]})] { + let _ = import(&sketch_zip("50.2", 103, doc, &[json!(5), json!({"do_objectID": "Q", "layers": "x"})], &[]), "f"); + } + let _ = import(&raw_zip(&document(&["P"]), &json!("x"), &[], &[]), "f"); +} + +#[test] +fn deep_nesting_does_not_fail_the_file() { + let mut deep = rect("R", 0.0, 0.0, 1.0, 1.0); + for i in 0..300 { + deep = group(&format!("G{i}"), 0.0, 0.0, 1.0, 1.0, vec![deep]); + } + let z = sketch_zip("50.2", 103, document(&["A", "B"]), &[page("A", "Deep", vec![deep]), page("B", "Normal", vec![])], &[]); + let (doc, warnings) = import(&z, "f").unwrap(); + assert!(page_names(&doc).contains(&"Normal".to_string())); + assert!(!warnings.is_empty()); +} + +#[test] +fn members_over_budget_are_skipped() { + let z = sketch_zip("50.2", 103, document(&["A", "B"]), &[page("A", "Big", vec![]), page("B", "Small", vec![])], &[]); + let (doc, warnings) = import_within(&z, "f", 200).unwrap(); + // Each page JSON is larger than 200 bytes: both are skipped, with a warning; nothing panics. + assert!(warnings.iter().any(|w| w.contains("too large")), "{warnings:?}"); + assert!(!page_names(&doc).contains(&"Big".to_string())); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs new file mode 100644 index 00000000..8b5fc0c8 --- /dev/null +++ b/crates/format/src/sketch/tree.rs @@ -0,0 +1,81 @@ +//! Sketch pages and layers → REST API file JSON nodes. + +use figcraft_doc::VectorNetwork; +use serde_json::{Map as JsonMap, Value, json}; + +use super::Archive; + +/// Deepest layer nesting followed (deeper layers are skipped with a warning). +const MAX_DEPTH: usize = 200; + +/// The REST JSON of a Sketch file, with what the mapping noticed. +pub(crate) struct Map { + pub json: Value, + pub warnings: Vec, + /// Vector networks the JSON references by `vectorNetworkRef`. + pub networks: Vec>, + /// A text layer stored the pre-48 way was met: the file is refused. + pub too_old: bool, +} + +/// The mapping's state while walking the file. +pub(crate) struct Cx { + pub warnings: Vec, + pub networks: Vec>, + pub too_old: bool, +} + +impl Cx { + pub(crate) fn warn(&mut self, msg: impl Into) { + let msg = msg.into(); + if !self.warnings.contains(&msg) { + self.warnings.push(msg); + } + } +} + +/// Map the pages of `archive` to a REST file JSON named `name`. +pub(crate) fn to_rest(archive: &Archive, name: &str) -> Map { + let mut cx = Cx { warnings: Vec::new(), networks: Vec::new(), too_old: false }; + let mut canvases = Vec::new(); + for (id, page) in &archive.pages { + let mut node = JsonMap::new(); + node.insert("type".into(), json!("CANVAS")); + node.insert("id".into(), json!(id)); + node.insert("name".into(), json!(str_field(page, "name").unwrap_or("Page"))); + let kids: Vec = layers(page).iter().filter_map(|l| layer(&mut cx, l, 0)).collect(); + node.insert("children".into(), Value::Array(kids)); + canvases.push(Value::Object(node)); + } + let json = json!({"name": name, "document": {"type": "DOCUMENT", "id": "0:0", "children": canvases}}); + Map { json, warnings: cx.warnings, networks: cx.networks, too_old: cx.too_old } +} + +/// One Sketch layer as a REST node (with its children), or `None` when it is skipped. +pub(crate) fn layer(cx: &mut Cx, l: &Value, depth: usize) -> Option { + if depth > MAX_DEPTH { + cx.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); + return None; + } + if is_archived_text(l) { + cx.too_old = true; + return None; + } + let class = str_field(l, "_class")?; + cx.warn(format!("Sketch {class} layers are not supported yet and were skipped")); + None +} + +/// A text layer whose string is a binary property list (Sketch 43–47). +fn is_archived_text(l: &Value) -> bool { + l.get("attributedString").is_some_and(|a| a.get("archivedAttributedString").is_some()) +} + +/// The child layers of a page or layer. +pub(crate) fn layers(l: &Value) -> &[Value] { + l.get("layers").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() +} + +pub(crate) fn str_field<'v>(v: &'v Value, key: &str) -> Option<&'v str> { + v.get(key).and_then(Value::as_str) +} diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index ebbf6d95..fc51bfb1 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -900,3 +900,10 @@ fn dropping_an_unsupported_file_on_the_home_screen_opens_nothing() { assert!(u.app.ui.home); assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Drop a .figcraft, .fig, .json, .svg or image file.")); } + +#[test] +fn sketch_files_open_and_drop_as_documents() { + let p = std::path::Path::new("/tmp/Design.SKETCH"); + assert!(crate::opens(p)); + assert!(crate::is_document(p)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..75096ffb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1201,7 +1201,7 @@ impl FigApp { let exts: &[&str] = if place { &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"] } else { - &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"] + &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig", "sketch"] }; let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; self.pick_file(title, exts, true, |app, file| { @@ -1823,17 +1823,17 @@ impl FigApp { } /// The file types File ▸ Open and a drop on the home screen open. -pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; +pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "sketch", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; /// Whether `path` names a file type that opens as a document (the extension, in any case). pub(crate) fn opens(path: &Path) -> bool { path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) } -/// Whether `path` is a whole document (`.figcraft`, `.fig`, file JSON) rather than an image or SVG: +/// Whether `path` is a whole document (`.figcraft`, `.fig`, `.sketch`, file JSON) rather than an image or SVG: /// dropped on the canvas it opens as a file of its own instead of adding its pages to the open one. pub(crate) fn is_document(path: &Path) -> bool { - path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["figcraft", "fig", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) + path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["figcraft", "fig", "sketch", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) } /// Reads the contents of dropped file `name` that has no file on disk to read by path. From 5805cea9b4527201dd1cf5d27833337ed0bbf72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 15:55:59 +0200 Subject: [PATCH 07/87] M6: Sketch import: layer tree, transforms, constraints, basic shapes Artboards (and Sketch 2025 frames) become frames with their background (white by default, as Sketch shows them), groups and graphics groups, rectangles with their corner radii, ovals ellipses, bitmaps rectangles with the image, slices slices. Positions accumulate through groups, rotation and flips keep their direction, resizingConstraint bits become constraints. Not yet mapped layer kinds are listed in the import report. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/fixture.rs | 1 - crates/format/src/sketch/mod.rs | 4 +- crates/format/src/sketch/tree.rs | 149 +++++++++++++++++++- crates/format/src/sketch/tree_tests.rs | 181 +++++++++++++++++++++++++ 4 files changed, 326 insertions(+), 9 deletions(-) create mode 100644 crates/format/src/sketch/tree_tests.rs diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index fd3b37ce..231225c9 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -88,7 +88,6 @@ pub(crate) fn page(id: &str, name: &str, layers: Vec) -> Value { with(layer("page", id, name, 0.0, 0.0, 0.0, 0.0), "layers", Value::Array(layers)) } -#[allow(dead_code)] // used by the layer tree tests pub(crate) fn artboard(id: &str, name: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { with(layer("artboard", id, name, x, y, w, h), "layers", Value::Array(layers)) } diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 7334606b..a46706db 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -16,6 +16,8 @@ mod fixture; #[cfg(test)] mod tests; mod tree; +#[cfg(test)] +mod tree_tests; use std::collections::BTreeSet; @@ -67,7 +69,7 @@ pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result } let archive = Archive::open(bytes, max_member)?; let max_nodes = crate::fig::layer_budget(bytes.len()); - let map = tree::to_rest(&archive, name); + let map = tree::to_rest(&archive, name, max_nodes); if map.too_old { return Err(FormatError::Io(TOO_OLD.into())); } diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 8b5fc0c8..59e3d637 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -1,12 +1,16 @@ //! Sketch pages and layers → REST API file JSON nodes. use figcraft_doc::VectorNetwork; +use figcraft_doc::geom::{Affine, Rect}; use serde_json::{Map as JsonMap, Value, json}; use super::Archive; /// Deepest layer nesting followed (deeper layers are skipped with a warning). const MAX_DEPTH: usize = 200; +/// Largest size and farthest position kept (the REST importer's own bounds). +const MAX_SIZE: f64 = 1e6; +const MAX_COORD: f64 = 1e7; /// The REST JSON of a Sketch file, with what the mapping noticed. pub(crate) struct Map { @@ -23,6 +27,9 @@ pub(crate) struct Cx { pub warnings: Vec, pub networks: Vec>, pub too_old: bool, + /// Layers mapped so far, and the most the file may give. + pub nodes: usize, + pub max_nodes: usize, } impl Cx { @@ -35,15 +42,15 @@ impl Cx { } /// Map the pages of `archive` to a REST file JSON named `name`. -pub(crate) fn to_rest(archive: &Archive, name: &str) -> Map { - let mut cx = Cx { warnings: Vec::new(), networks: Vec::new(), too_old: false }; +pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { + let mut cx = Cx { warnings: Vec::new(), networks: Vec::new(), too_old: false, nodes: 0, max_nodes }; let mut canvases = Vec::new(); for (id, page) in &archive.pages { let mut node = JsonMap::new(); node.insert("type".into(), json!("CANVAS")); node.insert("id".into(), json!(id)); node.insert("name".into(), json!(str_field(page, "name").unwrap_or("Page"))); - let kids: Vec = layers(page).iter().filter_map(|l| layer(&mut cx, l, 0)).collect(); + let kids: Vec = layers(page).iter().filter_map(|l| layer(&mut cx, l, Affine::IDENTITY, 0, true)).collect(); node.insert("children".into(), Value::Array(kids)); canvases.push(Value::Object(node)); } @@ -51,8 +58,9 @@ pub(crate) fn to_rest(archive: &Archive, name: &str) -> Map { Map { json, warnings: cx.warnings, networks: cx.networks, too_old: cx.too_old } } -/// One Sketch layer as a REST node (with its children), or `None` when it is skipped. -pub(crate) fn layer(cx: &mut Cx, l: &Value, depth: usize) -> Option { +/// One Sketch layer as a REST node (with its children), or `None` when it is skipped. `parent`: +/// the parent layer's transform to the page; `root`: the layer sits on the page itself. +pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { if depth > MAX_DEPTH { cx.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; @@ -62,8 +70,135 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, depth: usize) -> Option { return None; } let class = str_field(l, "_class")?; - cx.warn(format!("Sketch {class} layers are not supported yet and were skipped")); - None + let ty = match class { + "artboard" | "frame" => "FRAME", + "group" | "graphic" => "GROUP", + "rectangle" | "bitmap" => "RECTANGLE", + "oval" => "ELLIPSE", + "slice" => "SLICE", + other => { + cx.warn(format!("Sketch {} layers are not supported yet and were skipped", clip(other))); + return None; + } + }; + if cx.nodes >= cx.max_nodes { + cx.warn(format!("the file has more than {} layers; the rest were skipped", cx.max_nodes)); + return None; + } + cx.nodes += 1; + let (w, h) = size(l); + let world = parent * local(l, w, h); + let mut node = JsonMap::new(); + node.insert("type".into(), json!(ty)); + if let Some(id) = str_field(l, "do_objectID") { + node.insert("id".into(), json!(id)); + } + node.insert("name".into(), json!(str_field(l, "name").unwrap_or(class))); + node.insert("visible".into(), json!(bool_field(l, "isVisible").unwrap_or(true))); + node.insert("locked".into(), json!(bool_field(l, "isLocked").unwrap_or(false))); + node.insert("size".into(), json!({"x": w, "y": h})); + let rel = parent.inverse() * world; + let [a, b, c, d, e, f] = rel.as_coeffs(); + node.insert("relativeTransform".into(), json!([[a, c, e], [b, d, f]])); + // The page places top-level layers by their transform; deeper ones also say where they end up, + // which tells the importer their transform is relative to their group. + if !root { + let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); + node.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()})); + } + node.insert("constraints".into(), constraints(l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63))); + match class { + "artboard" | "frame" => { + let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) }; + node.insert("clipsContent".into(), json!(clips)); + let background = (bool_field(l, "hasBackgroundColor") == Some(true)).then(|| l.get("backgroundColor").and_then(color)).flatten(); + if class == "artboard" || background.is_some() { + let c = background.unwrap_or_else(|| json!({"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0})); + node.insert("fills".into(), json!([{"type": "SOLID", "color": c}])); + } + } + "rectangle" => corner_radii(l, &mut node), + "bitmap" => match l.get("image").and_then(|i| str_field(i, "_ref")) { + Some(r) => { + let paint = json!({"type": "IMAGE", "scaleMode": "STRETCH", "imageRef": r, "imageTransform": [[1, 0, 0], [0, 1, 0]]}); + node.insert("fills".into(), json!([paint])); + } + None => cx.warn("images stored inside the JSON were skipped"), + }, + _ => {} + } + if matches!(ty, "FRAME" | "GROUP") { + let kids: Vec = layers(l).iter().filter_map(|c| layer(cx, c, world, depth + 1, false)).collect(); + node.insert("children".into(), Value::Array(kids)); + } + Some(Value::Object(node)) +} + +/// A layer's size from its `frame` (finite, not negative, at most [`MAX_SIZE`]). +fn size(l: &Value) -> (f64, f64) { + let f = l.get("frame"); + let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_SIZE); + (n("width"), n("height")) +} + +/// A layer's transform within its parent: its frame's position, then its rotation (degrees, +/// counter-clockwise on screen) and flips about its centre. +fn local(l: &Value, w: f64, h: f64) -> Affine { + let f = l.get("frame"); + let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-MAX_COORD, MAX_COORD); + let rot = l.get("rotation").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) % 360.0; + let sx = if bool_field(l, "isFlippedHorizontal") == Some(true) { -1.0 } else { 1.0 }; + let sy = if bool_field(l, "isFlippedVertical") == Some(true) { -1.0 } else { 1.0 }; + Affine::translate((n("x") + w / 2.0, n("y") + h / 2.0)) + * Affine::rotate(-rot.to_radians()) + * Affine::scale_non_uniform(sx, sy) + * Affine::translate((-w / 2.0, -h / 2.0)) +} + +/// REST constraints from Sketch's `resizingConstraint`: six bits, a cleared bit pins that side or +/// fixes that size (right 1, width 2, left 4, bottom 8, height 16, top 32). A layer pinned on +/// neither side keeps its place in proportion (scale) unless its size is fixed (centre). +fn constraints(mask: i64) -> Value { + let pinned = |bit: i64| mask & bit == 0; + let axis = |low: i64, size: i64, high: i64, low_name: &str, high_name: &str, both: &str| match (pinned(low), pinned(high)) { + (true, true) => both.to_string(), + (true, false) => low_name.to_string(), + (false, true) => high_name.to_string(), + (false, false) if pinned(size) => "CENTER".into(), + (false, false) => "SCALE".into(), + }; + json!({ + "horizontal": axis(4, 2, 1, "LEFT", "RIGHT", "LEFT_RIGHT"), + "vertical": axis(32, 16, 8, "TOP", "BOTTOM", "TOP_BOTTOM"), + }) +} + +/// A rectangle's corner radii: per corner from its points (top left, top right, bottom right, +/// bottom left), else its `fixedRadius`. +fn corner_radii(l: &Value, node: &mut JsonMap) { + let radius = |v: Option<&Value>| v.and_then(Value::as_f64).filter(|r| r.is_finite()).map_or(0.0, |r| r.clamp(0.0, MAX_SIZE)); + let pts = l.get("points").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let each: Vec = pts.iter().map(|p| radius(p.get("cornerRadius"))).collect(); + if each.len() == 4 && each.iter().any(|r| *r > 0.0) { + node.insert("rectangleCornerRadii".into(), json!(each)); + } else if radius(l.get("fixedRadius")) > 0.0 { + node.insert("cornerRadius".into(), json!(radius(l.get("fixedRadius")))); + } +} + +/// A Sketch colour (`red`, `green`, `blue`, `alpha` in 0–1) as a REST colour. +pub(crate) fn color(c: &Value) -> Option { + let n = |k: &str| c.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)); + Some(json!({"r": n("red")?, "g": n("green")?, "b": n("blue")?, "a": n("alpha").unwrap_or(1.0)})) +} + +pub(crate) fn bool_field(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_bool) +} + +/// At most 64 characters of a name read from the file, for warnings. +fn clip(s: &str) -> String { + s.chars().take(64).collect() } /// A text layer whose string is a binary property list (Sketch 43–47). diff --git a/crates/format/src/sketch/tree_tests.rs b/crates/format/src/sketch/tree_tests.rs new file mode 100644 index 00000000..c9f99558 --- /dev/null +++ b/crates/format/src/sketch/tree_tests.rs @@ -0,0 +1,181 @@ +//! Sketch layers → document layers: kinds, positions, transforms, constraints, visibility. + +use figcraft_doc::color::PaintKind; +use figcraft_doc::{Document, HConstraint, NodeKind, VConstraint}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +/// Import one page holding `layers`, with extra archive `files`. +fn open_with(layers: Vec, files: &[(&str, &[u8])]) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", layers)], files); + import(&z, "f").unwrap() +} + +fn open(layers: Vec) -> (Document, Vec) { + open_with(layers, &[]) +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d figcraft_doc::Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}")) +} + +fn solid(doc: &Document, name: &str) -> Option<[f32; 4]> { + node(doc, name).fills.iter().find_map(|p| match &p.kind { + PaintKind::Solid { color } => Some([color.r, color.g, color.b, p.opacity]), + _ => None, + }) +} + +#[test] +fn artboard_is_frame_with_white_fill_unless_background() { + let plain = artboard("A", "Plain", 0.0, 0.0, 100.0, 50.0, vec![]); + let red = with(artboard("B", "Red", 200.0, 0.0, 100.0, 50.0, vec![]), "hasBackgroundColor", json!(true)); + let red = with(red, "backgroundColor", json!({"_class": "color", "red": 1, "green": 0, "blue": 0, "alpha": 1})); + let (doc, _) = open(vec![plain, red]); + assert!(matches!(node(&doc, "Plain").kind, NodeKind::Frame)); + assert!(node(&doc, "Plain").clip); + assert_eq!(solid(&doc, "Plain"), Some([1.0, 1.0, 1.0, 1.0])); + assert_eq!(solid(&doc, "Red"), Some([1.0, 0.0, 0.0, 1.0])); + let b = doc.world_bounds(node(&doc, "Red").id).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (200.0, 0.0, 100.0, 50.0)); +} + +#[test] +fn group_children_positions_accumulate() { + let r = rect("R", 5.0, 5.0, 10.0, 10.0); + let g = group("G", 10.0, 20.0, 15.0, 15.0, vec![r]); + let (doc, _) = open(vec![artboard("A", "Board", 100.0, 100.0, 300.0, 300.0, vec![g])]); + assert!(matches!(node(&doc, "G").kind, NodeKind::Group)); + let b = doc.world_bounds(node(&doc, "R").id).unwrap(); + assert!((b.x0 - 115.0).abs() < 1e-6 && (b.y0 - 125.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn rotation_and_flip() { + let rotated = with(rect("Turned", 0.0, 0.0, 40.0, 20.0), "rotation", json!(90)); + let flipped = with(rect("Flipped", 100.0, 0.0, 40.0, 20.0), "isFlippedHorizontal", json!(true)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 300.0, 300.0, vec![rotated, flipped])]); + let b = doc.world_bounds(node(&doc, "Turned").id).unwrap(); + assert!((b.width() - 20.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "{b:?}"); + assert!((b.center().x - 20.0).abs() < 1e-6 && (b.center().y - 10.0).abs() < 1e-6, "{b:?}"); + // Counter-clockwise on screen: the box's x axis points up. + let t = doc.world_transform(node(&doc, "Turned").id).as_coeffs(); + assert!(t[1] < -0.99, "{t:?}"); + let t = doc.world_transform(node(&doc, "Flipped").id).as_coeffs(); + assert!(t[0] < -0.99, "{t:?}"); + let b = doc.world_bounds(node(&doc, "Flipped").id).unwrap(); + assert!((b.x0 - 100.0).abs() < 1e-6 && (b.width() - 40.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn resizing_constraint_bits() { + use HConstraint as H; + use VConstraint as V; + // A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32. + let cases = [ + (63, H::Scale, V::Scale), + (63 - 4 - 32, H::Left, V::Top), + (63 - 1 - 8, H::Right, V::Bottom), + (63 - 1 - 4, H::LeftRight, V::Scale), + (63 - 8 - 32, H::Scale, V::TopBottom), + (63 - 2 - 16, H::Center, V::Center), + (63 - 1 - 2, H::Right, V::Scale), + (0, H::LeftRight, V::TopBottom), + ]; + let layers: Vec = cases + .iter() + .enumerate() + .map(|(i, (mask, _, _))| with(rect(&format!("R{i}"), 0.0, 0.0, 10.0, 10.0), "resizingConstraint", json!(mask))) + .collect(); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, layers)]); + for (i, (mask, h, v)) in cases.iter().enumerate() { + let c = node(&doc, &format!("R{i}")).constraints; + assert_eq!((c.horizontal, c.vertical), (*h, *v), "mask {mask}"); + } +} + +#[test] +fn hidden_locked_named() { + let r = with(with(rect("Hidden", 0.0, 0.0, 10.0, 10.0), "isVisible", json!(false)), "isLocked", json!(true)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![r])]); + let n = node(&doc, "Hidden"); + assert!(!n.visible && n.locked); +} + +#[test] +fn bad_numbers_are_sane() { + let mut nan = rect("Junk", 0.0, 0.0, 10.0, 10.0); + nan["frame"]["width"] = json!("NaN"); + nan["frame"]["x"] = json!(1e300); + let mut neg = rect("Negative", 0.0, 0.0, 10.0, 10.0); + neg["frame"]["height"] = json!(-5); + neg["rotation"] = json!("x"); + let mut none = rect("Frameless", 0.0, 0.0, 10.0, 10.0); + none.as_object_mut().unwrap().remove("frame"); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![nan, neg, none])]); + for n in doc.nodes() { + assert!(n.width.is_finite() && n.height.is_finite() && n.width >= 0.0 && n.height >= 0.0, "{}", n.name); + assert!(n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{}", n.name); + } +} + +#[test] +fn rectangle_corner_radii() { + let mut each = rect("Each", 0.0, 0.0, 40.0, 40.0); + for (i, r) in [1, 2, 3, 4].iter().enumerate() { + each["points"][i]["cornerRadius"] = json!(r); + } + let fixed = with(rect("Fixed", 50.0, 0.0, 40.0, 40.0), "fixedRadius", json!(8)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![each, fixed])]); + assert!(matches!(node(&doc, "Each").kind, NodeKind::Rectangle)); + assert_eq!(node(&doc, "Each").corners.radii, [1.0, 2.0, 3.0, 4.0]); + assert_eq!(node(&doc, "Fixed").corners.radii, [8.0; 4]); +} + +#[test] +fn oval_slice_and_bitmap() { + let mut oval = rect("Round", 0.0, 0.0, 30.0, 20.0); + oval["_class"] = json!("oval"); + let mut slice = rect("Cut", 0.0, 0.0, 30.0, 20.0); + slice["_class"] = json!("slice"); + let mut bitmap = rect("Photo", 0.0, 0.0, 30.0, 20.0); + bitmap["_class"] = json!("bitmap"); + bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref_class": "MSImageData", "_ref": "images/abc123.png"}); + let png = { + let mut out = Vec::new(); + image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + }; + let (doc, _) = open_with(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![oval, slice, bitmap])], &[("images/abc123.png", &png)]); + assert!(matches!(node(&doc, "Round").kind, NodeKind::Ellipse { .. })); + assert!(matches!(node(&doc, "Cut").kind, NodeKind::Slice)); + let photo = node(&doc, "Photo"); + assert!(matches!(photo.kind, NodeKind::Rectangle)); + assert!(photo.fills.iter().any(|p| matches!(&p.kind, PaintKind::Image(_)))); + assert_eq!(doc.images.len(), 1); +} + +#[test] +fn unknown_class_is_a_warning() { + let mut odd = rect("Odd", 0.0, 0.0, 10.0, 10.0); + odd["_class"] = json!("MSSomethingNew"); + let (doc, warnings) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![odd])]); + assert!(doc.nodes().all(|n| n.name != "Odd")); + assert!(warnings.iter().any(|w| w.contains("MSSomethingNew")), "{warnings:?}"); +} + +#[test] +fn sketch_2025_frames_and_graphics() { + let mut frame = artboard("F", "Frame", 0.0, 0.0, 100.0, 100.0, vec![rect("Inside", 0.0, 0.0, 10.0, 10.0)]); + frame["_class"] = json!("frame"); + frame["clipsContent"] = json!(false); + let mut graphic = group("Graphic", 10.0, 10.0, 10.0, 10.0, vec![rect("Leaf", 0.0, 0.0, 10.0, 10.0)]); + graphic["_class"] = json!("graphic"); + frame["layers"].as_array_mut().unwrap().push(graphic); + let (doc, _) = open(vec![frame]); + assert!(matches!(node(&doc, "Frame").kind, NodeKind::Frame)); + assert!(!node(&doc, "Frame").clip); + assert!(matches!(node(&doc, "Graphic").kind, NodeKind::Group)); +} From 72c7eae868e2de5b27f8eaaacfe9d5f0f5b33e9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 15:59:59 +0200 Subject: [PATCH 08/87] M6: Sketch import: editable paths, shape groups, booleans, stars, polygons Paths become editable vector networks (handles, per-point corner radius, open and closed, winding rule). A shape group with one shape becomes that shape in the group's place; several combine as boolean operations, mixed operations nesting from the left as Sketch applies them. Stars and polygons stay stars and polygons while their points are the generated ones, edited ones and triangles become vectors. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/fixture.rs | 14 ++ crates/format/src/sketch/mod.rs | 3 + crates/format/src/sketch/path.rs | 193 +++++++++++++++++++++++++ crates/format/src/sketch/path_tests.rs | 154 ++++++++++++++++++++ crates/format/src/sketch/tree.rs | 69 ++++++--- 5 files changed, 410 insertions(+), 23 deletions(-) create mode 100644 crates/format/src/sketch/path.rs create mode 100644 crates/format/src/sketch/path_tests.rs diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index 231225c9..68084767 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -118,3 +118,17 @@ pub(crate) fn with(mut l: Value, key: &str, value: Value) -> Value { } l } + +/// A `shapePath` through `points` (`(x, y)` in 0–1 of its frame), straight segments. +pub(crate) fn shape_path(id: &str, x: f64, y: f64, w: f64, h: f64, points: &[(f64, f64)], closed: bool) -> Value { + let pts: Vec = points.iter().map(|(px, py)| point(*px, *py)).collect(); + let mut l = with(layer("shapePath", id, id, x, y, w, h), "points", Value::Array(pts)); + l["isClosed"] = json!(closed); + l["booleanOperation"] = json!(-1); + l +} + +/// A `shapeGroup` (Sketch 51 and older wrap every shape in one) holding `layers`. +pub(crate) fn shape_group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + with(layer("shapeGroup", id, id, x, y, w, h), "layers", Value::Array(layers)) +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index a46706db..5d703622 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -13,6 +13,9 @@ #[cfg(test)] mod fixture; +mod path; +#[cfg(test)] +mod path_tests; #[cfg(test)] mod tests; mod tree; diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs new file mode 100644 index 00000000..a842b295 --- /dev/null +++ b/crates/format/src/sketch/path.rs @@ -0,0 +1,193 @@ +//! Sketch curve points → vector networks; shape groups → shapes and boolean operations. +//! +//! A path's `points` are `curvePoint`s whose `point`, `curveFrom` (the handle leaving the point) +//! and `curveTo` (the handle arriving at it) are `"{x, y}"` strings in 0–1 of the layer's frame; +//! values outside that range are allowed. Sketch 51 and older wrap every shape in a `shapeGroup` +//! that carries the style; its children combine left to right, each with its own +//! `booleanOperation` (−1 none and 0 union, 1 subtract, 2 intersect, 3 difference). + +use figcraft_doc::geom::{Affine, polygon_points, star_points}; +use figcraft_doc::{Region, Segment, VectorNetwork, Vertex, WindingRule}; +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, base, layer, layers, local, size}; + +/// Most points read from one path. +const MAX_POINTS: usize = 100_000; + +/// What a path layer becomes. +pub(crate) enum Shape { + Vector(VectorNetwork), + Star { count: u32, ratio: f64 }, + Polygon { count: u32 }, +} + +/// `"{x, y}"` → `(x, y)`, both finite. +pub(crate) fn parse_point(s: &str) -> Option<(f64, f64)> { + let inner = s.trim().strip_prefix('{')?.strip_suffix('}')?; + let (x, y) = inner.split_once(',')?; + let n = |t: &str| t.trim().parse::().ok().filter(|v| v.is_finite()); + Some((n(x)?, n(y)?)) +} + +/// The network of a path layer of `w`×`h` (`None` when it has no usable points). +pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option { + let pts = l.get("points").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + if pts.len() > MAX_POINTS { + cx.warn(format!("paths with more than {MAX_POINTS} points were cut")); + } + let at = |p: &Value, key: &str| p.get(key).and_then(Value::as_str).and_then(parse_point).map(|(x, y)| (x * w, y * h)); + let mut vertices = Vec::new(); + // Per vertex: its handles (relative to it) when it has them. + let mut handles: Vec<((f64, f64), (f64, f64))> = Vec::new(); + for p in pts.iter().take(MAX_POINTS) { + let Some((x, y)) = at(p, "point") else { + cx.warn("path points that couldn't be read were skipped"); + continue; + }; + let curved = p.get("curveMode").and_then(Value::as_i64).is_some_and(|m| m != 1); + let has = |flag: &str| p.get(flag).and_then(Value::as_bool).unwrap_or(curved); + let handle = |flag: &str, key: &str| if has(flag) { at(p, key).map_or((0.0, 0.0), |(hx, hy)| (hx - x, hy - y)) } else { (0.0, 0.0) }; + handles.push((handle("hasCurveFrom", "curveFrom"), handle("hasCurveTo", "curveTo"))); + let mut v = Vertex::new(x, y); + v.radius = p.get("cornerRadius").and_then(Value::as_f64).filter(|r| r.is_finite()).map_or(0.0, |r| r.clamp(0.0, 1e6)); + vertices.push(v); + } + let n = vertices.len(); + if n == 0 { + return None; + } + let closed = l.get("isClosed").and_then(Value::as_bool).unwrap_or(false) && n > 1; + let count = if closed { n } else { n - 1 }; + let segments: Vec = (0..count) + .map(|i| { + let j = (i + 1) % n; + let ta = handles.get(i).map_or((0.0, 0.0), |h| h.0); + let tb = handles.get(j).map_or((0.0, 0.0), |h| h.1); + Segment { a: i, b: j, ta, tb } + }) + .collect(); + let winding = match l.get("style").and_then(|s| s.get("windingRule")).and_then(Value::as_i64) { + Some(1) => WindingRule::EvenOdd, + _ => WindingRule::NonZero, + }; + let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding }] } else { Vec::new() }; + Some(VectorNetwork { vertices, segments, regions }) +} + +/// A path, star, polygon or triangle layer: a star or polygon while its points are still the +/// generated ones (same as FigCraft's, within half a percent), else an editable vector. +pub(crate) fn shape(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option { + let net = network(cx, l, w, h)?; + let class = l.get("_class").and_then(Value::as_str).unwrap_or(""); + let count = l.get("numberOfPoints").and_then(Value::as_u64).filter(|c| (3..=120).contains(c)).and_then(|c| u32::try_from(c).ok()); + let plain = net.vertices.iter().all(|v| v.radius == 0.0) && net.segments.iter().all(|s| s.ta == (0.0, 0.0) && s.tb == (0.0, 0.0)); + let matches = |pts: Vec| { + let tol = 0.005 * w.max(h) + 1e-6; + pts.len() == net.vertices.len() && pts.iter().zip(&net.vertices).all(|(p, v)| (p.x - v.x).abs() <= tol && (p.y - v.y).abs() <= tol) + }; + match (class, count) { + ("star", Some(count)) if plain => { + let ratio = l.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r > 0.0).map_or(0.5, |r| r.min(1.0)); + if matches(star_points(w, h, count, ratio)) { + return Some(Shape::Star { count, ratio }); + } + } + ("polygon", Some(count)) if plain && matches(polygon_points(w, h, count)) => return Some(Shape::Polygon { count }), + _ => {} + } + Some(Shape::Vector(net)) +} + +/// Write `shape` into `node` (its type and geometry). +pub(crate) fn put_shape(cx: &mut Cx, shape: Shape, node: &mut JsonMap) { + match shape { + Shape::Vector(net) => { + node.insert("type".into(), json!("VECTOR")); + node.insert("vectorNetworkRef".into(), json!(cx.networks.len())); + cx.networks.push(Some(net)); + } + Shape::Star { count, ratio } => { + node.insert("type".into(), json!("STAR")); + node.insert("pointCount".into(), json!(count)); + node.insert("innerRadius".into(), json!(ratio)); + } + Shape::Polygon { count } => { + node.insert("type".into(), json!("REGULAR_POLYGON")); + node.insert("pointCount".into(), json!(count)); + } + } +} + +/// A `shapeGroup`: its only child carrying the group's place, name and style, or its children +/// combined by boolean operations (mixed operations nest from the left). +pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + let (w, h) = size(l); + let world = parent * local(l, w, h); + let kids = layers(l); + if let [only] = kids { + let mut node = layer(cx, only, world, depth + 1, false)?; + let (cw, ch) = size(only); + let child_world = world * local(only, cw, ch); + let group = base(l, "GROUP", parent, child_world, cw, ch, root); + if let Some(m) = node.as_object_mut() { + for key in ["id", "name", "relativeTransform", "absoluteBoundingBox", "constraints", "locked"] { + match group.get(key) { + Some(v) => m.insert(key.into(), v.clone()), + None => m.remove(key), + }; + } + let visible = group.get("visible").and_then(Value::as_bool).unwrap_or(true) && m.get("visible").and_then(Value::as_bool).unwrap_or(true); + m.insert("visible".into(), json!(visible)); + } + return Some(node); + } + let mut chain: Vec = Vec::new(); + let mut op = "UNION"; + for (i, c) in kids.iter().enumerate() { + let Some(child) = layer(cx, c, world, depth + 2, false) else { continue }; + let this = boolean_op(c); + if i > 0 && this != op && chain.len() > 1 { + let inner = synthetic(l, op, parent, world, w, h, std::mem::take(&mut chain)); + chain.push(inner); + } + if i > 0 { + op = this; + } + chain.push(child); + } + if chain.is_empty() { + return None; + } + let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, root); + node.insert("booleanOperation".into(), json!(op)); + node.insert("children".into(), Value::Array(chain)); + Some(Value::Object(node)) +} + +/// A boolean operation the shape group doesn't have as a layer of its own: placed like the group. +fn synthetic(l: &Value, op: &str, parent: Affine, world: Affine, w: f64, h: f64, children: Vec) -> Value { + let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, false); + node.remove("id"); + let name = match op { + "SUBTRACT" => "Subtract", + "INTERSECT" => "Intersect", + "EXCLUDE" => "Exclude", + _ => "Union", + }; + node.insert("name".into(), json!(name)); + // Placed where the group is: inside it, that's no move at all. + node.insert("relativeTransform".into(), json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])); + node.insert("booleanOperation".into(), json!(op)); + node.insert("children".into(), Value::Array(children)); + Value::Object(node) +} + +fn boolean_op(l: &Value) -> &'static str { + match l.get("booleanOperation").and_then(Value::as_i64) { + Some(1) => "SUBTRACT", + Some(2) => "INTERSECT", + Some(3) => "EXCLUDE", + _ => "UNION", + } +} diff --git a/crates/format/src/sketch/path_tests.rs b/crates/format/src/sketch/path_tests.rs new file mode 100644 index 00000000..8e1528fb --- /dev/null +++ b/crates/format/src/sketch/path_tests.rs @@ -0,0 +1,154 @@ +//! Sketch paths → editable vector networks; shape groups → shapes and boolean operations; stars +//! and polygons. + +use figcraft_doc::{BoolOp, Document, NodeKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; +use super::path::parse_point; + +fn open(layers: Vec) -> Document { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], &[]); + import(&z, "f").unwrap().0 +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d figcraft_doc::Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}")) +} + +fn with_op(mut l: Value, op: i64) -> Value { + l["booleanOperation"] = json!(op); + l +} + +#[test] +fn parse_point_accepts_sketch_strings() { + assert_eq!(parse_point("{0.5, 1}"), Some((0.5, 1.0))); + assert_eq!(parse_point("{-0.25,2e-1}"), Some((-0.25, 0.2))); + assert_eq!(parse_point("{1e400, 0}"), None); + assert_eq!(parse_point("garbage"), None); + assert_eq!(parse_point("{0.1}"), None); + assert_eq!(parse_point(""), None); +} + +#[test] +fn triangle_path_is_editable_vector() { + let tri = shape_path("Tri", 10.0, 10.0, 100.0, 100.0, &[(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)], true); + let open_line = shape_path("Line", 10.0, 200.0, 100.0, 50.0, &[(0.0, 0.0), (1.0, 1.0)], false); + let doc = open(vec![tri, open_line]); + let NodeKind::Vector { network } = &node(&doc, "Tri").kind else { panic!("not a vector") }; + let pts: Vec<(f64, f64)> = network.vertices.iter().map(|v| (v.x, v.y)).collect(); + assert_eq!(pts, [(50.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); + assert_eq!(network.segments.len(), 3); + assert_eq!(network.regions.len(), 1); + let NodeKind::Vector { network } = &node(&doc, "Line").kind else { panic!("not a vector") }; + assert_eq!(network.segments.len(), 1); + assert!(network.regions.is_empty()); +} + +#[test] +fn curve_handles_and_corner_radius_are_kept() { + let mut p = shape_path("Curve", 0.0, 0.0, 100.0, 100.0, &[(0.0, 0.0), (1.0, 0.0)], false); + p["points"][0]["curveMode"] = json!(2); + p["points"][0]["hasCurveFrom"] = json!(true); + p["points"][0]["curveFrom"] = json!("{0.25, 0.5}"); + p["points"][1]["curveMode"] = json!(2); + p["points"][1]["hasCurveTo"] = json!(true); + p["points"][1]["curveTo"] = json!("{0.75, 0.5}"); + p["points"][1]["cornerRadius"] = json!(6); + let doc = open(vec![p]); + let NodeKind::Vector { network } = &node(&doc, "Curve").kind else { panic!("not a vector") }; + let s = network.segments[0]; + assert_eq!((s.ta, s.tb), ((25.0, 50.0), (-25.0, 50.0))); + assert_eq!(network.vertices[1].radius, 6.0); +} + +#[test] +fn single_child_shape_group_collapses() { + let inner = with_op(rect("Inner", 0.0, 0.0, 50.0, 30.0), -1); + let doc = open(vec![shape_group("Button", 20.0, 40.0, 50.0, 30.0, vec![inner])]); + let b = node(&doc, "Button"); + assert!(matches!(b.kind, NodeKind::Rectangle)); + assert!(doc.nodes().all(|n| n.name != "Inner")); + let bb = doc.world_bounds(b.id).unwrap(); + assert_eq!((bb.x0, bb.y0, bb.width(), bb.height()), (20.0, 40.0, 50.0, 30.0)); +} + +#[test] +fn mixed_boolean_ops_chain_left() { + let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1); + let b = with_op(rect("B", 10.0, 10.0, 20.0, 20.0), 1); + let c = with_op(rect("C", 60.0, 0.0, 20.0, 20.0), 0); + let doc = open(vec![shape_group("Combined", 0.0, 0.0, 80.0, 50.0, vec![a, b, c])]); + let outer = node(&doc, "Combined"); + assert!(matches!(outer.kind, NodeKind::BooleanOp { op: BoolOp::Union })); + let kids: Vec<&figcraft_doc::Node> = outer.children.iter().map(|c| doc.node(*c).unwrap()).collect(); + assert_eq!(kids.len(), 2); + assert!(matches!(kids[0].kind, NodeKind::BooleanOp { op: BoolOp::Subtract })); + assert_eq!(kids[1].name, "C"); + let inner: Vec<&str> = kids[0].children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(inner, ["A", "B"]); + let bb = doc.world_bounds(node(&doc, "B").id).unwrap(); + assert_eq!((bb.x0, bb.y0), (10.0, 10.0)); +} + +#[test] +fn intersect_and_difference_ops() { + let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1); + let b = with_op(rect("B", 10.0, 10.0, 50.0, 50.0), 2); + let doc = open(vec![shape_group("Both", 0.0, 0.0, 60.0, 60.0, vec![a.clone(), b])]); + assert!(matches!(node(&doc, "Both").kind, NodeKind::BooleanOp { op: BoolOp::Intersect })); + let b = with_op(rect("B", 10.0, 10.0, 50.0, 50.0), 3); + let doc = open(vec![shape_group("Either", 0.0, 0.0, 60.0, 60.0, vec![a, b])]); + assert!(matches!(node(&doc, "Either").kind, NodeKind::BooleanOp { op: BoolOp::Exclude })); +} + +#[test] +fn standalone_shapes_52_plus() { + let doc = open(vec![shape_path("Free", 5.0, 5.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true)]); + assert!(matches!(node(&doc, "Free").kind, NodeKind::Vector { .. })); +} + +#[test] +fn star_and_polygon() { + let unit = |pts: Vec, w: f64, h: f64| -> Vec<(f64, f64)> { pts.iter().map(|p| (p.x / w, p.y / h)).collect() }; + let mut star = shape_path("Star", 0.0, 0.0, 100.0, 80.0, &unit(figcraft_doc::geom::star_points(100.0, 80.0, 5, 0.5), 100.0, 80.0), true); + star["_class"] = json!("star"); + star["numberOfPoints"] = json!(5); + star["radius"] = json!(0.5); + let mut poly = shape_path("Hexagon", 0.0, 100.0, 60.0, 60.0, &unit(figcraft_doc::geom::polygon_points(60.0, 60.0, 6), 60.0, 60.0), true); + poly["_class"] = json!("polygon"); + poly["numberOfPoints"] = json!(6); + let mut edited = star.clone(); + edited["do_objectID"] = json!("Edited"); + edited["name"] = json!("Edited"); + edited["points"][2]["point"] = json!("{0.9, 0.9}"); + let mut tri = shape_path("Triangle", 200.0, 0.0, 50.0, 50.0, &[(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)], true); + tri["_class"] = json!("triangle"); + let doc = open(vec![star, poly, edited, tri]); + assert!(matches!(node(&doc, "Star").kind, NodeKind::Star { count: 5, .. })); + if let NodeKind::Star { inner_ratio, .. } = node(&doc, "Star").kind { + assert!((inner_ratio - 0.5).abs() < 1e-9); + } + assert!(matches!(node(&doc, "Hexagon").kind, NodeKind::Polygon { count: 6 })); + assert!(matches!(node(&doc, "Edited").kind, NodeKind::Vector { .. })); + assert!(matches!(node(&doc, "Triangle").kind, NodeKind::Vector { .. })); +} + +#[test] +fn points_outside_unit_are_kept_finite() { + let doc = open(vec![shape_path("Over", 0.0, 0.0, 10.0, 10.0, &[(-0.5, 2.0), (1.0, 0.0), (0.0, 0.0)], true)]); + let NodeKind::Vector { network } = &node(&doc, "Over").kind else { panic!("not a vector") }; + assert!(network.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())); + assert_eq!((network.vertices[0].x, network.vertices[0].y), (-5.0, 20.0)); +} + +#[test] +fn even_odd_winding() { + let mut p = shape_path("Holes", 0.0, 0.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true); + p["style"]["windingRule"] = json!(1); + let doc = open(vec![p]); + let NodeKind::Vector { network } = &node(&doc, "Holes").kind else { panic!("not a vector") }; + assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 59e3d637..89a43010 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -70,43 +70,31 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: return None; } let class = str_field(l, "_class")?; + if class == "shapeGroup" { + return count(cx).then(|| super::path::shape_group(cx, l, parent, depth, root)).flatten(); + } let ty = match class { "artboard" | "frame" => "FRAME", "group" | "graphic" => "GROUP", "rectangle" | "bitmap" => "RECTANGLE", "oval" => "ELLIPSE", "slice" => "SLICE", + "shapePath" | "star" | "polygon" | "triangle" => "VECTOR", other => { cx.warn(format!("Sketch {} layers are not supported yet and were skipped", clip(other))); return None; } }; - if cx.nodes >= cx.max_nodes { - cx.warn(format!("the file has more than {} layers; the rest were skipped", cx.max_nodes)); + if !count(cx) { return None; } - cx.nodes += 1; let (w, h) = size(l); let world = parent * local(l, w, h); - let mut node = JsonMap::new(); - node.insert("type".into(), json!(ty)); - if let Some(id) = str_field(l, "do_objectID") { - node.insert("id".into(), json!(id)); + let mut node = base(l, ty, parent, world, w, h, root); + if ty == "VECTOR" { + let shape = super::path::shape(cx, l, w, h)?; + super::path::put_shape(cx, shape, &mut node); } - node.insert("name".into(), json!(str_field(l, "name").unwrap_or(class))); - node.insert("visible".into(), json!(bool_field(l, "isVisible").unwrap_or(true))); - node.insert("locked".into(), json!(bool_field(l, "isLocked").unwrap_or(false))); - node.insert("size".into(), json!({"x": w, "y": h})); - let rel = parent.inverse() * world; - let [a, b, c, d, e, f] = rel.as_coeffs(); - node.insert("relativeTransform".into(), json!([[a, c, e], [b, d, f]])); - // The page places top-level layers by their transform; deeper ones also say where they end up, - // which tells the importer their transform is relative to their group. - if !root { - let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); - node.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()})); - } - node.insert("constraints".into(), constraints(l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63))); match class { "artboard" | "frame" => { let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) }; @@ -134,8 +122,43 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: Some(Value::Object(node)) } +/// Count one more layer; false (with a warning) once the file's budget is spent. +fn count(cx: &mut Cx) -> bool { + if cx.nodes >= cx.max_nodes { + cx.warn(format!("the file has more than {} layers; the rest were skipped", cx.max_nodes)); + return false; + } + cx.nodes += 1; + true +} + +/// The properties every REST node of layer `l` has: its type, id, name, visibility, lock, size, +/// place (`world`, relative to `parent`) and constraints. +pub(crate) fn base(l: &Value, ty: &str, parent: Affine, world: Affine, w: f64, h: f64, root: bool) -> JsonMap { + let mut node = JsonMap::new(); + node.insert("type".into(), json!(ty)); + if let Some(id) = str_field(l, "do_objectID") { + node.insert("id".into(), json!(id)); + } + let class = str_field(l, "_class").unwrap_or("Layer"); + node.insert("name".into(), json!(str_field(l, "name").unwrap_or(class))); + node.insert("visible".into(), json!(bool_field(l, "isVisible").unwrap_or(true))); + node.insert("locked".into(), json!(bool_field(l, "isLocked").unwrap_or(false))); + node.insert("size".into(), json!({"x": w, "y": h})); + let [a, b, c, d, e, f] = (parent.inverse() * world).as_coeffs(); + node.insert("relativeTransform".into(), json!([[a, c, e], [b, d, f]])); + // The page places top-level layers by their transform; deeper ones also say where they end up, + // which tells the importer their transform is relative to their group. + if !root { + let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); + node.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()})); + } + node.insert("constraints".into(), constraints(l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63))); + node +} + /// A layer's size from its `frame` (finite, not negative, at most [`MAX_SIZE`]). -fn size(l: &Value) -> (f64, f64) { +pub(crate) fn size(l: &Value) -> (f64, f64) { let f = l.get("frame"); let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_SIZE); (n("width"), n("height")) @@ -143,7 +166,7 @@ fn size(l: &Value) -> (f64, f64) { /// A layer's transform within its parent: its frame's position, then its rotation (degrees, /// counter-clockwise on screen) and flips about its centre. -fn local(l: &Value, w: f64, h: f64) -> Affine { +pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine { let f = l.get("frame"); let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-MAX_COORD, MAX_COORD); let rot = l.get("rotation").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) % 360.0; From d77d57912bf26408571a1844e7ca8addc244910c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:05:02 +0200 Subject: [PATCH 09/87] M6: Sketch import: fills, borders, effects, opacity, masks Solid, gradient (linear, radial with its ellipse, angular) and image pattern fills (tile, fill, stretch, fit); borders as strokes with the first enabled border's width and position, dashes, caps, joins and arrow ends; shadows, inner shadows, layer and background blurs; layer and paint opacity and blend modes; clipping masks, wrapped in a group of their own where Sketch breaks the mask chain; Display P3 documents. Shape groups pass their style to the shape or boolean operation they become. Noise fills, motion and zoom blurs are listed in the import report. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/fixture.rs | 17 ++ crates/format/src/sketch/mod.rs | 13 +- crates/format/src/sketch/path.rs | 7 + crates/format/src/sketch/style.rs | 265 ++++++++++++++++++++++++ crates/format/src/sketch/style_tests.rs | 235 +++++++++++++++++++++ crates/format/src/sketch/tree.rs | 53 ++++- 6 files changed, 585 insertions(+), 5 deletions(-) create mode 100644 crates/format/src/sketch/style.rs create mode 100644 crates/format/src/sketch/style_tests.rs diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index 68084767..dc6fefe9 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -132,3 +132,20 @@ pub(crate) fn shape_path(id: &str, x: f64, y: f64, w: f64, h: f64, points: &[(f6 pub(crate) fn shape_group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { with(layer("shapeGroup", id, id, x, y, w, h), "layers", Value::Array(layers)) } + +/// A Sketch colour. +pub(crate) fn color(r: f64, g: f64, b: f64, a: f64) -> Value { + json!({"_class": "color", "red": r, "green": g, "blue": b, "alpha": a}) +} + +/// An enabled solid fill. +pub(crate) fn solid_fill(r: f64, g: f64, b: f64, a: f64) -> Value { + json!({"_class": "fill", "isEnabled": true, "fillType": 0, "color": color(r, g, b, a), + "contextSettings": {"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 1}}) +} + +/// `l` with `style.` set to `value`. +pub(crate) fn styled(mut l: Value, key: &str, value: Value) -> Value { + l["style"][key] = value; + l +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 5d703622..8ab154d9 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -16,6 +16,9 @@ mod fixture; mod path; #[cfg(test)] mod path_tests; +mod style; +#[cfg(test)] +mod style_tests; #[cfg(test)] mod tests; mod tree; @@ -79,7 +82,10 @@ pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result let mut warnings = archive.warnings.clone(); warnings.extend(map.warnings); let images = |r: &str| archive.image(r); - let (doc, more) = crate::rest::import_with_networks_plain(&map.json, &images, map.networks, max_nodes)?; + let (mut doc, more) = crate::rest::import_with_networks_plain(&map.json, &images, map.networks, max_nodes)?; + if archive.document.get("colorSpace").and_then(Value::as_i64) == Some(2) { + doc.color_profile = figcraft_doc::color::ColorProfile::DisplayP3; + } warnings.extend(more); Ok((doc, warnings)) } @@ -88,8 +94,9 @@ fn corrupt(msg: impl Into) -> FormatError { FormatError::Corrupt(format!("Sketch file: {}", msg.into())) } -/// The parts of a `.sketch` archive: the pages in document order and the images. +/// The parts of a `.sketch` archive: the document, the pages in document order and the images. pub(crate) struct Archive<'b> { + pub document: Value, /// `(id, page JSON)` in the document's order. pub pages: Vec<(String, Value)>, /// Problems met while reading (pages skipped). @@ -132,7 +139,7 @@ impl<'b> Archive<'b> { if refs.len() > MAX_PAGES { warnings.push(format!("the file has more than {MAX_PAGES} pages; the rest were skipped")); } - Ok(Archive { pages, warnings, zip: z }) + Ok(Archive { document, pages, warnings, zip: z }) } /// The bytes of the image a layer references (`images/`). diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index a842b295..47a8f2d3 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -139,6 +139,10 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, } let visible = group.get("visible").and_then(Value::as_bool).unwrap_or(true) && m.get("visible").and_then(Value::as_bool).unwrap_or(true); m.insert("visible".into(), json!(visible)); + if let Some(st) = l.get("style") { + super::style::clear(m); + super::style::apply(cx, st, cw, ch, m); + } } return Some(node); } @@ -162,6 +166,9 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, root); node.insert("booleanOperation".into(), json!(op)); node.insert("children".into(), Value::Array(chain)); + if let Some(st) = l.get("style") { + super::style::apply(cx, st, w, h, &mut node); + } Some(Value::Object(node)) } diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs new file mode 100644 index 00000000..2da1b7a4 --- /dev/null +++ b/crates/format/src/sketch/style.rs @@ -0,0 +1,265 @@ +//! Sketch styles → REST paints, strokes, effects, opacity and blend modes. +//! +//! A layer's `style` lists `fills` and `borders` (each a colour, gradient or, for fills, an image +//! pattern, with its own `contextSettings` opacity and blend mode), `borderOptions` (dashes, caps, +//! joins), `shadows`, `innerShadows`, one `blur` and the layer's own `contextSettings`. Every +//! border has its own thickness and position; FigCraft strokes share one, so the first enabled +//! border's is used. + +use serde_json::{Map as JsonMap, Value, json}; + +use super::path::parse_point; +use super::tree::{Cx, color}; + +/// Most paints or effects read from one list. +const MAX_LIST: usize = 64; + +/// Write the paints, strokes, effects, opacity and blend mode of `style` into `node`, a layer of +/// `w`×`h`. Lists the style doesn't have are left as they are. +pub(crate) fn apply(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap) { + let fills: Vec = list(style, "fills").iter().filter_map(|f| paint(cx, f, w, h)).collect(); + if !fills.is_empty() { + node.insert("fills".into(), Value::Array(fills)); + } + borders(cx, style, w, h, node); + let effects = effects(cx, style); + if !effects.is_empty() { + node.insert("effects".into(), Value::Array(effects)); + } + if let Some(ctx) = style.get("contextSettings") { + if let Some(o) = num(ctx, "opacity") { + node.insert("opacity".into(), json!(o.clamp(0.0, 1.0))); + } + if let Some(b) = ctx.get("blendMode").and_then(Value::as_i64).filter(|b| *b != 0).and_then(blend) { + node.insert("blendMode".into(), json!(b)); + } + } +} + +/// Remove what [`apply`] writes, so another style can take its place. +pub(crate) fn clear(node: &mut JsonMap) { + for key in [ + "fills", + "strokes", + "strokeWeight", + "strokeAlign", + "strokeDashes", + "strokeCap", + "strokeCapStart", + "strokeCapEnd", + "strokeJoin", + "effects", + "opacity", + "blendMode", + ] { + node.remove(key); + } +} + +/// One fill or border as a REST paint; `None` for kinds FigCraft doesn't have (with a warning). +fn paint(cx: &mut Cx, f: &Value, w: f64, h: f64) -> Option { + let mut p = match f.get("fillType").and_then(Value::as_i64).unwrap_or(0) { + 0 => json!({"type": "SOLID", "color": f.get("color").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}))}), + 1 => gradient(f.get("gradient")?, w, h)?, + 4 => { + let Some(r) = f.get("image").and_then(|i| i.get("_ref")).and_then(Value::as_str) else { + cx.warn("image fills stored inside the JSON were skipped"); + return None; + }; + let mut p = json!({"type": "IMAGE", "imageRef": r}); + match f.get("patternFillType").and_then(Value::as_i64).unwrap_or(1) { + 0 => { + p["scaleMode"] = json!("TILE"); + p["scalingFactor"] = json!(num(f, "patternTileScale").filter(|s| *s > 0.0).unwrap_or(1.0)); + } + 2 => { + p["scaleMode"] = json!("STRETCH"); + p["imageTransform"] = json!([[1, 0, 0], [0, 1, 0]]); + } + 3 => p["scaleMode"] = json!("FIT"), + _ => p["scaleMode"] = json!("FILL"), + } + p + } + 5 => { + cx.warn("noise fills were skipped"); + return None; + } + other => { + cx.warn(format!("fills of type {other} were skipped")); + return None; + } + }; + p["visible"] = json!(f.get("isEnabled").and_then(Value::as_bool).unwrap_or(true)); + if let Some(ctx) = f.get("contextSettings") { + if let Some(o) = num(ctx, "opacity") { + p["opacity"] = json!(o.clamp(0.0, 1.0)); + } + if let Some(b) = ctx.get("blendMode").and_then(Value::as_i64).and_then(blend) { + p["blendMode"] = json!(b); + } + } + Some(p) +} + +/// A gradient fill: `from` and `to` in 0–1 of the layer; a radial gradient's `elipseLength` is its +/// width across the main axis as a share of the main axis, measured in pixels. +fn gradient(g: &Value, w: f64, h: f64) -> Option { + let point = |k: &str| g.get(k).and_then(Value::as_str).and_then(parse_point); + let (from, to) = (point("from").unwrap_or((0.5, 0.0)), point("to").unwrap_or((0.5, 1.0))); + let stops: Vec = list(g, "stops") + .iter() + .filter_map(|s| Some(json!({"position": num(s, "position")?.clamp(0.0, 1.0), "color": s.get("color").and_then(color)?}))) + .collect(); + let (ty, handles) = match g.get("gradientType").and_then(Value::as_i64).unwrap_or(0) { + 1 => { + let e = num(g, "elipseLength").filter(|e| *e > 0.0).unwrap_or(1.0); + let (dx, dy) = ((to.0 - from.0) * w, (to.1 - from.1) * h); + let across = if w > 0.0 && h > 0.0 { (from.0 - dy * e / w, from.1 + dx * e / h) } else { from }; + ("GRADIENT_RADIAL", json!([pt(from), pt(to), pt(across)])) + } + 2 => ("GRADIENT_ANGULAR", json!([pt((0.5, 0.5)), pt((0.5, 0.0))])), + _ => ("GRADIENT_LINEAR", json!([pt(from), pt(to)])), + }; + Some(json!({"type": ty, "gradientHandlePositions": handles, "gradientStops": stops})) +} + +fn pt((x, y): (f64, f64)) -> Value { + json!({"x": x, "y": y}) +} + +/// Borders → stroke paints, and the first enabled border's thickness and position. +fn borders(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap) { + let list = list(style, "borders"); + let strokes: Vec = list.iter().filter_map(|b| paint(cx, b, w, h)).collect(); + if strokes.is_empty() { + return; + } + let enabled: Vec<&Value> = list.iter().filter(|b| b.get("isEnabled").and_then(Value::as_bool).unwrap_or(true)).collect(); + let first = enabled.first().copied().or(list.first()); + let shape = |b: &Value| (num(b, "thickness").unwrap_or(1.0), b.get("position").and_then(Value::as_i64).unwrap_or(0)); + if let Some(b) = first { + let (t, pos) = shape(b); + if enabled.iter().any(|o| shape(o) != (t, pos)) { + cx.warn("layers with several borders of different widths or positions use the first one's"); + } + node.insert("strokeWeight".into(), json!(t.clamp(0.0, 1e6))); + node.insert( + "strokeAlign".into(), + json!(match pos { + 1 => "INSIDE", + 2 => "OUTSIDE", + _ => "CENTER", + }), + ); + } + node.insert("strokes".into(), Value::Array(strokes)); + if let Some(o) = style.get("borderOptions") { + let dashes: Vec = o + .get("dashPattern") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(Value::as_f64) + .filter(|d| d.is_finite() && *d >= 0.0) + .take(MAX_LIST) + .collect(); + if !dashes.is_empty() { + node.insert("strokeDashes".into(), json!(dashes)); + } + node.insert( + "strokeCap".into(), + json!(match o.get("lineCapStyle").and_then(Value::as_i64) { + Some(1) => "ROUND", + Some(2) => "SQUARE", + _ => "NONE", + }), + ); + node.insert( + "strokeJoin".into(), + json!(match o.get("lineJoinStyle").and_then(Value::as_i64) { + Some(1) => "ROUND", + Some(2) => "BEVEL", + _ => "MITER", + }), + ); + } + for (key, field) in [("startMarkerType", "strokeCapStart"), ("endMarkerType", "strokeCapEnd")] { + let cap = match style.get(key).and_then(Value::as_i64).unwrap_or(0) { + 0 => continue, + 1 => "LINE_ARROW", + 2 => "TRIANGLE_ARROW", + 5 => "CIRCLE_FILLED", + _ => { + cx.warn("line ends other than arrows and filled circles were drawn without an end"); + continue; + } + }; + node.insert(field.into(), json!(cap)); + } +} + +/// Shadows, inner shadows and the blur as REST effects. +fn effects(cx: &mut Cx, style: &Value) -> Vec { + let mut out = Vec::new(); + for (key, ty) in [("shadows", "DROP_SHADOW"), ("innerShadows", "INNER_SHADOW")] { + for s in list(style, key) { + let mut c = s.get("color").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 0.5})); + let o = s.get("contextSettings").and_then(|c| num(c, "opacity")).unwrap_or(1.0).clamp(0.0, 1.0); + c["a"] = json!(c["a"].as_f64().unwrap_or(1.0) * o); + out.push(json!({ + "type": ty, + "visible": s.get("isEnabled").and_then(Value::as_bool).unwrap_or(true), + "radius": num(s, "blurRadius").unwrap_or(0.0).clamp(0.0, 1e6), + "spread": num(s, "spread").unwrap_or(0.0), + "offset": {"x": num(s, "offsetX").unwrap_or(0.0), "y": num(s, "offsetY").unwrap_or(0.0)}, + "color": c, + })); + } + } + if let Some(b) = style.get("blur").filter(|b| b.get("isEnabled").and_then(Value::as_bool) == Some(true)) { + let radius = num(b, "radius").unwrap_or(0.0).clamp(0.0, 1e6); + match b.get("type").and_then(Value::as_i64).unwrap_or(0) { + 0 => out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius})), + 3 => out.push(json!({"type": "BACKGROUND_BLUR", "visible": true, "radius": radius})), + _ => cx.warn("motion and zoom blurs were skipped"), + } + } + out.truncate(MAX_LIST); + out +} + +/// Sketch's blend modes (0 normal … 17 plus lighter) by REST name. +fn blend(mode: i64) -> Option<&'static str> { + const MODES: [&str; 18] = [ + "NORMAL", + "DARKEN", + "MULTIPLY", + "COLOR_BURN", + "LIGHTEN", + "SCREEN", + "COLOR_DODGE", + "OVERLAY", + "SOFT_LIGHT", + "HARD_LIGHT", + "DIFFERENCE", + "EXCLUSION", + "HUE", + "SATURATION", + "COLOR", + "LUMINOSITY", + "LINEAR_BURN", + "LINEAR_DODGE", + ]; + MODES.get(usize::try_from(mode).ok()?).copied() +} + +fn list<'v>(v: &'v Value, key: &str) -> &'v [Value] { + let l = v.get(key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + l.get(..MAX_LIST).unwrap_or(l) +} + +fn num(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite()) +} diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs new file mode 100644 index 00000000..81b9f774 --- /dev/null +++ b/crates/format/src/sketch/style_tests.rs @@ -0,0 +1,235 @@ +//! Sketch styles → paints, strokes, effects, opacity, blend modes and masks. + +use figcraft_doc::color::{BlendMode, ColorProfile, GradientKind, ImageScaleMode, PaintKind}; +use figcraft_doc::{Document, EffectKind, NodeKind, StrokeAlign, StrokeCap, StrokeJoin}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +fn open_doc(document_json: Value, layers: Vec, files: &[(&str, &[u8])]) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, document_json, &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], files); + import(&z, "f").unwrap() +} + +fn open(layers: Vec) -> (Document, Vec) { + open_doc(document(&["P"]), layers, &[]) +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d figcraft_doc::Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}")) +} + +fn r(name: &str) -> Value { + rect(name, 0.0, 0.0, 100.0, 50.0) +} + +#[test] +fn solid_and_disabled_fills() { + let mut second = solid_fill(0.0, 0.0, 1.0, 1.0); + second["isEnabled"] = json!(false); + let mut half = solid_fill(1.0, 0.0, 0.0, 1.0); + half["contextSettings"]["opacity"] = json!(0.5); + let (doc, _) = open(vec![styled(r("Two"), "fills", json!([half, second]))]); + let fills = &node(&doc, "Two").fills; + assert_eq!(fills.len(), 2); + assert!(matches!(fills[0].kind, PaintKind::Solid { color } if color.r == 1.0 && color.b == 0.0)); + assert!((fills[0].opacity - 0.5).abs() < 1e-6 && fills[0].visible); + assert!(!fills[1].visible); +} + +#[test] +fn collapsed_shape_group_takes_the_groups_style() { + let inner = with(r("Inner"), "booleanOperation", json!(-1)); + let g = styled(shape_group("Button", 0.0, 0.0, 100.0, 50.0, vec![inner]), "fills", json!([solid_fill(1.0, 0.0, 0.0, 1.0)])); + let (doc, _) = open(vec![g]); + assert!(matches!(node(&doc, "Button").fills[0].kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0)); +} + +#[test] +fn boolean_shape_group_takes_the_groups_style() { + let a = with(rect("A", 0.0, 0.0, 50.0, 50.0), "booleanOperation", json!(-1)); + let b = with(rect("B", 10.0, 10.0, 20.0, 20.0), "booleanOperation", json!(1)); + let g = styled(shape_group("Ring", 0.0, 0.0, 50.0, 50.0, vec![a, b]), "fills", json!([solid_fill(0.0, 1.0, 0.0, 1.0)])); + let (doc, _) = open(vec![g]); + assert_eq!(node(&doc, "Ring").fills.len(), 1); +} + +#[test] +fn gradients() { + let stops = json!([{"_class": "gradientStop", "position": 0, "color": color(1.0, 0.0, 0.0, 1.0)}, + {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 1.0, 1.0)}]); + let grad = |ty: i64, ellipse: f64| { + json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0), + "gradient": {"_class": "gradient", "gradientType": ty, "from": "{0.5, 0}", "to": "{0.5, 1}", "elipseLength": ellipse, "stops": stops.clone()}}) + }; + let (doc, _) = open(vec![ + styled(r("Linear"), "fills", json!([grad(0, 0.0)])), + styled(r("Radial"), "fills", json!([grad(1, 0.5)])), + styled(r("Angular"), "fills", json!([grad(2, 0.0)])), + ]); + let g = |name: &str| match &node(&doc, name).fills[0].kind { + PaintKind::Gradient(g) => g.clone(), + other => panic!("{name}: {other:?}"), + }; + let lin = g("Linear"); + assert_eq!(lin.kind, GradientKind::Linear); + assert_eq!((lin.from, lin.to), ((0.5, 0.0), (0.5, 1.0))); + assert_eq!(lin.stops.len(), 2); + assert_eq!(lin.stops[1].color.b, 1.0); + assert_eq!(g("Radial").kind, GradientKind::Radial); + assert_eq!(g("Angular").kind, GradientKind::Angular); + // The radial gradient's width handle: half the length, across the main axis (in pixels). + let rad = g("Radial"); + let (dx, dy) = ((rad.width.0 - rad.from.0) * 100.0, (rad.width.1 - rad.from.1) * 50.0); + assert!(((dx * dx + dy * dy).sqrt() - 25.0).abs() < 1e-6, "{rad:?}"); +} + +#[test] +fn pattern_fill_modes() { + let png = { + let mut out = Vec::new(); + image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + }; + let pat = |mode: i64| { + json!({"_class": "fill", "isEnabled": true, "fillType": 4, "patternFillType": mode, "patternTileScale": 1, + "image": {"_class": "MSJSONFileReference", "_ref": "images/p1.png"}}) + }; + let layers = (0..4).map(|m| styled(r(&format!("M{m}")), "fills", json!([pat(m)]))).collect(); + let (doc, _) = open_doc(document(&["P"]), layers, &[("images/p1.png", &png)]); + let mode = |name: &str| match &node(&doc, name).fills[0].kind { + PaintKind::Image(i) => i.scale_mode, + other => panic!("{other:?}"), + }; + assert_eq!(mode("M0"), ImageScaleMode::Tile); + assert_eq!(mode("M1"), ImageScaleMode::Fill); + assert_eq!(mode("M2"), ImageScaleMode::Crop); + assert_eq!(mode("M3"), ImageScaleMode::Fit); + assert_eq!(doc.images.len(), 1); +} + +#[test] +fn noise_fill_warns() { + let noise = json!({"_class": "fill", "isEnabled": true, "fillType": 5, "noiseIntensity": 0.3}); + let (_, warnings) = open(vec![styled(r("Noisy"), "fills", json!([noise]))]); + assert!(warnings.iter().any(|w| w.contains("noise")), "{warnings:?}"); +} + +#[test] +fn borders() { + let border = |pos: i64, t: f64| json!({"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": pos, "thickness": t}); + let opts = json!({"_class": "borderOptions", "isEnabled": true, "dashPattern": [4, 2], "lineCapStyle": 1, "lineJoinStyle": 2}); + let (doc, warnings) = open(vec![ + styled(r("Center"), "borders", json!([border(0, 2.0)])), + styled(styled(r("Inside"), "borders", json!([border(1, 3.0)])), "borderOptions", opts), + styled(r("Outside"), "borders", json!([border(2, 1.0)])), + styled(r("Two"), "borders", json!([border(1, 1.0), border(1, 5.0)])), + ]); + let s = |n: &str| node(&doc, n).stroke.clone(); + assert_eq!((s("Center").align, s("Center").weight), (StrokeAlign::Center, 2.0)); + assert_eq!((s("Inside").align, s("Inside").weight), (StrokeAlign::Inside, 3.0)); + assert_eq!(s("Inside").dashes, vec![4.0, 2.0]); + assert_eq!((s("Inside").cap_start, s("Inside").join), (StrokeCap::Round, StrokeJoin::Bevel)); + assert_eq!(s("Outside").align, StrokeAlign::Outside); + assert_eq!(node(&doc, "Two").strokes.len(), 2); + assert_eq!(s("Two").weight, 1.0); + assert!(warnings.iter().any(|w| w.contains("borders")), "{warnings:?}"); +} + +#[test] +fn shadows_and_blurs() { + let shadow = json!({"_class": "shadow", "isEnabled": true, "blurRadius": 8, "offsetX": 2, "offsetY": 4, "spread": 1, "color": color(0.0, 0.0, 0.0, 0.5), + "contextSettings": {"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 1}}); + let mut off = shadow.clone(); + off["isEnabled"] = json!(false); + let blur = |ty: i64| json!({"_class": "blur", "isEnabled": true, "radius": 6, "type": ty}); + let (doc, warnings) = open(vec![ + styled(styled(r("Shadowed"), "shadows", json!([shadow, off])), "innerShadows", json!([shadow])), + styled(r("Blurred"), "blur", blur(0)), + styled(r("Frosted"), "blur", blur(3)), + styled(r("Moving"), "blur", blur(1)), + ]); + let fx = &node(&doc, "Shadowed").effects; + assert_eq!(fx.len(), 3); + match &fx[0].kind { + EffectKind::DropShadow(s) => assert_eq!((s.offset, s.radius, s.spread, s.color.a), ((2.0, 4.0), 8.0, 1.0, 0.5)), + other => panic!("{other:?}"), + } + assert!(!fx[1].visible); + assert!(matches!(fx[2].kind, EffectKind::InnerShadow(_))); + assert!(matches!(node(&doc, "Blurred").effects[0].kind, EffectKind::LayerBlur(_))); + assert!(matches!(node(&doc, "Frosted").effects[0].kind, EffectKind::BackgroundBlur(_))); + assert!(node(&doc, "Moving").effects.is_empty()); + assert!(warnings.iter().any(|w| w.contains("motion")), "{warnings:?}"); +} + +#[test] +fn opacity_and_blend() { + let modes = [ + BlendMode::Normal, + BlendMode::Darken, + BlendMode::Multiply, + BlendMode::ColorBurn, + BlendMode::Lighten, + BlendMode::Screen, + BlendMode::ColorDodge, + BlendMode::Overlay, + BlendMode::SoftLight, + BlendMode::HardLight, + BlendMode::Difference, + BlendMode::Exclusion, + BlendMode::Hue, + BlendMode::Saturation, + BlendMode::Color, + BlendMode::Luminosity, + BlendMode::PlusDarker, + BlendMode::PlusLighter, + ]; + let layers = (0..modes.len()) + .map(|i| styled(r(&format!("B{i}")), "contextSettings", json!({"_class": "graphicsContextSettings", "opacity": 0.3, "blendMode": i}))) + .collect(); + let (doc, _) = open(layers); + for (i, m) in modes.iter().enumerate() { + let n = node(&doc, &format!("B{i}")); + assert!((n.opacity - 0.3).abs() < 1e-6); + // A normal layer passes through like any other. + if i > 0 { + assert_eq!(n.blend, *m, "mode {i}"); + } + } +} + +#[test] +fn clipping_mask_chain() { + let mask = with(r("Mask"), "hasClippingMask", json!(true)); + let a = r("A"); + let b = with(r("B"), "shouldBreakMaskChain", json!(true)); + let c = r("C"); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 100.0, 50.0, vec![mask, a, b, c])]); + let m = node(&doc, "Mask"); + assert!(m.is_mask); + // The mask and what it clips share a group of their own, so B and C stay unclipped. + let holder = m.parent.unwrap(); + let kids: Vec<&str> = doc.node(holder).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(kids, ["Mask", "A"]); + assert_ne!(node(&doc, "B").parent, Some(holder)); + let bb = doc.world_bounds(node(&doc, "A").id).unwrap(); + assert_eq!((bb.x0, bb.y0), (0.0, 0.0)); +} + +#[test] +fn display_p3_document() { + let mut d = document(&["P"]); + d["colorSpace"] = json!(2); + let (doc, _) = open_doc(d, vec![], &[]); + assert_eq!(doc.color_profile, ColorProfile::DisplayP3); +} + +#[test] +fn style_on_frames_and_text_kinds_untouched() { + // An artboard keeps its background when its style has no fills. + let (doc, _) = open(vec![]); + assert!(matches!(node(&doc, "Board").kind, NodeKind::Frame)); + assert_eq!(node(&doc, "Board").fills.len(), 1); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 89a43010..303be326 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -50,7 +50,7 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { node.insert("type".into(), json!("CANVAS")); node.insert("id".into(), json!(id)); node.insert("name".into(), json!(str_field(page, "name").unwrap_or("Page"))); - let kids: Vec = layers(page).iter().filter_map(|l| layer(&mut cx, l, Affine::IDENTITY, 0, true)).collect(); + let kids = children(&mut cx, page, Affine::IDENTITY, (0.0, 0.0), 0, true); node.insert("children".into(), Value::Array(kids)); canvases.push(Value::Object(node)); } @@ -95,6 +95,10 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: let shape = super::path::shape(cx, l, w, h)?; super::path::put_shape(cx, shape, &mut node); } + if bool_field(l, "hasClippingMask") == Some(true) { + node.insert("isMask".into(), json!(true)); + node.insert("maskType".into(), json!(if l.get("clippingMaskMode").and_then(Value::as_i64) == Some(1) { "ALPHA" } else { "VECTOR" })); + } match class { "artboard" | "frame" => { let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) }; @@ -115,8 +119,11 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: }, _ => {} } + if let Some(st) = l.get("style") { + super::style::apply(cx, st, w, h, &mut node); + } if matches!(ty, "FRAME" | "GROUP") { - let kids: Vec = layers(l).iter().filter_map(|c| layer(cx, c, world, depth + 1, false)).collect(); + let kids = children(cx, l, world, (w, h), depth, false); node.insert("children".into(), Value::Array(kids)); } Some(Value::Object(node)) @@ -224,6 +231,48 @@ fn clip(s: &str) -> String { s.chars().take(64).collect() } +/// The child layers of `l` (placed at `world`, `size`) as REST nodes. A clipping mask clips the +/// layers after it up to one that breaks the chain (or the next mask); when that happens before +/// the end, the mask and what it clips are wrapped in a group of their own, since a FigCraft mask +/// clips everything above it. +pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), depth: usize, root: bool) -> Vec { + let built: Vec<(bool, Value)> = layers(l) + .iter() + .filter_map(|c| { + let breaks = bool_field(c, "shouldBreakMaskChain") == Some(true) || bool_field(c, "hasClippingMask") == Some(true); + layer(cx, c, world, depth + 1, root).map(|n| (breaks, n)) + }) + .collect(); + let mut out = Vec::new(); + let mut i = 0; + while let Some((_, n)) = built.get(i) { + let end = if n.get("isMask").and_then(Value::as_bool) == Some(true) { + built.iter().skip(i + 1).position(|(b, _)| *b).map(|k| i + 1 + k) + } else { + None + }; + let Some(end) = end else { + out.push(n.clone()); + i += 1; + continue; + }; + let masked: Vec = built.get(i..end).unwrap_or_default().iter().map(|(_, n)| n.clone()).collect(); + let mut g = JsonMap::new(); + g.insert("type".into(), json!("GROUP")); + g.insert("name".into(), json!("Mask group")); + g.insert("relativeTransform".into(), json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])); + g.insert("size".into(), json!({"x": size.0, "y": size.1})); + if !root { + let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, size.0, size.1)); + g.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()})); + } + g.insert("children".into(), Value::Array(masked)); + out.push(Value::Object(g)); + i = end; + } + out +} + /// A text layer whose string is a binary property list (Sketch 43–47). fn is_archived_text(l: &Value) -> bool { l.get("attributedString").is_some_and(|a| a.get("archivedAttributedString").is_some()) From 8f32e4f504720d7959d98c9299372fe4168aec0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:09:08 +0200 Subject: [PATCH 10/87] M6: Sketch import: shape groups that are clipping masks clip Sketch 51 and older files make masks from shape groups; the mask flag was lost when the group became its shape or boolean operation, so a masked map spilled out of its card. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/path.rs | 2 ++ crates/format/src/sketch/style_tests.rs | 15 +++++++++++++++ crates/format/src/sketch/tree.rs | 13 +++++++++---- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 47a8f2d3..6ebb1a75 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -143,6 +143,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, super::style::clear(m); super::style::apply(cx, st, cw, ch, m); } + super::tree::mask(l, m); } return Some(node); } @@ -166,6 +167,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, root); node.insert("booleanOperation".into(), json!(op)); node.insert("children".into(), Value::Array(chain)); + super::tree::mask(l, &mut node); if let Some(st) = l.get("style") { super::style::apply(cx, st, w, h, &mut node); } diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index 81b9f774..6bc02bd8 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -233,3 +233,18 @@ fn style_on_frames_and_text_kinds_untouched() { assert!(matches!(node(&doc, "Board").kind, NodeKind::Frame)); assert_eq!(node(&doc, "Board").fills.len(), 1); } + +#[test] +fn shape_group_masks_clip() { + // Sketch 51 and older: the mask is a shape group. + let inner = with(r("Outline"), "booleanOperation", json!(-1)); + let mask = with(shape_group("Card", 0.0, 0.0, 100.0, 50.0, vec![inner]), "hasClippingMask", json!(true)); + let a = rect("A", 0.0, 0.0, 300.0, 300.0); + let b = with(r("B"), "shouldBreakMaskChain", json!(true)); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 300.0, 300.0, vec![mask, a, b])]); + let m = node(&doc, "Card"); + assert!(m.is_mask); + let holder = doc.node(m.parent.unwrap()).unwrap(); + let kids: Vec<&str> = holder.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(kids, ["Card", "A"]); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 303be326..bfa59455 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -95,10 +95,7 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: let shape = super::path::shape(cx, l, w, h)?; super::path::put_shape(cx, shape, &mut node); } - if bool_field(l, "hasClippingMask") == Some(true) { - node.insert("isMask".into(), json!(true)); - node.insert("maskType".into(), json!(if l.get("clippingMaskMode").and_then(Value::as_i64) == Some(1) { "ALPHA" } else { "VECTOR" })); - } + mask(l, &mut node); match class { "artboard" | "frame" => { let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) }; @@ -129,6 +126,14 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: Some(Value::Object(node)) } +/// A clipping mask's flag and kind (outline or alpha). +pub(crate) fn mask(l: &Value, node: &mut JsonMap) { + if bool_field(l, "hasClippingMask") == Some(true) { + node.insert("isMask".into(), json!(true)); + node.insert("maskType".into(), json!(if l.get("clippingMaskMode").and_then(Value::as_i64) == Some(1) { "ALPHA" } else { "VECTOR" })); + } +} + /// Count one more layer; false (with a warning) once the file's budget is spent. fn count(cx: &mut Cx) -> bool { if cx.nodes >= cx.max_nodes { From ff9d966ea679803c9203bb14978e3c2df0808aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:18:34 +0200 Subject: [PATCH 11/87] M6: Sketch import: masks that show their own style draw it Sketch draws a clipping mask with its fills, borders and effects under what it clips; a FigCraft mask only clips. A plain copy of such a mask goes underneath it, so a light avatar circle with a white icon in it no longer vanishes on a white artboard. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sketch_import_tests.rs | 51 +++++++++++++++++++++++- crates/format/src/sketch/style_tests.rs | 35 ++++++++++++++++ crates/format/src/sketch/tree.rs | 26 ++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index cb3bf2f1..09959bb6 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -10,6 +10,11 @@ use crate::Session; /// A minimal `.sketch` zip with one page per `(id, name)`, saved by `app_version`. fn sketch(app_version: &str, pages: &[(&str, &str)]) -> Vec { + sketch_with(app_version, pages, &[]) +} + +/// [`sketch`] with `layers` on the first page. +fn sketch_with(app_version: &str, pages: &[(&str, &str)], layers: &[Value]) -> Vec { let refs: Vec = pages.iter().map(|(id, _)| json!({"_class": "MSJSONFileReference", "_ref": format!("pages/{id}")})).collect(); let mut out = Vec::new(); { @@ -19,9 +24,10 @@ fn sketch(app_version: &str, pages: &[(&str, &str)]) -> Vec { z.write_all(json!({"_class": "document", "pages": refs}).to_string().as_bytes()).unwrap(); z.start_file("meta.json", o).unwrap(); z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": app_version, "version": 103}).to_string().as_bytes()).unwrap(); - for (id, name) in pages { + for (i, (id, name)) in pages.iter().enumerate() { + let ls = if i == 0 { layers.to_vec() } else { Vec::new() }; z.start_file(format!("pages/{id}.json"), o).unwrap(); - z.write_all(json!({"_class": "page", "do_objectID": id, "name": name, "layers": []}).to_string().as_bytes()).unwrap(); + z.write_all(json!({"_class": "page", "do_objectID": id, "name": name, "layers": ls}).to_string().as_bytes()).unwrap(); } z.finish().unwrap(); } @@ -87,3 +93,44 @@ fn old_and_damaged_sketch_files_fail_cleanly() { assert!(s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err()); let _ = std::fs::remove_dir_all(&dir); } + +fn shape(class: &str, name: &str, frame: [f64; 4], extra: Value) -> Value { + let mut l = json!({"_class": class, "do_objectID": name, "name": name, "isVisible": true, "resizingConstraint": 63, + "frame": {"x": frame[0], "y": frame[1], "width": frame[2], "height": frame[3]}, "style": {"fills": []}}); + for (k, v) in extra.as_object().unwrap() { + l[k] = v.clone(); + } + l +} + +fn fill(grey: f64) -> Value { + json!({"style": {"fills": [{"isEnabled": true, "fillType": 0, "color": {"red": grey, "green": grey, "blue": grey, "alpha": 1}}]}}) +} + +/// Sketch draws a mask's own fill under what it clips; the clipped layers show inside it. +#[test] +fn sketch_masks_show_their_content() { + let oval = shape("oval", "Ring", [0.0, 0.0, 60.0, 60.0], json!({"booleanOperation": -1, "points": []})); + let mut mask = shape("shapeGroup", "Circle", [0.0, 0.0, 60.0, 60.0], fill(0.8)); + mask["hasClippingMask"] = json!(true); + mask["layers"] = json!([oval]); + let pts = ["{0, 0}", "{1, 0}", "{1, 1}", "{0, 1}"].map(|p| json!({"point": p, "curveFrom": p, "curveTo": p, "curveMode": 1})); + let body = shape("shapePath", "Square", [0.0, 0.0, 20.0, 20.0], json!({"points": pts, "isClosed": true, "booleanOperation": -1})); + let mut content = shape("shapeGroup", "Body", [20.0, 20.0, 20.0, 20.0], fill(0.2)); + content["layers"] = json!([body]); + let icon = shape("group", "Icon", [0.0, 0.0, 60.0, 60.0], json!({"layers": [mask, content]})); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("50.2", &[("A", "Page")], &[icon])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let icon = doc.nodes().find(|n| n.name == "Icon").unwrap().id; + let pix = figcraft_render::render_node(doc, icon, 1.0).unwrap(); + let px = |x: u32, y: u32| pix.pixel(x, y).unwrap(); + // The square in the middle, dark grey. + let c = px(30, 30); + assert!(c.alpha() > 200 && c.red() < 100, "content: {c:?}"); + // The mask's own fill around it, light grey; outside the circle nothing. + let m = px(30, 8); + assert!(m.alpha() > 200 && m.red() > 150, "mask fill: {m:?}"); + assert!(px(2, 2).alpha() < 50); +} diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index 6bc02bd8..ee428c88 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -248,3 +248,38 @@ fn shape_group_masks_clip() { let kids: Vec<&str> = holder.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); assert_eq!(kids, ["Card", "A"]); } + +#[test] +fn masked_icon_keeps_its_geometry() { + let oval = { + let mut o = with(rect("Path", 0.0, 0.0, 67.0, 67.0), "booleanOperation", json!(-1)); + o["_class"] = json!("oval"); + o + }; + let mask = styled( + with(shape_group("Circle", 0.0, 0.0, 67.0, 67.0, vec![oval]), "hasClippingMask", json!(true)), + "fills", + json!([solid_fill(0.9, 0.9, 0.9, 1.0)]), + ); + let p = |id: &str, x: f64, y: f64, w: f64, h: f64, op: i64| { + with(shape_path(id, x, y, w, h, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true), "booleanOperation", json!(op)) + }; + let content = styled( + shape_group( + "Body", + 12.0, + 12.0, + 45.0, + 62.0, + vec![p("P1", 9.0, 9.0, 22.0, 22.0, -1), p("P2", 25.0, 15.0, 20.0, 15.0, 0), p("P3", 0.0, 17.0, 31.0, 45.0, 0)], + ), + "fills", + json!([solid_fill(0.5, 0.5, 0.5, 1.0)]), + ); + let (doc, _) = open(vec![group("Icon", 152.0, 20.0, 67.0, 67.0, vec![mask, content])]); + let b = doc.world_bounds(node(&doc, "Circle").id).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (152.0, 20.0, 67.0, 67.0), "mask"); + let b = doc.world_bounds(node(&doc, "Body").id).unwrap(); + assert!((b.x0 - 164.0).abs() < 1e-6 && (b.y0 - 41.0).abs() < 1e-6 && (b.width() - 45.0).abs() < 1e-6, "body {b:?}"); + assert!(matches!(node(&doc, "Circle").kind, NodeKind::Ellipse { .. })); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index bfa59455..c59b8c90 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -247,6 +247,12 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), let breaks = bool_field(c, "shouldBreakMaskChain") == Some(true) || bool_field(c, "hasClippingMask") == Some(true); layer(cx, c, world, depth + 1, root).map(|n| (breaks, n)) }) + .flat_map(|(breaks, n)| match drawn_mask(&n) { + // Sketch also draws a mask with its own style, under what it clips; a FigCraft mask + // only clips, so a plain copy of it goes underneath. + Some(copy) => vec![(false, copy), (breaks, n)], + None => vec![(breaks, n)], + }) .collect(); let mut out = Vec::new(); let mut i = 0; @@ -278,6 +284,26 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), out } +/// A plain copy of mask `n` when its style shows something (fills, borders or effects). +fn drawn_mask(n: &Value) -> Option { + if n.get("isMask").and_then(Value::as_bool) != Some(true) { + return None; + } + let shows = + |key: &str| n.get(key).and_then(Value::as_array).is_some_and(|l| l.iter().any(|p| p.get("visible").and_then(Value::as_bool) != Some(false))); + if !(shows("fills") || shows("strokes") || shows("effects")) { + return None; + } + let mut copy = n.clone(); + let m = copy.as_object_mut()?; + m.remove("isMask"); + m.remove("maskType"); + m.remove("id"); + let name = m.get("name").and_then(Value::as_str).unwrap_or("Mask").to_string(); + m.insert("name".into(), json!(format!("{name} (drawn)"))); + Some(copy) +} + /// A text layer whose string is a binary property list (Sketch 43–47). fn is_archived_text(l: &Value) -> bool { l.get("attributedString").is_some_and(|a| a.get("archivedAttributedString").is_some()) From a0542e581c57de857fac34ca6c9c21829dcd6d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:23:30 +0200 Subject: [PATCH 12/87] M6: Sketch import: text with styled runs and font names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text layers keep their string and styled runs (font, size, colour, line height, letter spacing, decorations, text transform; ranges in UTF-16 units, clamped), paragraph alignment and spacing, vertical alignment and how the box sizes. Fonts named by PostScript name become family and style; the engine then matches families to installed ones ignoring spaces (PingFangSC → PingFang SC). Fonts embedded in newer files are named in the import report. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 35 +++++ crates/engine/src/sketch_import_tests.rs | 11 ++ crates/format/src/fig/mod.rs | 2 +- crates/format/src/sketch/fixture.rs | 19 +++ crates/format/src/sketch/mod.rs | 19 +++ crates/format/src/sketch/text.rs | 165 ++++++++++++++++++++ crates/format/src/sketch/text_tests.rs | 188 +++++++++++++++++++++++ crates/format/src/sketch/tree.rs | 4 + 8 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 crates/format/src/sketch/text.rs create mode 100644 crates/format/src/sketch/text_tests.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 4f8802b7..179f1c06 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -558,11 +558,46 @@ fn load_with( fn load_sketch(bytes: &[u8], name: &str) -> Option> { let r = figcraft_format::sketch::try_import(bytes, name)?; Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + match_font_families(&mut doc); link_instances(&mut doc); figcraft_format::Loaded { issues: warnings, ..doc.into() } })) } +/// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from +/// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and +/// case. +fn match_font_families(doc: &mut Document) { + let installed = figcraft_text::fontdb::family_names(); + let mut cache: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut fix = |style: &mut figcraft_doc::TextStyle| { + let found = cache.entry(style.font_family.clone()).or_insert_with(|| installed_family(&style.font_family, &installed)); + if let Some(f) = found { + style.font_family = f.clone(); + } + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { + fix(&mut td.style); + for r in &mut td.runs { + fix(&mut r.style); + } + } + } +} + +/// The installed family `family` means when it isn't installed under that exact name: the one +/// whose name is the same without spaces, ignoring case. +pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option { + let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::(); + if installed.iter().any(|f| f.eq_ignore_ascii_case(family)) { + return None; + } + let want = key(family); + installed.iter().find(|f| key(f) == want).cloned() +} + /// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { let r = figcraft_format::fig::try_import(bytes, name)?; diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index 09959bb6..ac35cb06 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -134,3 +134,14 @@ fn sketch_masks_show_their_content() { assert!(m.alpha() > 200 && m.red() > 150, "mask fill: {m:?}"); assert!(px(2, 2).alpha() < 50); } + +/// Family names guessed from PostScript names find the installed family whatever its spaces. +#[test] +fn sketch_font_families_match_installed_ones() { + let installed = ["PingFang SC".to_string(), "Helvetica Neue".to_string(), "IBM Plex Sans".to_string()]; + let m = |f: &str| crate::cmd::file::installed_family(f, &installed); + assert_eq!(m("Ping Fang SC").as_deref(), Some("PingFang SC")); + assert_eq!(m("Helvetica Neue"), None, "already right"); + assert_eq!(m("IBMPlex Sans").as_deref(), Some("IBM Plex Sans")); + assert_eq!(m("Comic Sans"), None); +} diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 4c103d08..8932c18a 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -115,7 +115,7 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma /// 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 { +pub(crate) 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::(); diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index dc6fefe9..a7c5ea73 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -149,3 +149,22 @@ pub(crate) fn styled(mut l: Value, key: &str, value: Value) -> Value { l["style"][key] = value; l } + +/// A text layer showing `string`, one run per `(location, length, font, size, rgba)`; locations and +/// lengths count UTF-16 code units, as Sketch does. +pub(crate) fn text(id: &str, x: f64, y: f64, w: f64, h: f64, string: &str, runs: &[(usize, usize, &str, f64, [f64; 4])]) -> Value { + let attrs: Vec = runs + .iter() + .map(|(loc, len, font, size, [r, g, b, a])| { + json!({"_class": "stringAttribute", "location": loc, "length": len, "attributes": { + "MSAttributedStringFontAttribute": {"_class": "fontDescriptor", "attributes": {"name": font, "size": size}}, + "MSAttributedStringColorAttribute": color(*r, *g, *b, *a), + "paragraphStyle": {"_class": "paragraphStyle", "alignment": 0}, + }}) + }) + .collect(); + let mut l = layer("text", id, id, x, y, w, h); + l["attributedString"] = json!({"_class": "attributedString", "string": string, "attributes": attrs}); + l["textBehaviour"] = json!(1); + l +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 8ab154d9..8859c4a9 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -21,6 +21,9 @@ mod style; mod style_tests; #[cfg(test)] mod tests; +mod text; +#[cfg(test)] +mod text_tests; mod tree; #[cfg(test)] mod tree_tests; @@ -81,6 +84,9 @@ pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result } let mut warnings = archive.warnings.clone(); warnings.extend(map.warnings); + if let Some(note) = embedded_fonts(&archive.document) { + warnings.push(note); + } let images = |r: &str| archive.image(r); let (mut doc, more) = crate::rest::import_with_networks_plain(&map.json, &images, map.networks, max_nodes)?; if archive.document.get("colorSpace").and_then(Value::as_i64) == Some(2) { @@ -90,6 +96,19 @@ pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result Ok((doc, warnings)) } +/// The note about fonts the file carries (Sketch 7x and newer can embed them): FigCraft doesn't +/// use fonts from inside documents, so their text shows in installed fonts. +fn embedded_fonts(document: &Value) -> Option { + let refs = document.get("fontReferences").and_then(Value::as_array)?; + let families: BTreeSet = + refs.iter().filter_map(|r| r.get("fontFamilyName").and_then(Value::as_str)).map(|f| f.chars().take(64).collect()).take(20).collect(); + if families.is_empty() { + return None; + } + let list: Vec<&str> = families.iter().map(String::as_str).collect(); + Some(format!("the file has embedded fonts FigCraft doesn't use ({}); install them to see the text as designed", list.join(", "))) +} + fn corrupt(msg: impl Into) -> FormatError { FormatError::Corrupt(format!("Sketch file: {}", msg.into())) } diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs new file mode 100644 index 00000000..edafe30e --- /dev/null +++ b/crates/format/src/sketch/text.rs @@ -0,0 +1,165 @@ +//! Sketch text layers → REST text nodes. +//! +//! A text layer's `attributedString` holds the `string` and `attributes`: runs given by `location` +//! and `length` in UTF-16 code units (like the REST importer's per-character style ids), each with +//! a font (PostScript name and size), colour, kerning, paragraph style, decorations and text +//! transform. `textBehaviour` sizes the box (0 auto width, 1 fixed width, 2 fixed); the style's +//! `textStyle` gives the vertical alignment. + +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, color}; + +/// Most characters read from one text layer (the REST importer's own limit). +const MAX_CHARS: usize = 1 << 20; +/// Most runs read from one text layer. +const MAX_RUNS: usize = 10_000; + +/// Fill `node` (a TEXT node) from text layer `l`. +pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap) { + let a = l.get("attributedString"); + let string: String = a.and_then(|a| a.get("string")).and_then(Value::as_str).unwrap_or("").chars().take(MAX_CHARS).collect(); + let units = string.encode_utf16().count(); + let runs: &[Value] = a.and_then(|a| a.get("attributes")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + if runs.len() > MAX_RUNS { + cx.warn(format!("text with more than {MAX_RUNS} styled runs keeps the first ones")); + } + let fallback = l.get("style").and_then(|s| s.get("textStyle")).and_then(|t| t.get("encodedAttributes")); + let styles: Vec<(usize, usize, Value)> = runs + .iter() + .take(MAX_RUNS) + .filter_map(|r| { + let start = usize::try_from(r.get("location")?.as_u64()?).ok()?.min(units); + let len = usize::try_from(r.get("length")?.as_u64()?).unwrap_or(usize::MAX); + Some((start, start.saturating_add(len).min(units), type_style(cx, r.get("attributes")?))) + }) + .collect(); + let base = styles.first().map(|s| s.2.clone()).or_else(|| fallback.map(|f| type_style(cx, f))).unwrap_or_else(|| type_style(cx, &json!({}))); + let mut style = base.clone(); + let mut fills = None; + if let Some(m) = style.as_object_mut() { + fills = m.remove("fills"); + // Paragraph settings are the first run's. + let paragraph = runs.first().and_then(|r| r.get("attributes")).or(fallback).and_then(|a| a.get("paragraphStyle")); + let align = match paragraph.and_then(|p| p.get("alignment")).and_then(Value::as_i64) { + Some(1) => "RIGHT", + Some(2) => "CENTER", + Some(3) => "JUSTIFIED", + _ => "LEFT", + }; + m.insert("textAlignHorizontal".into(), json!(align)); + if let Some(sp) = paragraph.and_then(|p| p.get("paragraphSpacing")).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0) { + m.insert("paragraphSpacing".into(), json!(sp.min(1e4))); + } + let vertical = l.get("style").and_then(|s| s.get("textStyle")).and_then(|t| t.get("verticalAlignment")).and_then(Value::as_i64); + m.insert( + "textAlignVertical".into(), + json!(match vertical { + Some(1) => "CENTER", + Some(2) => "BOTTOM", + _ => "TOP", + }), + ); + m.insert( + "textAutoResize".into(), + json!(match l.get("textBehaviour").and_then(Value::as_i64) { + Some(1) => "HEIGHT", + Some(2) => "NONE", + _ => "WIDTH_AND_HEIGHT", + }), + ); + } + node.insert("characters".into(), json!(string)); + node.insert("style".into(), style); + if let Some(f) = fills { + node.insert("fills".into(), f); + } + // Runs that differ from the first: a style id per UTF-16 unit. + let mut ids = vec![0u64; units]; + let mut table = JsonMap::new(); + for (start, end, s) in &styles { + if *s == base { + continue; + } + let id = table.len() as u64 + 1; + table.insert(id.to_string(), s.clone()); + for slot in ids.get_mut(*start..*end).into_iter().flatten() { + *slot = id; + } + } + if !table.is_empty() { + node.insert("characterStyleOverrides".into(), json!(ids)); + node.insert("styleOverrideTable".into(), Value::Object(table)); + } +} + +/// A run's attributes as a REST type style (with its colour as `fills`). +fn type_style(cx: &mut Cx, a: &Value) -> Value { + let font = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes")); + let ps = font.and_then(|f| f.get("name")).and_then(Value::as_str).unwrap_or(""); + let size = font.and_then(|f| f.get("size")).and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(12.0); + let (family, style) = font_name(ps); + if ps.is_empty() { + cx.warn("text without a font uses Helvetica"); + } + let mut s = json!({"fontFamily": family, "fontStyle": style, "fontSize": size.min(10_000.0)}); + let p = a.get("paragraphStyle"); + let line = |k: &str| p.and_then(|p| p.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + if let Some(lh) = line("maximumLineHeight").or_else(|| line("minimumLineHeight")) { + s["lineHeightPx"] = json!(lh.min(1e4)); + s["lineHeightUnit"] = json!("PIXELS"); + } + if let Some(k) = a.get("kerning").and_then(Value::as_f64).filter(|k| k.is_finite()) { + s["letterSpacing"] = json!(k); + } + let set = |k: &str| a.get(k).and_then(Value::as_i64).is_some_and(|v| v != 0); + s["textDecoration"] = json!(if set("underlineStyle") { + "UNDERLINE" + } else if set("strikethroughStyle") { + "STRIKETHROUGH" + } else { + "NONE" + }); + s["textCase"] = json!(match a.get("MSAttributedStringTextTransformAttribute").and_then(Value::as_i64) { + Some(1) => "UPPER", + Some(2) => "LOWER", + _ => "ORIGINAL", + }); + let c = a.get("MSAttributedStringColorAttribute").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0})); + s["fills"] = json!([{"type": "SOLID", "color": c}]); + s +} + +/// Family and style of a font from its PostScript name: "HelveticaNeue-Bold" → ("Helvetica Neue", +/// "Bold"). Words of the family are split at case changes, which can't always match the real name +/// ("PingFangSC" → "Ping Fang SC"); the engine matches families against the installed fonts ignoring +/// spaces. Apple's system fonts take the SF Pro names. +pub(crate) fn font_name(ps: &str) -> (String, String) { + let (family, style) = ps.split_once('-').unwrap_or((ps, "")); + let style = if style.is_empty() { "Regular".to_string() } else { crate::fig::style_name(style) }; + let family = match family { + "" => "Helvetica".to_string(), + ".SFNSText" | ".SFUIText" | "SanFranciscoText" | ".SFProText" => "SF Pro Text".into(), + ".SFNSDisplay" | ".SFUIDisplay" | "SanFranciscoDisplay" | ".SFProDisplay" => "SF Pro Display".into(), + f => words(f.trim_start_matches('.')), + }; + (family, style) +} + +/// "IBMPlexSans" → "IBM Plex Sans": a space before a capital that follows a small letter, and +/// before the last capital of a run of capitals followed by a small letter. +fn words(s: &str) -> String { + let chars: Vec = s.chars().collect(); + let mut out = String::with_capacity(s.len() + 4); + for (i, c) in chars.iter().enumerate() { + let prev = i.checked_sub(1).and_then(|p| chars.get(p)); + let next = chars.get(i + 1); + let split = c.is_uppercase() + && (prev.is_some_and(|p| p.is_lowercase()) || (prev.is_some_and(|p| p.is_uppercase()) && next.is_some_and(|n| n.is_lowercase()))); + if split { + out.push(' '); + } + out.push(*c); + } + out +} diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs new file mode 100644 index 00000000..bde9dcef --- /dev/null +++ b/crates/format/src/sketch/text_tests.rs @@ -0,0 +1,188 @@ +//! Sketch text layers → text with styled runs; font PostScript names → family and style. + +use figcraft_doc::color::PaintKind; +use figcraft_doc::{Document, LetterSpacing, LineHeight, NodeKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; +use super::text::font_name; + +const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0]; +const RED: [f64; 4] = [1.0, 0.0, 0.0, 1.0]; + +fn open(layers: Vec) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], &[]); + import(&z, "f").unwrap() +} + +fn data<'d>(doc: &'d Document, name: &str) -> &'d TextData { + match &doc.nodes().find(|n| n.name == name).unwrap().kind { + NodeKind::Text(td) => td, + other => panic!("{name}: {other:?}"), + } +} + +fn red(fills: &Option>) -> bool { + fills.as_ref().is_some_and(|f| f.iter().any(|p| matches!(p.kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0))) +} + +#[test] +fn two_runs() { + let t = text("Hi", 0.0, 0.0, 200.0, 20.0, "Hello world", &[(0, 5, "HelveticaNeue-Bold", 16.0, RED), (5, 6, "HelveticaNeue", 12.0, BLACK)]); + let (doc, _) = open(vec![t]); + let td = data(&doc, "Hi"); + assert_eq!(td.characters, "Hello world"); + let style_at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(&td.style, |r| &r.style); + let (a, b) = (style_at(0), style_at(7)); + assert_eq!((a.font_family.as_str(), a.font_style.as_str(), a.font_size), ("Helvetica Neue", "Bold", 16.0)); + assert_eq!((b.font_family.as_str(), b.font_style.as_str(), b.font_size), ("Helvetica Neue", "Regular", 12.0)); + let node = doc.nodes().find(|n| n.name == "Hi").unwrap(); + assert!(red(&a.fills) || (red(&Some(node.fills.clone())) && !red(&b.fills))); +} + +#[test] +fn utf16_ranges() { + // "a😀b": the emoji takes two UTF-16 units. + let t = text( + "Emoji", + 0.0, + 0.0, + 200.0, + 20.0, + "a😀b", + &[(0, 1, "Helvetica", 10.0, BLACK), (1, 2, "Helvetica", 20.0, BLACK), (3, 1, "Helvetica", 30.0, BLACK)], + ); + let (doc, _) = open(vec![t]); + let td = data(&doc, "Emoji"); + let size_at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(td.style.font_size, |r| r.style.font_size); + assert_eq!((size_at(0), size_at(1), size_at(2)), (10.0, 20.0, 30.0)); + // A range splitting the emoji and one past the end: no panic, the text stays whole. + let t = text( + "Split", + 0.0, + 50.0, + 200.0, + 20.0, + "a😀b", + &[(0, 2, "Helvetica", 10.0, BLACK), (2, 99, "Helvetica", 20.0, BLACK), (500, 3, "Helvetica", 5.0, BLACK)], + ); + let (doc, _) = open(vec![t]); + assert_eq!(data(&doc, "Split").characters, "a😀b"); +} + +#[test] +fn paragraph_and_spacing() { + let mut t = text("Para", 0.0, 0.0, 200.0, 40.0, "Centered", &[(0, 8, "Helvetica", 14.0, BLACK)]); + let a = &mut t["attributedString"]["attributes"][0]["attributes"]; + a["paragraphStyle"] = + json!({"_class": "paragraphStyle", "alignment": 2, "maximumLineHeight": 24, "minimumLineHeight": 24, "paragraphSpacing": 8}); + a["kerning"] = json!(0.5); + let (doc, _) = open(vec![t]); + let td = data(&doc, "Para"); + assert_eq!(td.align_h, TextAlignH::Center); + assert_eq!(td.style.line_height, LineHeight::Pixels(24.0)); + assert_eq!(td.paragraph_spacing, 8.0); + assert_eq!(td.style.letter_spacing, LetterSpacing::Pixels(0.5)); + for (code, want) in [(0, TextAlignH::Left), (1, TextAlignH::Right), (3, TextAlignH::Justified), (4, TextAlignH::Left)] { + let mut t = text("A", 0.0, 0.0, 100.0, 20.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]); + t["attributedString"]["attributes"][0]["attributes"]["paragraphStyle"]["alignment"] = json!(code); + let (doc, _) = open(vec![t]); + assert_eq!(data(&doc, "A").align_h, want, "alignment {code}"); + } +} + +#[test] +fn behaviour_and_vertical_alignment() { + let cases = [(0, TextAutoResize::WidthAndHeight), (1, TextAutoResize::Height), (2, TextAutoResize::None)]; + for (b, want) in cases { + let mut t = text("T", 0.0, 0.0, 100.0, 20.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]); + t["textBehaviour"] = json!(b); + let (doc, _) = open(vec![t]); + assert_eq!(data(&doc, "T").auto_resize, want, "behaviour {b}"); + } + for (v, want) in [(0, TextAlignV::Top), (1, TextAlignV::Middle), (2, TextAlignV::Bottom)] { + let mut t = text("V", 0.0, 0.0, 100.0, 60.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]); + t["textBehaviour"] = json!(2); + t["style"]["textStyle"] = json!({"_class": "textStyle", "verticalAlignment": v, "encodedAttributes": {}}); + let (doc, _) = open(vec![t]); + assert_eq!(data(&doc, "V").align_v, want, "vertical {v}"); + } +} + +#[test] +fn decorations_and_case() { + let mut t = text( + "Deco", + 0.0, + 0.0, + 200.0, + 20.0, + "abcdef", + &[(0, 2, "Helvetica", 14.0, BLACK), (2, 2, "Helvetica", 14.0, BLACK), (4, 2, "Helvetica", 14.0, BLACK)], + ); + t["attributedString"]["attributes"][0]["attributes"]["underlineStyle"] = json!(1); + t["attributedString"]["attributes"][1]["attributes"]["strikethroughStyle"] = json!(1); + t["attributedString"]["attributes"][2]["attributes"]["MSAttributedStringTextTransformAttribute"] = json!(1); + let (doc, _) = open(vec![t]); + let td = data(&doc, "Deco"); + let at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(&td.style, |r| &r.style); + assert_eq!(at(0).decoration, TextDecoration::Underline); + assert_eq!(at(2).decoration, TextDecoration::Strikethrough); + assert_eq!(at(4).case, TextCase::Upper); +} + +#[test] +fn text_layer_fill_wins_over_run_colour() { + let t = styled(text("Filled", 0.0, 0.0, 100.0, 20.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]), "fills", json!([solid_fill(1.0, 0.0, 0.0, 1.0)])); + let (doc, _) = open(vec![t]); + let n = doc.nodes().find(|n| n.name == "Filled").unwrap(); + assert!(red(&Some(n.fills.clone()))); +} + +#[test] +fn font_names() { + let cases = [ + ("HelveticaNeue-Bold", "Helvetica Neue", "Bold"), + ("HelveticaNeue", "Helvetica Neue", "Regular"), + ("HelveticaNeue-Medium", "Helvetica Neue", "Medium"), + ("PingFangSC-Semibold", "Ping Fang SC", "Semi Bold"), + (".SFNSText", "SF Pro Text", "Regular"), + (".SFNSDisplay-Bold", "SF Pro Display", "Bold"), + ("SanFranciscoText-Semibold", "SF Pro Text", "Semi Bold"), + ("Impact", "Impact", "Regular"), + ("IBMPlexSans-MediumItalic", "IBM Plex Sans", "Medium Italic"), + ("Roboto-Italic", "Roboto", "Italic"), + ("", "Helvetica", "Regular"), + ]; + for (ps, family, style) in cases { + assert_eq!(font_name(ps), (family.to_string(), style.to_string()), "{ps}"); + } +} + +#[test] +fn long_and_odd_text_is_safe() { + let long = "x".repeat(10_000); + let t = text("Long", 0.0, 0.0, 100.0, 20.0, &long, &[(0, usize::MAX >> 1, "Helvetica", 14.0, BLACK)]); + let mut odd = text("Odd", 0.0, 0.0, 100.0, 20.0, "abc", &[(0, 3, "Helvetica", -5.0, BLACK)]); + odd["attributedString"]["attributes"][0]["attributes"]["MSAttributedStringFontAttribute"] = json!("junk"); + let mut empty = text("Empty", 0.0, 0.0, 100.0, 20.0, "", &[]); + empty["attributedString"]["string"] = json!(5); + let (doc, _) = open(vec![t, odd, empty]); + assert_eq!(data(&doc, "Long").characters.len(), 10_000); + assert!(data(&doc, "Odd").style.font_size > 0.0); +} + +#[test] +fn embedded_fonts_are_reported() { + let mut d = document(&["P"]); + d["fontReferences"] = json!([ + {"_class": "fontReference", "fontFamilyName": "IBM Plex Sans", "fontFileName": "IBMPlexSans-Regular.otf", "fontData": {"_ref": "fonts/abc"}}, + {"_class": "fontReference", "fontFamilyName": "IBM Plex Sans", "fontFileName": "IBMPlexSans-Bold.otf", "fontData": {"_ref": "fonts/def"}}, + {"_class": "fontReference", "fontFamilyName": "IBM Plex Mono", "fontData": {"_ref": "fonts/ghi"}}, + ]); + let z = sketch_zip("83.2", 139, d, &[page("P", "Page", vec![])], &[("fonts/abc", b"x")]); + let (_, warnings) = import(&z, "f").unwrap(); + let w = warnings.iter().find(|w| w.contains("embedded")).expect("a note about embedded fonts"); + assert!(w.contains("IBM Plex Mono") && w.contains("IBM Plex Sans"), "{w}"); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index c59b8c90..16382dbe 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -80,6 +80,7 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: "oval" => "ELLIPSE", "slice" => "SLICE", "shapePath" | "star" | "polygon" | "triangle" => "VECTOR", + "text" => "TEXT", other => { cx.warn(format!("Sketch {} layers are not supported yet and were skipped", clip(other))); return None; @@ -91,6 +92,9 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: let (w, h) = size(l); let world = parent * local(l, w, h); let mut node = base(l, ty, parent, world, w, h, root); + if ty == "TEXT" { + super::text::node(cx, l, &mut node); + } if ty == "VECTOR" { let shape = super::path::shape(cx, l, w, h)?; super::path::put_shape(cx, shape, &mut node); From 60cc184ab7ebb3b0a23e83c3ea6f2b0d816cddf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:28:16 +0200 Subject: [PATCH 13/87] M6: Sketch import: PingFang families keep their own names Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/text.rs | 6 ++++-- crates/format/src/sketch/text_tests.rs | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index edafe30e..74e7a731 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -132,8 +132,8 @@ fn type_style(cx: &mut Cx, a: &Value) -> Value { /// Family and style of a font from its PostScript name: "HelveticaNeue-Bold" → ("Helvetica Neue", /// "Bold"). Words of the family are split at case changes, which can't always match the real name -/// ("PingFangSC" → "Ping Fang SC"); the engine matches families against the installed fonts ignoring -/// spaces. Apple's system fonts take the SF Pro names. +/// (a CJK family would split as "Ping Fang SC"); the engine matches families against the installed +/// fonts ignoring spaces. Apple's system fonts take the SF Pro names, PingFang its own. pub(crate) fn font_name(ps: &str) -> (String, String) { let (family, style) = ps.split_once('-').unwrap_or((ps, "")); let style = if style.is_empty() { "Regular".to_string() } else { crate::fig::style_name(style) }; @@ -141,6 +141,8 @@ pub(crate) fn font_name(ps: &str) -> (String, String) { "" => "Helvetica".to_string(), ".SFNSText" | ".SFUIText" | "SanFranciscoText" | ".SFProText" => "SF Pro Text".into(), ".SFNSDisplay" | ".SFUIDisplay" | "SanFranciscoDisplay" | ".SFProDisplay" => "SF Pro Display".into(), + // Apple's CJK families whose names don't split at case changes. + "PingFangSC" | "PingFangTC" | "PingFangHK" => format!("PingFang {}", family.get(8..).unwrap_or("")), f => words(f.trim_start_matches('.')), }; (family, style) diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs index bde9dcef..a7ee96b0 100644 --- a/crates/format/src/sketch/text_tests.rs +++ b/crates/format/src/sketch/text_tests.rs @@ -146,7 +146,8 @@ fn font_names() { ("HelveticaNeue-Bold", "Helvetica Neue", "Bold"), ("HelveticaNeue", "Helvetica Neue", "Regular"), ("HelveticaNeue-Medium", "Helvetica Neue", "Medium"), - ("PingFangSC-Semibold", "Ping Fang SC", "Semi Bold"), + ("PingFangSC-Semibold", "PingFang SC", "Semi Bold"), + ("PingFangTC-Regular", "PingFang TC", "Regular"), (".SFNSText", "SF Pro Text", "Regular"), (".SFNSDisplay-Bold", "SF Pro Display", "Bold"), ("SanFranciscoText-Semibold", "SF Pro Text", "Semi Bold"), From 4437d49a7250ddbb312adf435084bcc36c9053e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:36:10 +0200 Subject: [PATCH 14/87] M6: Sketch import: symbols as components, instances with overrides Symbol masters become components, also those copied from libraries (on a page of their own); instances carry their master's layers with their overrides applied (text, nested symbol swaps and hiding, images, layer and text styles, nested paths) and resize them by their constraints, so editing a master still updates every instance. A symbol inside itself stops with a note; instances of missing symbols are empty frames. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + crates/engine/src/sketch_import_tests.rs | 30 +++ crates/format/Cargo.toml | 1 + crates/format/src/sketch/fixture.rs | 18 ++ crates/format/src/sketch/mod.rs | 37 ++- crates/format/src/sketch/symbols.rs | 298 ++++++++++++++++++++++ crates/format/src/sketch/symbols_tests.rs | 165 ++++++++++++ crates/format/src/sketch/tree.rs | 33 ++- 8 files changed, 579 insertions(+), 5 deletions(-) create mode 100644 crates/format/src/sketch/symbols.rs create mode 100644 crates/format/src/sketch/symbols_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 459c8320..0d118fda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1579,6 +1579,7 @@ dependencies = [ "thiserror 2.0.21", "wat", "web-time", + "zip", ] [[package]] @@ -1586,6 +1587,7 @@ name = "figcraft-format" version = "0.9.0" dependencies = [ "figcraft-doc", + "figcraft-layout", "figcraft-pathops", "flate2", "image", diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index ac35cb06..10a7b9ca 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -6,6 +6,8 @@ use std::io::Write; use base64::Engine; use serde_json::{Value, json}; +use figcraft_doc::NodeKind; + use crate::Session; /// A minimal `.sketch` zip with one page per `(id, name)`, saved by `app_version`. @@ -145,3 +147,31 @@ fn sketch_font_families_match_installed_ones() { assert_eq!(m("IBMPlex Sans").as_deref(), Some("IBM Plex Sans")); assert_eq!(m("Comic Sans"), None); } + +/// Editing a master changes its instances, and a resized instance's layout isn't an override. +#[test] +fn sketch_instances_stay_live() { + let tag = shape("rectangle", "Tag", [80.0, 0.0, 20.0, 40.0], json!({"resizingConstraint": 60, "points": []})); + let master = shape("symbolMaster", "Bar", [0.0, 0.0, 100.0, 40.0], json!({"symbolID": "S", "layers": [tag]})); + let inst = shape("symbolInstance", "Wide", [0.0, 100.0, 200.0, 40.0], json!({"symbolID": "S", "overrideValues": []})); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("50.2", &[("A", "Page")], &[master, inst])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let find = |s: &Session, inst: bool| { + let doc = s.doc().unwrap(); + let root = doc + .nodes() + .find(|n| if inst { matches!(n.kind, NodeKind::Instance { .. }) } else { matches!(n.kind, NodeKind::Component { .. }) }) + .unwrap() + .id; + doc.subtree(root).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Tag")).unwrap() + }; + let (main_tag, inst_tag) = (find(&s, false), find(&s, true)); + assert!(s.doc().unwrap().node(inst_tag).unwrap().overrides.is_empty(), "{:?}", s.doc().unwrap().node(inst_tag).unwrap().overrides); + s.execute("node.set", &json!({"id": main_tag, "props": {"fill": "#ff0000"}})).unwrap(); + let doc = s.doc().unwrap(); + let fill = &doc.node(inst_tag).unwrap().fills; + assert!(fill.iter().any(|p| matches!(p.kind, figcraft_doc::color::PaintKind::Solid { color } if color.r > 0.99 && color.g < 0.01)), "{fill:?}"); + let b = doc.world_bounds(inst_tag).unwrap(); + assert!((b.x0 - 180.0).abs() < 1e-6, "{b:?}"); +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 3dca3912..80a943e2 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] figcraft-doc = { workspace = true } figcraft-pathops = { workspace = true } +figcraft-layout = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } zip = { workspace = true } diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index a7c5ea73..ac426f0e 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -168,3 +168,21 @@ pub(crate) fn text(id: &str, x: f64, y: f64, w: f64, h: f64, string: &str, runs: l["textBehaviour"] = json!(1); l } + +/// A symbol master `symbol_id` holding `layers`. +pub(crate) fn master(id: &str, symbol_id: &str, name: &str, w: f64, h: f64, layers: Vec) -> Value { + let mut l = artboard(id, name, 0.0, 0.0, w, h, layers); + l["_class"] = json!("symbolMaster"); + l["symbolID"] = json!(symbol_id); + l +} + +/// An instance of symbol `symbol_id` with `overrides` (`overrideName`, value). +pub(crate) fn instance(id: &str, symbol_id: &str, x: f64, y: f64, w: f64, h: f64, overrides: &[(&str, Value)]) -> Value { + let mut l = layer("symbolInstance", id, id, x, y, w, h); + l["symbolID"] = json!(symbol_id); + l["scale"] = json!(1); + l["overrideValues"] = + Value::Array(overrides.iter().map(|(name, value)| json!({"_class": "overrideValue", "overrideName": name, "value": value})).collect()); + l +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 8859c4a9..b9063a70 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -19,6 +19,9 @@ mod path_tests; mod style; #[cfg(test)] mod style_tests; +mod symbols; +#[cfg(test)] +mod symbols_tests; #[cfg(test)] mod tests; mod text; @@ -68,16 +71,26 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option Result<(Document, Vec), FormatError> { - import_within(bytes, name, MAX_JSON_MEMBER) + import_parts(bytes, name, MAX_JSON_MEMBER, crate::fig::layer_budget(bytes.len())) } /// [`import`] reading pages of at most `max_member` bytes of JSON each. +#[cfg(test)] pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result<(Document, Vec), FormatError> { + import_parts(bytes, name, max_member, crate::fig::layer_budget(bytes.len())) +} + +/// [`import`] with at most `max_nodes` layers. +#[cfg(test)] +pub(crate) fn import_within_nodes(bytes: &[u8], name: &str, max_nodes: usize) -> Result<(Document, Vec), FormatError> { + import_parts(bytes, name, MAX_JSON_MEMBER, max_nodes) +} + +fn import_parts(bytes: &[u8], name: &str, max_member: u64, max_nodes: usize) -> Result<(Document, Vec), FormatError> { if bytes.starts_with(b"SQLite format 3\0") { return Err(FormatError::Io(TOO_OLD.into())); } let archive = Archive::open(bytes, max_member)?; - let max_nodes = crate::fig::layer_budget(bytes.len()); let map = tree::to_rest(&archive, name, max_nodes); if map.too_old { return Err(FormatError::Io(TOO_OLD.into())); @@ -92,6 +105,7 @@ pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result if archive.document.get("colorSpace").and_then(Value::as_i64) == Some(2) { doc.color_profile = figcraft_doc::color::ColorProfile::DisplayP3; } + fit_instances(&mut doc); warnings.extend(more); Ok((doc, warnings)) } @@ -109,6 +123,25 @@ fn embedded_fonts(document: &Value) -> Option { Some(format!("the file has embedded fonts FigCraft doesn't use ({}); install them to see the text as designed", list.join(", "))) } +/// Instances come in with their master's layers laid out at the master's size; resized ones lay +/// them out for their own size by their constraints, innermost first. +fn fit_instances(doc: &mut Document) { + let mut insts: Vec<(usize, figcraft_doc::NodeId, f64, f64)> = doc + .nodes() + .filter_map(|n| match n.kind { + figcraft_doc::NodeKind::Instance { component, .. } => { + let c = doc.node(component)?; + Some((doc.ancestors(n.id).len(), n.id, c.width, c.height)) + } + _ => None, + }) + .collect(); + insts.sort_by_key(|i| std::cmp::Reverse(i.0)); + for (_, id, w, h) in insts { + figcraft_layout::apply_constraints(doc, id, w, h); + } +} + fn corrupt(msg: impl Into) -> FormatError { FormatError::Corrupt(format!("Sketch file: {}", msg.into())) } diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs new file mode 100644 index 00000000..d6e7165c --- /dev/null +++ b/crates/format/src/sketch/symbols.rs @@ -0,0 +1,298 @@ +//! Sketch symbols → REST components and instances. +//! +//! A `symbolMaster` (on any page, or copied from a library into `document.foreignSymbols`) is a +//! component; a `symbolInstance` names it by `symbolID`. REST instances carry their layers, so an +//! instance is its master's layers with the instance's `overrideValues` applied, laid out at the +//! master's size (the import then resizes them to the instance by their constraints). An override +//! is named `_`: `path` is the `do_objectID` of a layer of the master, or of +//! nested instances and then a layer of theirs, joined by `/`; properties: `stringValue` (text), +//! `symbolID` (swap a nested instance; empty hides it), `image`, `layerStyle` and `textStyle` +//! (shared style ids). + +use std::collections::BTreeMap; + +use figcraft_doc::geom::Affine; +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, base, bool_field, children, local, size, str_field}; + +/// Most masters read from one file. +const MAX_MASTERS: usize = 100_000; + +/// Every symbol master of a file by `symbolID`, and the library ones no page holds. +#[derive(Default)] +pub(crate) struct Symbols { + masters: BTreeMap, + /// Masters copied from libraries that no page holds (they get a page of their own), with the + /// libraries' names. + pub foreign: Vec, + pub libraries: Vec, +} + +impl Symbols { + /// The masters of `pages` and of the document's library copies. + pub(crate) fn read(document: &Value, pages: &[(String, Value)]) -> Symbols { + let mut s = Symbols::default(); + let mut found = Vec::new(); + for (_, p) in pages { + masters_in(p, &mut found, 0); + } + for m in found { + s.add(m.clone()); + } + for f in document.get("foreignSymbols").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() { + let Some(m) = f.get("symbolMaster") else { continue }; + let Some(id) = str_field(m, "symbolID") else { continue }; + if s.masters.contains_key(id) { + continue; + } + if let Some(lib) = str_field(f, "sourceLibraryName") + && !s.libraries.iter().any(|l| l == lib) + { + s.libraries.push(lib.chars().take(64).collect()); + } + s.add(m.clone()); + s.foreign.push(m.clone()); + } + s + } + + fn add(&mut self, m: Value) { + if self.masters.len() >= MAX_MASTERS { + return; + } + if let Some(id) = str_field(&m, "symbolID") { + self.masters.entry(id.to_string()).or_insert(m); + } + } + + fn master(&self, symbol_id: &str) -> Option<&Value> { + self.masters.get(symbol_id) + } +} + +fn masters_in<'v>(l: &'v Value, out: &mut Vec<&'v Value>, depth: usize) { + if depth > 64 { + return; + } + for c in super::tree::layers(l) { + if str_field(c, "_class") == Some("symbolMaster") { + out.push(c); + } else { + masters_in(c, out, depth + 1); + } + } +} + +/// A master's background as REST fills (masters show one only when they have it on). +pub(crate) fn master_fills(m: &Value) -> Option { + if bool_field(m, "hasBackgroundColor") != Some(true) { + return None; + } + let c = m.get("backgroundColor").and_then(super::tree::color)?; + Some(json!([{"type": "SOLID", "color": c}])) +} + +/// A symbol master as a COMPONENT node. +pub(crate) fn component(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + let (w, h) = size(l); + let world = parent * local(l, w, h); + let mut node = base(l, "COMPONENT", parent, world, w, h, root); + node.insert("clipsContent".into(), json!(true)); + if let Some(f) = master_fills(l) { + node.insert("fills".into(), f); + } + let sid = str_field(l, "symbolID").unwrap_or("").to_string(); + cx.chain.push(sid); + let kids = children(cx, l, world, (w, h), depth, false); + cx.chain.pop(); + node.insert("children".into(), Value::Array(kids)); + Some(Value::Object(node)) +} + +/// A symbol instance as an INSTANCE node holding its master's layers with its overrides, or an +/// empty frame when its master isn't in the file. +pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + let (w, h) = size(l); + let world = parent * local(l, w, h); + let sid = str_field(l, "symbolID").unwrap_or(""); + let Some(m) = cx.symbols.master(sid).cloned() else { + cx.warn("instances of symbols that aren't in the file were imported as empty frames"); + let mut node = base(l, "FRAME", parent, world, w, h, root); + node.insert("children".into(), json!([])); + return Some(Value::Object(node)); + }; + if cx.chain.iter().any(|c| c == sid) { + let name: String = str_field(&m, "name").unwrap_or("").chars().take(64).collect(); + cx.warn(format!("symbol {name} contains itself; the inner copy was left out")); + return None; + } + if l.get("scale").and_then(Value::as_f64).is_some_and(|s| s.is_finite() && (s - 1.0).abs() > 1e-9) { + cx.warn("scaled symbol instances were imported at their size, unscaled"); + } + let mut node = base(l, "INSTANCE", parent, world, w, h, root); + node.insert("componentId".into(), json!(str_field(&m, "do_objectID").unwrap_or(sid))); + node.insert("clipsContent".into(), json!(true)); + if let Some(f) = master_fills(&m) { + node.insert("fills".into(), f); + } + if let Some(st) = l.get("style") { + super::style::apply(cx, st, w, h, &mut node); + } + let overrides = overrides(l); + let mut copy = m; + if let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) { + apply(layers, &overrides, cx.document, 0); + } + let (mw, mh) = size(©); + cx.chain.push(sid.to_string()); + let mut kids = children(cx, ©, world, (mw, mh), depth, false); + cx.chain.pop(); + let prefix = str_field(l, "do_objectID").unwrap_or("I").to_string(); + for k in &mut kids { + prefix_ids(k, &prefix, 0); + } + node.insert("children".into(), Value::Array(kids)); + Some(Value::Object(node)) +} + +/// An instance's overrides by name (`_` → value). +fn overrides(l: &Value) -> BTreeMap { + l.get("overrideValues") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .take(100_000) + .filter_map(|o| Some((str_field(o, "overrideName")?.to_string(), o.get("value")?.clone()))) + .collect() +} + +/// Apply `ov` to the layers of a master's copy: to the layers it names, and passed on to nested +/// instances for the layers inside them (an outer override wins over the nested instance's own). +fn apply(layers: &mut [Value], ov: &BTreeMap, document: &Value, depth: usize) { + if depth > 64 || ov.is_empty() { + return; + } + for l in layers { + let Some(id) = str_field(l, "do_objectID").map(str::to_string) else { continue }; + let get = |prop: &str| ov.get(&format!("{id}_{prop}")); + match str_field(l, "_class") { + Some("symbolInstance") => { + match get("symbolID").map(|v| v.as_str().unwrap_or("")) { + Some("") => l["isVisible"] = json!(false), + Some(other) => l["symbolID"] = json!(other), + None => {} + } + let prefix = format!("{id}/"); + let inner: Vec = ov + .iter() + .filter_map(|(k, v)| k.strip_prefix(&prefix).map(|rest| json!({"_class": "overrideValue", "overrideName": rest, "value": v}))) + .collect(); + if !inner.is_empty() { + let mut own = l.get("overrideValues").and_then(Value::as_array).cloned().unwrap_or_default(); + own.extend(inner); + l["overrideValues"] = Value::Array(own); + } + continue; + } + Some("text") => { + if let Some(s) = get("stringValue").and_then(Value::as_str) { + set_string(l, s); + } + } + Some("bitmap") => { + if let Some(img) = get("image").filter(|v| v.get("_ref").is_some()) { + l["image"] = img.clone(); + } + } + _ => {} + } + for (prop, list) in [("layerStyle", "layerStyles"), ("textStyle", "layerTextStyles")] { + if let Some(style) = get(prop).and_then(Value::as_str).and_then(|sid| shared_style(document, list, sid)) { + l["style"] = style.clone(); + if prop == "textStyle" + && let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) + && let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) + { + for r in runs { + r["attributes"] = enc.clone(); + } + } + } + } + if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { + apply(kids, ov, document, depth + 1); + } + } +} + +/// A text layer's string replaced, in the style of its first run. +fn set_string(l: &mut Value, s: &str) { + let units = s.encode_utf16().count(); + let first = l.get("attributedString").and_then(|a| a.get("attributes")).and_then(Value::as_array).and_then(|a| a.first()).cloned(); + let Some(a) = l.get_mut("attributedString").and_then(Value::as_object_mut) else { return }; + a.insert("string".into(), json!(s)); + let runs = match first { + Some(mut r) => { + r["location"] = json!(0); + r["length"] = json!(units); + vec![r] + } + None => Vec::new(), + }; + a.insert("attributes".into(), Value::Array(runs)); +} + +/// The style of shared style `id` from the document's `list` (local) or its library copies. +pub(crate) fn shared_style<'v>(document: &'v Value, list: &str, id: &str) -> Option<&'v Value> { + let local = document.get(list).and_then(|c| c.get("objects")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let foreign_key = if list == "layerStyles" { "foreignLayerStyles" } else { "foreignTextStyles" }; + let foreign = document.get(foreign_key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + local + .iter() + .find(|s| str_field(s, "do_objectID") == Some(id)) + .or_else(|| foreign.iter().filter_map(|f| f.get("localSharedStyle")).find(|s| str_field(s, "do_objectID") == Some(id))) + .and_then(|s| s.get("value")) +} + +/// Make the ids of an instance's layers its own (`;`): the master's layers keep +/// theirs. +fn prefix_ids(v: &mut Value, prefix: &str, depth: usize) { + if depth > 256 { + return; + } + let Some(m) = v.as_object_mut() else { return }; + if let Some(id) = m.get("id").and_then(Value::as_str).map(str::to_string) { + m.insert("id".into(), json!(format!("{prefix};{id}"))); + } + if let Some(kids) = m.get_mut("children").and_then(Value::as_array_mut) { + for k in kids { + prefix_ids(k, prefix, depth + 1); + } + } +} + +/// Library masters no page holds: a page of their own, side by side. +pub(crate) fn library_page(cx: &mut Cx, masters: &[Value]) -> Value { + let mut x = 0.0; + let mut kids = Vec::new(); + for m in masters { + let mut placed = m.clone(); + let (w, _) = size(m); + if let Some(f) = placed.get_mut("frame").and_then(Value::as_object_mut) { + f.insert("x".into(), json!(x)); + f.insert("y".into(), json!(0.0)); + } + x += w + 100.0; + if let Some(node) = component(cx, &placed, Affine::IDENTITY, 0, true) { + kids.push(node); + } + } + let mut page = JsonMap::new(); + page.insert("type".into(), json!("CANVAS")); + page.insert("id".into(), json!("library-symbols")); + page.insert("name".into(), json!("Library symbols")); + page.insert("children".into(), Value::Array(kids)); + Value::Object(page) +} diff --git a/crates/format/src/sketch/symbols_tests.rs b/crates/format/src/sketch/symbols_tests.rs new file mode 100644 index 00000000..e2204399 --- /dev/null +++ b/crates/format/src/sketch/symbols_tests.rs @@ -0,0 +1,165 @@ +//! Sketch symbols → components and instances with overrides. + +use figcraft_doc::{Document, Node, NodeKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::{import, import_within_nodes}; + +const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0]; + +/// One page with `layers`, one "Symbols" page with `masters`, and `document` changes. +fn open_doc(doc_json: Value, layers: Vec, masters: Vec) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, doc_json, &[page("P", "Page", layers), page("S", "Symbols", masters)], &[]); + import(&z, "f").unwrap() +} + +fn open(layers: Vec, masters: Vec) -> (Document, Vec) { + let mut d = document(&["P", "S"]); + d["do_objectID"] = json!("DOC"); + open_doc(d, layers, masters) +} + +fn named<'d>(doc: &'d Document, name: &str) -> Vec<&'d Node> { + doc.nodes().filter(|n| n.name == name).collect() +} + +fn inside<'d>(doc: &'d Document, root: &Node, name: &str) -> &'d Node { + doc.subtree(root.id).into_iter().filter_map(|id| doc.node(id)).find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}")) +} + +fn chars(n: &Node) -> &str { + match &n.kind { + NodeKind::Text(td) => &td.characters, + other => panic!("{other:?}"), + } +} + +fn instance_of<'d>(doc: &'d Document, name: &str) -> (&'d Node, &'d Node) { + let inst = named(doc, name).into_iter().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap_or_else(|| panic!("no instance {name}")); + let NodeKind::Instance { component, .. } = inst.kind else { panic!("not an instance") }; + (inst, doc.node(component).unwrap()) +} + +fn button() -> Value { + master("M1", "S1", "Button", 100.0, 40.0, vec![text("Label", 10.0, 10.0, 80.0, 20.0, "Go", &[(0, 2, "Helvetica", 14.0, BLACK)])]) +} + +#[test] +fn master_is_component_instance_links() { + let (doc, _) = open(vec![instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[])], vec![button()]); + let (inst, main) = instance_of(&doc, "I1"); + assert!(matches!(main.kind, NodeKind::Component { .. })); + assert_eq!(main.name, "Button"); + assert_eq!(chars(inside(&doc, inst, "Label")), "Go"); +} + +#[test] +fn string_override() { + let (doc, _) = open(vec![instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[("Label_stringValue", json!("OK"))])], vec![button()]); + let (inst, main) = instance_of(&doc, "I1"); + assert_eq!(chars(inside(&doc, inst, "Label")), "OK"); + assert_eq!(chars(inside(&doc, main, "Label")), "Go"); +} + +#[test] +fn nested_symbol_swap_and_hide() { + let icon_a = master("MA", "SA", "Icon A", 20.0, 20.0, vec![rect("Dot A", 0.0, 0.0, 20.0, 20.0)]); + let icon_b = master("MB", "SB", "Icon B", 20.0, 20.0, vec![rect("Dot B", 0.0, 0.0, 20.0, 20.0)]); + let card = master("MC", "SC", "Card", 100.0, 40.0, vec![instance("N", "SA", 0.0, 0.0, 20.0, 20.0, &[])]); + let swapped = instance("Swapped", "SC", 0.0, 0.0, 100.0, 40.0, &[("N_symbolID", json!("SB"))]); + let hidden = instance("Hidden", "SC", 0.0, 100.0, 100.0, 40.0, &[("N_symbolID", json!(""))]); + let (doc, _) = open(vec![swapped, hidden], vec![icon_a, icon_b, card]); + let (sw, _) = instance_of(&doc, "Swapped"); + assert!(doc.subtree(sw.id).into_iter().filter_map(|i| doc.node(i)).any(|n| n.name == "Dot B")); + assert!(!doc.subtree(sw.id).into_iter().filter_map(|i| doc.node(i)).any(|n| n.name == "Dot A")); + let (hd, _) = instance_of(&doc, "Hidden"); + let nested = doc.node(hd.children[0]).unwrap(); + assert!(!nested.visible); +} + +#[test] +fn nested_path_override() { + let inner = master("MB", "SB", "Badge", 50.0, 20.0, vec![text("Count", 0.0, 0.0, 50.0, 20.0, "1", &[(0, 1, "Helvetica", 12.0, BLACK)])]); + let outer = master("MA", "SA", "Row", 100.0, 20.0, vec![instance("N", "SB", 50.0, 0.0, 50.0, 20.0, &[])]); + let (doc, _) = open(vec![instance("I1", "SA", 0.0, 0.0, 100.0, 20.0, &[("N/Count_stringValue", json!("42"))])], vec![inner, outer]); + let (inst, _) = instance_of(&doc, "I1"); + assert_eq!(chars(inside(&doc, inst, "Count")), "42"); +} + +#[test] +fn image_override() { + let mut bitmap = rect("Photo", 0.0, 0.0, 40.0, 40.0); + bitmap["_class"] = json!("bitmap"); + bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref": "images/a.png"}); + let m = master("M", "S", "Avatar", 40.0, 40.0, vec![bitmap]); + let png = { + let mut out = Vec::new(); + image::RgbaImage::new(1, 1).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + }; + let mut png2 = Vec::new(); + image::RgbaImage::new(2, 1).write_to(&mut std::io::Cursor::new(&mut png2), image::ImageFormat::Png).unwrap(); + let inst = instance("I1", "S", 0.0, 0.0, 40.0, 40.0, &[("Photo_image", json!({"_class": "MSJSONFileReference", "_ref": "images/b.png"}))]); + let z = sketch_zip( + "50.2", + 103, + document(&["P", "S"]), + &[page("P", "Page", vec![inst]), page("S", "Symbols", vec![m])], + &[("images/a.png", &png), ("images/b.png", &png2)], + ); + let (doc, _) = import(&z, "f").unwrap(); + assert_eq!(doc.images.len(), 2); +} + +#[test] +fn foreign_symbols_are_components() { + let mut d = document(&["P", "S"]); + d["foreignSymbols"] = json!([{"_class": "MSImmutableForeignSymbol", "sourceLibraryName": "Kit", "symbolMaster": button()}]); + let (doc, warnings) = open_doc(d, vec![instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[])], vec![]); + let (_, main) = instance_of(&doc, "I1"); + assert_eq!(main.name, "Button"); + let page = doc.pages.iter().copied().find(|p| doc.is_ancestor(*p, main.id)).unwrap(); + assert_eq!(doc.node(page).unwrap().name, "Library symbols"); + assert!(warnings.iter().any(|w| w.contains("Kit")), "{warnings:?}"); +} + +#[test] +fn missing_master_warns() { + let (doc, warnings) = open(vec![instance("Lost", "NOPE", 0.0, 0.0, 30.0, 30.0, &[])], vec![]); + let n = named(&doc, "Lost")[0]; + assert!(matches!(n.kind, NodeKind::Frame)); + assert_eq!((n.width, n.height), (30.0, 30.0)); + assert!(warnings.iter().any(|w| w.contains("symbol")), "{warnings:?}"); +} + +#[test] +fn self_containing_master_stops() { + let a = master("MA", "SA", "Loop A", 50.0, 50.0, vec![instance("Self", "SA", 0.0, 0.0, 10.0, 10.0, &[])]); + let b = master("MB", "SB", "Loop B", 50.0, 50.0, vec![instance("ToC", "SC", 0.0, 0.0, 10.0, 10.0, &[])]); + let c = master("MC", "SC", "Loop C", 50.0, 50.0, vec![instance("ToB", "SB", 0.0, 0.0, 10.0, 10.0, &[])]); + let (_, warnings) = open(vec![instance("I1", "SA", 0.0, 0.0, 50.0, 50.0, &[]), instance("I2", "SB", 100.0, 0.0, 50.0, 50.0, &[])], vec![a, b, c]); + assert!(warnings.iter().any(|w| w.contains("contains itself")), "{warnings:?}"); +} + +#[test] +fn instance_explosion_is_bounded() { + let big = master("M", "S", "Big", 100.0, 100.0, (0..1000).map(|i| rect(&format!("R{i}"), 0.0, 0.0, 1.0, 1.0)).collect()); + let many: Vec = (0..1000).map(|i| instance(&format!("I{i}"), "S", 0.0, 0.0, 100.0, 100.0, &[])).collect(); + let z = sketch_zip("50.2", 103, document(&["P", "S"]), &[page("P", "Page", many), page("S", "Symbols", vec![big])], &[]); + let (doc, warnings) = import_within_nodes(&z, "f", 20_000).unwrap(); + assert!(doc.nodes().count() <= 20_100); + assert!(warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); +} + +#[test] +fn resized_instance_follows_constraints() { + // Pinned right with a fixed width (bits 1 and 2 cleared). + let pinned = with(rect("Tag", 80.0, 0.0, 20.0, 40.0), "resizingConstraint", json!(60)); + let m = master("M", "S", "Bar", 100.0, 40.0, vec![pinned]); + let (doc, _) = open(vec![instance("Wide", "S", 0.0, 0.0, 200.0, 40.0, &[])], vec![m]); + let (inst, _) = instance_of(&doc, "Wide"); + let tag = inside(&doc, inst, "Tag"); + let b = doc.world_bounds(tag.id).unwrap(); + assert!((b.x0 - 180.0).abs() < 1e-6 && (b.width() - 20.0).abs() < 1e-6, "{b:?}"); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 16382dbe..11c7a414 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -23,7 +23,12 @@ pub(crate) struct Map { } /// The mapping's state while walking the file. -pub(crate) struct Cx { +pub(crate) struct Cx<'a> { + /// `document.json`: shared styles and library copies. + pub document: &'a Value, + pub symbols: super::symbols::Symbols, + /// The symbols being expanded, outermost first (a symbol inside itself stops there). + pub chain: Vec, pub warnings: Vec, pub networks: Vec>, pub too_old: bool, @@ -32,7 +37,7 @@ pub(crate) struct Cx { pub max_nodes: usize, } -impl Cx { +impl Cx<'_> { pub(crate) fn warn(&mut self, msg: impl Into) { let msg = msg.into(); if !self.warnings.contains(&msg) { @@ -43,7 +48,17 @@ impl Cx { /// Map the pages of `archive` to a REST file JSON named `name`. pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { - let mut cx = Cx { warnings: Vec::new(), networks: Vec::new(), too_old: false, nodes: 0, max_nodes }; + let symbols = super::symbols::Symbols::read(&archive.document, &archive.pages); + let mut cx = Cx { + document: &archive.document, + symbols, + chain: Vec::new(), + warnings: Vec::new(), + networks: Vec::new(), + too_old: false, + nodes: 0, + max_nodes, + }; let mut canvases = Vec::new(); for (id, page) in &archive.pages { let mut node = JsonMap::new(); @@ -54,6 +69,12 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { node.insert("children".into(), Value::Array(kids)); canvases.push(Value::Object(node)); } + let foreign = std::mem::take(&mut cx.symbols.foreign); + if !foreign.is_empty() { + canvases.push(super::symbols::library_page(&mut cx, &foreign)); + let libraries = cx.symbols.libraries.join(", "); + cx.warn(format!("symbols from the libraries {libraries} became local components on the page Library symbols")); + } let json = json!({"name": name, "document": {"type": "DOCUMENT", "id": "0:0", "children": canvases}}); Map { json, warnings: cx.warnings, networks: cx.networks, too_old: cx.too_old } } @@ -73,6 +94,12 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: if class == "shapeGroup" { return count(cx).then(|| super::path::shape_group(cx, l, parent, depth, root)).flatten(); } + if class == "symbolMaster" { + return count(cx).then(|| super::symbols::component(cx, l, parent, depth, root)).flatten(); + } + if class == "symbolInstance" { + return count(cx).then(|| super::symbols::instance(cx, l, parent, depth, root)).flatten(); + } let ty = match class { "artboard" | "frame" => "FRAME", "group" | "graphic" => "GROUP", From 4be7017ed1d746707d6a8a64aa36e1e68e3304e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:42:22 +0200 Subject: [PATCH 15/87] M6: Sketch import: shared styles and colour variables Shared layer styles become a paint style for their fills, a paint style for their border colours (linked to strokes) and an effect style; shared text styles become text styles (colour stays on the layers); local and library copies alike, unused ones listed too. Layers link to them, keeping their own values where they differ. Color Variables (swatches, also library ones) become colour variables in a Color Variables collection, with fills, borders and text colours bound to them. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/mod.rs | 3 + crates/format/src/sketch/path.rs | 9 +- crates/format/src/sketch/shared.rs | 134 ++++++++++++++++++++ crates/format/src/sketch/shared_tests.rs | 150 +++++++++++++++++++++++ crates/format/src/sketch/style.rs | 25 +++- crates/format/src/sketch/symbols.rs | 5 +- crates/format/src/sketch/text.rs | 8 +- crates/format/src/sketch/tree.rs | 26 +++- 8 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 crates/format/src/sketch/shared.rs create mode 100644 crates/format/src/sketch/shared_tests.rs diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index b9063a70..db0421f8 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -16,6 +16,9 @@ mod fixture; mod path; #[cfg(test)] mod path_tests; +mod shared; +#[cfg(test)] +mod shared_tests; mod style; #[cfg(test)] mod style_tests; diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 6ebb1a75..0cce6e08 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -139,9 +139,10 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, } let visible = group.get("visible").and_then(Value::as_bool).unwrap_or(true) && m.get("visible").and_then(Value::as_bool).unwrap_or(true); m.insert("visible".into(), json!(visible)); - if let Some(st) = l.get("style") { + if l.get("style").is_some() { super::style::clear(m); - super::style::apply(cx, st, cw, ch, m); + m.remove("styles"); + super::tree::styles(cx, l, cw, ch, m); } super::tree::mask(l, m); } @@ -168,9 +169,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, node.insert("booleanOperation".into(), json!(op)); node.insert("children".into(), Value::Array(chain)); super::tree::mask(l, &mut node); - if let Some(st) = l.get("style") { - super::style::apply(cx, st, w, h, &mut node); - } + super::tree::styles(cx, l, w, h, &mut node); Some(Value::Object(node)) } diff --git a/crates/format/src/sketch/shared.rs b/crates/format/src/sketch/shared.rs new file mode 100644 index 00000000..104a1931 --- /dev/null +++ b/crates/format/src/sketch/shared.rs @@ -0,0 +1,134 @@ +//! Sketch shared styles and swatches → REST style definitions and colour variables. +//! +//! A Sketch layer style holds fills, borders and effects together; FigCraft styles hold one kind +//! each, so a layer style becomes up to three styles named after it: its fills (a paint style), +//! its border colours (a paint style for strokes; widths stay on the layers) and its effects. A +//! text style becomes a text style; its colour stays on the layers, as FigCraft text styles carry +//! none. Layers link to them by `style.sharedObjectID`. Swatches (Color Variables, Sketch 69+) +//! become colour variables in one collection; colours name theirs by `swatchID`. + +use std::collections::BTreeMap; + +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, color, str_field}; + +/// Most shared styles or swatches read from one file. +const MAX_SHARED: usize = 20_000; +/// The collection swatches become variables in. +const COLLECTION: &str = "sketch-color-variables"; +const MODE: &str = "default"; + +/// A file's shared styles and swatches. +#[derive(Default)] +pub(crate) struct Shared { + /// `(id, name, style)` of layer styles, then text styles. + layer: Vec<(String, String, Value)>, + text: Vec<(String, String, Value)>, + swatches: BTreeMap, +} + +impl Shared { + pub(crate) fn read(document: &Value) -> Shared { + let mut s = Shared::default(); + let objects = + |key: &str| -> Vec { document.get(key).and_then(|c| c.get("objects")).and_then(Value::as_array).cloned().unwrap_or_default() }; + let foreign = |key: &str, inner: &str| -> Vec { + document.get(key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default().iter().filter_map(|f| f.get(inner).cloned()).collect() + }; + let entry = |o: &Value| { + Some(( + str_field(o, "do_objectID")?.to_string(), + str_field(o, "name").unwrap_or("Style").chars().take(200).collect(), + o.get("value")?.clone(), + )) + }; + for o in objects("layerStyles").iter().chain(&foreign("foreignLayerStyles", "localSharedStyle")).take(MAX_SHARED) { + s.layer.extend(entry(o)); + } + for o in objects("layerTextStyles").iter().chain(&foreign("foreignTextStyles", "localSharedStyle")).take(MAX_SHARED) { + s.text.extend(entry(o)); + } + let foreign_swatches = [foreign("foreignSwatches", "localSwatch"), foreign("foreignSwatches", "localSharedSwatch")].concat(); + for o in objects("sharedSwatches").iter().chain(&foreign_swatches).take(MAX_SHARED) { + if let Some((id, name, value)) = entry(o) + && let Some(c) = color(&value) + { + s.swatches.entry(id).or_insert((name, c)); + } + } + s + } + + pub(crate) fn has_swatch(&self, id: &str) -> bool { + self.swatches.contains_key(id) + } + + /// The REST `styleDefinitions` of every shared style. + pub(crate) fn definitions(cx: &mut Cx) -> Vec { + let layer = cx.shared.layer.clone(); + let text = cx.shared.text.clone(); + let mut out = Vec::new(); + for (id, name, style) in &layer { + let fills = super::style::fills(cx, style, 100.0, 100.0); + if !fills.is_empty() { + out.push(json!({"id": format!("{id}:fill"), "name": name, "styleType": "FILL", "node": {"fills": fills}})); + } + let strokes = super::style::border_paints(cx, style, 100.0, 100.0); + if !strokes.is_empty() { + out.push(json!({"id": format!("{id}:stroke"), "name": format!("{name} border"), "styleType": "FILL", "node": {"fills": strokes}})); + } + let effects = super::style::effects(cx, style); + if !effects.is_empty() { + out.push(json!({"id": format!("{id}:effect"), "name": name, "styleType": "EFFECT", "node": {"effects": effects}})); + } + } + for (id, name, style) in &text { + let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) else { continue }; + let mut ts = super::text::type_style(cx, enc); + if let Some(m) = ts.as_object_mut() { + m.remove("fills"); + } + out.push(json!({"id": id, "name": name, "styleType": "TEXT", "node": {"type": "TEXT", "characters": "", "style": ts}})); + } + out + } + + /// Link `node` (layer `l`) to the styles made from its shared style. + pub(crate) fn link(&self, cx_defs: &[String], l: &Value, node: &mut JsonMap) { + let Some(sid) = l.get("style").and_then(|s| str_field(s, "sharedObjectID")) else { return }; + let mut refs = JsonMap::new(); + let is_text = str_field(l, "_class") == Some("text"); + if is_text && self.text.iter().any(|(id, _, _)| id == sid) { + refs.insert("text".into(), json!(sid)); + } else if self.layer.iter().any(|(id, _, _)| id == sid) { + for slot in ["fill", "stroke", "effect"] { + let id = format!("{sid}:{slot}"); + if cx_defs.contains(&id) { + refs.insert(slot.into(), json!(id)); + } + } + } + if !refs.is_empty() { + node.insert("styles".into(), Value::Object(refs)); + } + } + + /// The REST `variableCollections` and `variables` for the swatches (none without swatches). + pub(crate) fn variables(&self) -> Option<(Value, Value)> { + if self.swatches.is_empty() { + return None; + } + let ids: Vec<&String> = self.swatches.keys().collect(); + let collections = json!({COLLECTION: {"id": COLLECTION, "name": "Color Variables", "modes": [{"modeId": MODE, "name": "Default"}], + "defaultModeId": MODE, "variableIds": ids}}); + let vars: JsonMap = self + .swatches + .iter() + .map(|(id, (name, c))| { + (id.clone(), json!({"id": id, "name": name, "resolvedType": "COLOR", "variableCollectionId": COLLECTION, "valuesByMode": {MODE: c}})) + }) + .collect(); + Some((collections, Value::Object(vars))) + } +} diff --git a/crates/format/src/sketch/shared_tests.rs b/crates/format/src/sketch/shared_tests.rs new file mode 100644 index 00000000..571a6d2e --- /dev/null +++ b/crates/format/src/sketch/shared_tests.rs @@ -0,0 +1,150 @@ +//! Sketch shared styles → FigCraft styles; Color Variables (swatches) → colour variables. + +use figcraft_doc::color::PaintKind; +use figcraft_doc::{Document, Node, StyleKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0]; + +fn open(doc_json: Value, layers: Vec) -> (Document, Vec) { + let z = sketch_zip("83.2", 139, doc_json, &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], &[]); + import(&z, "f").unwrap() +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}")) +} + +fn style<'d>(doc: &'d Document, id: &str) -> &'d figcraft_doc::Style { + doc.styles.iter().find(|s| s.id == id).unwrap_or_else(|| panic!("no style {id}: {:?}", doc.styles.iter().map(|s| &s.id).collect::>())) +} + +fn card_style() -> Value { + json!({"_class": "style", "fills": [solid_fill(0.0, 0.0, 1.0, 1.0)], "borders": [], + "shadows": [{"_class": "shadow", "isEnabled": true, "blurRadius": 4, "offsetX": 0, "offsetY": 2, "spread": 0, "color": color(0.0, 0.0, 0.0, 0.3)}], + "innerShadows": []}) +} + +fn with_shared(d: &mut Value, layer_styles: Vec, text_styles: Vec) { + d["layerStyles"]["objects"] = Value::Array(layer_styles); + d["layerTextStyles"]["objects"] = Value::Array(text_styles); +} + +fn shared(id: &str, name: &str, value: Value) -> Value { + json!({"_class": "sharedStyle", "do_objectID": id, "name": name, "value": value}) +} + +fn linked(mut l: Value, style: Value, shared_id: &str) -> Value { + l["style"] = style; + l["style"]["sharedObjectID"] = json!(shared_id); + l +} + +#[test] +fn layer_style_splits() { + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), card_style(), "CARD")]); + let n = node(&doc, "R"); + let fill = n.styles.fill.clone().expect("fill style linked"); + let effect = n.styles.effect.clone().expect("effect style linked"); + assert_eq!(style(&doc, &fill).name, "Card"); + assert!(matches!(style(&doc, &fill).kind, StyleKind::Paint { .. })); + assert!(matches!(style(&doc, &effect).kind, StyleKind::Effect { .. })); + assert_eq!(style(&doc, &effect).name, "Card"); +} + +#[test] +fn layer_style_borders_become_a_stroke_style() { + let mut st = card_style(); + st["borders"] = + json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(1.0, 0.0, 0.0, 1.0), "position": 1, "thickness": 2}]); + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", st.clone())], vec![]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), st, "CARD")]); + let stroke = node(&doc, "R").styles.stroke.clone().expect("stroke style linked"); + assert!(style(&doc, &stroke).name.starts_with("Card")); +} + +#[test] +fn unused_shared_styles_are_listed() { + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]); + let (doc, _) = open(d, vec![]); + assert!(doc.styles.iter().any(|s| s.name == "Card")); +} + +#[test] +fn text_style_links() { + let enc = json!({"MSAttributedStringFontAttribute": {"_class": "fontDescriptor", "attributes": {"name": "Helvetica-Bold", "size": 32}}, + "MSAttributedStringColorAttribute": color(1.0, 0.0, 0.0, 1.0), + "paragraphStyle": {"_class": "paragraphStyle", "alignment": 0, "maximumLineHeight": 40, "minimumLineHeight": 40}}); + let st = json!({"_class": "style", "textStyle": {"_class": "textStyle", "encodedAttributes": enc, "verticalAlignment": 0}}); + let mut d = document(&["P"]); + with_shared(&mut d, vec![], vec![shared("H1", "Heading/H1", st.clone())]); + let t = linked(text("Title", 0.0, 0.0, 200.0, 40.0, "Hello", &[(0, 5, "Helvetica-Bold", 32.0, [1.0, 0.0, 0.0, 1.0])]), st, "H1"); + let (doc, _) = open(d, vec![t]); + let n = node(&doc, "Title"); + let sid = n.styles.text.clone().expect("text style linked"); + let s = style(&doc, &sid); + assert_eq!(s.name, "Heading/H1"); + let StyleKind::Text { style: ts } = &s.kind else { panic!("not a text style") }; + assert_eq!((ts.font_family.as_str(), ts.font_style.as_str(), ts.font_size), ("Helvetica", "Bold", 32.0)); + assert!(n.fills.iter().any(|p| matches!(p.kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0))); +} + +#[test] +fn locally_changed_style_keeps_link() { + let mut local = card_style(); + local["fills"] = json!([solid_fill(0.0, 1.0, 0.0, 1.0)]); + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), local, "CARD")]); + let n = node(&doc, "R"); + assert!(n.styles.fill.is_some()); + assert!(matches!(n.fills[0].kind, PaintKind::Solid { color } if color.g == 1.0 && color.b == 0.0)); +} + +#[test] +fn foreign_styles_too() { + let mut d = document(&["P"]); + d["foreignLayerStyles"] = json!([{"_class": "MSImmutableForeignLayerStyle", "sourceLibraryName": "Kit", "localSharedStyle": shared("FCARD", "Kit card", card_style())}]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), card_style(), "FCARD")]); + assert_eq!(style(&doc, node(&doc, "R").styles.fill.as_deref().unwrap()).name, "Kit card"); +} + +#[test] +fn swatches_are_color_variables() { + let mut d = document(&["P"]); + d["sharedSwatches"] = json!({"_class": "swatchContainer", "objects": [{"_class": "swatch", "do_objectID": "SW1", "name": "Brand/Blue", "value": color(0.06, 0.38, 1.0, 1.0)}]}); + d["foreignSwatches"] = json!([{"_class": "MSImmutableForeignSwatch", "sourceLibraryName": "Kit", "localSwatch": {"_class": "swatch", "do_objectID": "SW2", "name": "Kit/Red", "value": color(1.0, 0.0, 0.0, 1.0)}}]); + let mut fill = solid_fill(0.06, 0.38, 1.0, 1.0); + fill["color"]["swatchID"] = json!("SW1"); + let mut fill2 = solid_fill(1.0, 0.0, 0.0, 1.0); + fill2["color"]["swatchID"] = json!("SW2"); + let (doc, _) = open( + d, + vec![styled(rect("R", 0.0, 0.0, 50.0, 50.0), "fills", json!([fill])), styled(rect("S", 0.0, 0.0, 50.0, 50.0), "fills", json!([fill2]))], + ); + let coll = doc.variables.collections.iter().find(|c| c.name == "Color Variables").expect("collection"); + let var = doc.variables.variables.iter().find(|v| v.name == "Brand/Blue").expect("variable"); + assert_eq!(var.collection, coll.id); + assert!(doc.variables.variables.iter().any(|v| v.name == "Kit/Red")); + assert_eq!(node(&doc, "R").bound.get("fill"), Some(&var.id)); + assert!(node(&doc, "S").bound.contains_key("fill")); +} + +#[test] +fn unknown_shared_ids_are_ignored() { + let (doc, _) = open(document(&["P"]), vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), card_style(), "NOPE")]); + let n = node(&doc, "R"); + assert!(n.styles.fill.is_none()); + let mut f = solid_fill(0.0, 0.0, 0.0, 1.0); + f["color"]["swatchID"] = json!("NOPE"); + let (doc, _) = + open(document(&["P"]), vec![styled(text("T", 0.0, 0.0, 10.0, 10.0, "x", &[(0, 1, "Helvetica", 9.0, BLACK)]), "fills", json!([f]))]); + assert!(node(&doc, "T").bound.is_empty()); +} diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs index 2da1b7a4..de2529aa 100644 --- a/crates/format/src/sketch/style.rs +++ b/crates/format/src/sketch/style.rs @@ -17,7 +17,7 @@ const MAX_LIST: usize = 64; /// Write the paints, strokes, effects, opacity and blend mode of `style` into `node`, a layer of /// `w`×`h`. Lists the style doesn't have are left as they are. pub(crate) fn apply(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap) { - let fills: Vec = list(style, "fills").iter().filter_map(|f| paint(cx, f, w, h)).collect(); + let fills = fills(cx, style, w, h); if !fills.is_empty() { node.insert("fills".into(), Value::Array(fills)); } @@ -36,6 +36,16 @@ pub(crate) fn apply(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonM } } +/// A style's fills as REST paints. +pub(crate) fn fills(cx: &mut Cx, style: &Value, w: f64, h: f64) -> Vec { + list(style, "fills").iter().filter_map(|f| paint(cx, f, w, h)).collect() +} + +/// A style's borders as REST paints. +pub(crate) fn border_paints(cx: &mut Cx, style: &Value, w: f64, h: f64) -> Vec { + list(style, "borders").iter().filter_map(|b| paint(cx, b, w, h)).collect() +} + /// Remove what [`apply`] writes, so another style can take its place. pub(crate) fn clear(node: &mut JsonMap) { for key in [ @@ -91,6 +101,9 @@ fn paint(cx: &mut Cx, f: &Value, w: f64, h: f64) -> Option { } }; p["visible"] = json!(f.get("isEnabled").and_then(Value::as_bool).unwrap_or(true)); + if let Some(var) = swatch(cx, f.get("color")) { + p["boundVariables"] = var; + } if let Some(ctx) = f.get("contextSettings") { if let Some(o) = num(ctx, "opacity") { p["opacity"] = json!(o.clamp(0.0, 1.0)); @@ -131,7 +144,7 @@ fn pt((x, y): (f64, f64)) -> Value { /// Borders → stroke paints, and the first enabled border's thickness and position. fn borders(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap) { let list = list(style, "borders"); - let strokes: Vec = list.iter().filter_map(|b| paint(cx, b, w, h)).collect(); + let strokes = border_paints(cx, style, w, h); if strokes.is_empty() { return; } @@ -201,7 +214,7 @@ fn borders(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap Vec { +pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec { let mut out = Vec::new(); for (key, ty) in [("shadows", "DROP_SHADOW"), ("innerShadows", "INNER_SHADOW")] { for s in list(style, key) { @@ -230,6 +243,12 @@ fn effects(cx: &mut Cx, style: &Value) -> Vec { out } +/// The variable binding of a colour that names a swatch the file has (`{color: alias}`). +pub(crate) fn swatch(cx: &Cx, c: Option<&Value>) -> Option { + let id = c?.get("swatchID").and_then(Value::as_str).filter(|id| cx.shared.has_swatch(id))?; + Some(json!({"color": {"type": "VARIABLE_ALIAS", "id": id}})) +} + /// Sketch's blend modes (0 normal … 17 plus lighter) by REST name. fn blend(mode: i64) -> Option<&'static str> { const MODES: [&str; 18] = [ diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index d6e7165c..15079702 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -209,8 +209,11 @@ fn apply(layers: &mut [Value], ov: &BTreeMap, document: &Value, d _ => {} } for (prop, list) in [("layerStyle", "layerStyles"), ("textStyle", "layerTextStyles")] { - if let Some(style) = get(prop).and_then(Value::as_str).and_then(|sid| shared_style(document, list, sid)) { + let sid = get(prop).and_then(Value::as_str); + if let Some((sid, style)) = sid.and_then(|sid| Some((sid, shared_style(document, list, sid)?))) { l["style"] = style.clone(); + // The layer now uses that shared style: it links to it. + l["style"]["sharedObjectID"] = json!(sid); if prop == "textStyle" && let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) && let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index 74e7a731..3cdb2afc 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -94,7 +94,7 @@ pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap) { } /// A run's attributes as a REST type style (with its colour as `fills`). -fn type_style(cx: &mut Cx, a: &Value) -> Value { +pub(crate) fn type_style(cx: &mut Cx, a: &Value) -> Value { let font = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes")); let ps = font.and_then(|f| f.get("name")).and_then(Value::as_str).unwrap_or(""); let size = font.and_then(|f| f.get("size")).and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(12.0); @@ -126,7 +126,11 @@ fn type_style(cx: &mut Cx, a: &Value) -> Value { _ => "ORIGINAL", }); let c = a.get("MSAttributedStringColorAttribute").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0})); - s["fills"] = json!([{"type": "SOLID", "color": c}]); + let mut paint = json!({"type": "SOLID", "color": c}); + if let Some(var) = super::style::swatch(cx, a.get("MSAttributedStringColorAttribute")) { + paint["boundVariables"] = var; + } + s["fills"] = json!([paint]); s } diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 11c7a414..481eec70 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -27,6 +27,9 @@ pub(crate) struct Cx<'a> { /// `document.json`: shared styles and library copies. pub document: &'a Value, pub symbols: super::symbols::Symbols, + pub shared: super::shared::Shared, + /// Ids of the styles made from the shared styles. + pub style_defs: Vec, /// The symbols being expanded, outermost first (a symbol inside itself stops there). pub chain: Vec, pub warnings: Vec, @@ -49,9 +52,12 @@ impl Cx<'_> { /// Map the pages of `archive` to a REST file JSON named `name`. pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { let symbols = super::symbols::Symbols::read(&archive.document, &archive.pages); + let shared = super::shared::Shared::read(&archive.document); let mut cx = Cx { document: &archive.document, symbols, + shared, + style_defs: Vec::new(), chain: Vec::new(), warnings: Vec::new(), networks: Vec::new(), @@ -59,6 +65,8 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { nodes: 0, max_nodes, }; + let defs = super::shared::Shared::definitions(&mut cx); + cx.style_defs = defs.iter().filter_map(|d| d.get("id").and_then(Value::as_str).map(str::to_string)).collect(); let mut canvases = Vec::new(); for (id, page) in &archive.pages { let mut node = JsonMap::new(); @@ -75,7 +83,11 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { let libraries = cx.symbols.libraries.join(", "); cx.warn(format!("symbols from the libraries {libraries} became local components on the page Library symbols")); } - let json = json!({"name": name, "document": {"type": "DOCUMENT", "id": "0:0", "children": canvases}}); + let mut json = json!({"name": name, "document": {"type": "DOCUMENT", "id": "0:0", "children": canvases}, "styleDefinitions": defs}); + if let Some((collections, variables)) = cx.shared.variables() { + json["variableCollections"] = collections; + json["variables"] = variables; + } Map { json, warnings: cx.warnings, networks: cx.networks, too_old: cx.too_old } } @@ -147,9 +159,7 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: }, _ => {} } - if let Some(st) = l.get("style") { - super::style::apply(cx, st, w, h, &mut node); - } + styles(cx, l, w, h, &mut node); if matches!(ty, "FRAME" | "GROUP") { let kids = children(cx, l, world, (w, h), depth, false); node.insert("children".into(), Value::Array(kids)); @@ -157,6 +167,14 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: Some(Value::Object(node)) } +/// Layer `l`'s style into `node`, and its links to the styles made from its shared style. +pub(crate) fn styles(cx: &mut Cx, l: &Value, w: f64, h: f64, node: &mut JsonMap) { + if let Some(st) = l.get("style") { + super::style::apply(cx, st, w, h, node); + } + cx.shared.link(&cx.style_defs, l, node); +} + /// A clipping mask's flag and kind (outline or alpha). pub(crate) fn mask(l: &Value, node: &mut JsonMap) { if bool_field(l, "hasClippingMask") == Some(true) { From 31698164acacf2348a70beea39e8a2d5cf9b97cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:46:21 +0200 Subject: [PATCH 16/87] M6: Sketch import: prototype links, export settings, layout grids Layers linked to artboards navigate there on click (back links go back, slide animations slide), hotspots become transparent frames with their link, flow-home artboards start a flow; export options become export settings (scale, width or height, suffix); artboard layout and square grids become layout grids, which instances show like their masters. Smart Layout, stacks and locked overrides are noted once per file. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/mod.rs | 3 + crates/format/src/sketch/path.rs | 2 + crates/format/src/sketch/proto.rs | 117 ++++++++++++++++++++++ crates/format/src/sketch/proto_tests.rs | 125 ++++++++++++++++++++++++ crates/format/src/sketch/symbols.rs | 4 + crates/format/src/sketch/tree.rs | 20 ++++ 6 files changed, 271 insertions(+) create mode 100644 crates/format/src/sketch/proto.rs create mode 100644 crates/format/src/sketch/proto_tests.rs diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index db0421f8..3132e658 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -16,6 +16,9 @@ mod fixture; mod path; #[cfg(test)] mod path_tests; +mod proto; +#[cfg(test)] +mod proto_tests; mod shared; #[cfg(test)] mod shared_tests; diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 0cce6e08..8fbe84cd 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -143,6 +143,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, super::style::clear(m); m.remove("styles"); super::tree::styles(cx, l, cw, ch, m); + super::tree::extras(cx, l, ch, m); } super::tree::mask(l, m); } @@ -170,6 +171,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, node.insert("children".into(), Value::Array(chain)); super::tree::mask(l, &mut node); super::tree::styles(cx, l, w, h, &mut node); + super::tree::extras(cx, l, h, &mut node); Some(Value::Object(node)) } diff --git a/crates/format/src/sketch/proto.rs b/crates/format/src/sketch/proto.rs new file mode 100644 index 00000000..afa6df48 --- /dev/null +++ b/crates/format/src/sketch/proto.rs @@ -0,0 +1,117 @@ +//! Sketch prototype links, hotspots, export options and layout grids → REST. +//! +//! A layer's `flow` links it to an artboard (`destinationArtboardID`, or `back`) with an +//! `animationType` (0 none, 1–4 slide from the right, left, bottom, top); hotspots are layers that +//! only carry a link; `isFlowHome` marks an artboard where a prototype starts. + +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, bool_field, str_field}; + +/// Most export settings read from one layer. +const MAX_EXPORTS: usize = 64; + +/// The click interaction of layer `l`'s `flow`, if it has one. +pub(crate) fn interactions(l: &Value, node: &mut JsonMap) { + let Some(flow) = l.get("flow") else { return }; + let Some(dest) = str_field(flow, "destinationArtboardID").filter(|d| !d.is_empty()) else { return }; + let action = if dest == "back" { + json!({"type": "BACK"}) + } else { + let direction = match flow.get("animationType").and_then(Value::as_i64) { + Some(1) => Some("LEFT"), + Some(2) => Some("RIGHT"), + Some(3) => Some("TOP"), + Some(4) => Some("BOTTOM"), + _ => None, + }; + let transition = + direction.map_or(Value::Null, |d| json!({"type": "SLIDE_IN", "direction": d, "duration": 0.3, "easing": {"type": "EASE_IN_AND_OUT"}})); + json!({"type": "NODE", "destinationId": dest, "navigation": "NAVIGATE", "transition": transition}) + }; + node.insert("interactions".into(), json!([{"trigger": {"type": "ON_CLICK"}, "actions": [action]}])); +} + +/// The artboards of a page marked as where a prototype starts, as REST `flowStartingPoints`. +pub(crate) fn starting_points(page: &Value) -> Vec { + super::tree::layers(page) + .iter() + .filter(|a| bool_field(a, "isFlowHome") == Some(true)) + .filter_map(|a| Some(json!({"nodeId": str_field(a, "do_objectID")?, "name": str_field(a, "name").unwrap_or("Flow")}))) + .collect() +} + +/// Layer `l`'s export options as REST `exportSettings`. +pub(crate) fn export_settings(cx: &mut Cx, l: &Value, node: &mut JsonMap) { + let formats = l.get("exportOptions").and_then(|o| o.get("exportFormats")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let mut out = Vec::new(); + for f in formats.iter().take(MAX_EXPORTS) { + let format = match str_field(f, "fileFormat").unwrap_or("png").to_ascii_lowercase().as_str() { + "png" => "PNG", + "jpg" | "jpeg" => "JPG", + "svg" => "SVG", + "pdf" => "PDF", + "webp" => "WEBP", + _ => { + cx.warn("export settings in formats FigCraft doesn't export (TIFF, EPS, HEIC) were skipped"); + continue; + } + }; + let num = |k: &str| f.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + let constraint = match (f.get("visibleScaleType").and_then(Value::as_i64), num("absoluteSize")) { + (Some(1), Some(s)) => json!({"type": "WIDTH", "value": s.min(16_384.0)}), + (Some(2), Some(s)) => json!({"type": "HEIGHT", "value": s.min(16_384.0)}), + _ => json!({"type": "SCALE", "value": num("scale").unwrap_or(1.0).min(64.0)}), + }; + let name: String = str_field(f, "name").unwrap_or("").chars().take(200).collect(); + if f.get("namingScheme").and_then(Value::as_i64) == Some(1) && !name.is_empty() { + cx.warn("export names Sketch puts before the layer name come after it"); + } + out.push(json!({"format": format, "suffix": name, "constraint": constraint})); + } + if !out.is_empty() { + node.insert("exportSettings".into(), Value::Array(out)); + } +} + +/// An artboard's layout grid (columns, rows) and square grid as REST `layoutGrids`. +pub(crate) fn layout_grids(l: &Value, h: f64, node: &mut JsonMap) { + let mut out = Vec::new(); + if let Some(g) = l.get("layout").filter(|g| bool_field(g, "isEnabled") != Some(false)) { + let num = |k: &str| g.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v >= 0.0); + if bool_field(g, "drawVertical") != Some(false) + && let (Some(count), Some(width)) = (num("numberOfColumns"), num("columnWidth")) + { + out.push(json!({"pattern": "COLUMNS", "alignment": "MIN", "count": count.min(1000.0), "sectionSize": width.min(1e5), + "gutterSize": num("gutterWidth").unwrap_or(0.0).min(1e5), "offset": num("horizontalOffset").unwrap_or(0.0).min(1e5), "visible": true})); + } + if bool_field(g, "drawHorizontal") == Some(true) { + let gutter = num("gutterHeight").unwrap_or(0.0).min(1e5); + let section = (gutter * num("rowHeightMultiplication").unwrap_or(1.0)).min(1e5); + if section > 0.0 { + let count = (h / (section + gutter)).floor().clamp(1.0, 1000.0); + out.push(json!({"pattern": "ROWS", "alignment": "MIN", "count": count, "sectionSize": section, "gutterSize": gutter, "offset": 0, "visible": true})); + } + } + } + if let Some(g) = l.get("grid").filter(|g| bool_field(g, "isEnabled") != Some(false)) + && let Some(size) = g.get("gridSize").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0) + { + out.push(json!({"pattern": "GRID", "sectionSize": size.min(1e5), "visible": true})); + } + if !out.is_empty() { + node.insert("layoutGrids".into(), Value::Array(out)); + } +} + +/// Notes about what the layer has that FigCraft keeps only as laid out: Smart Layout and Stacks, +/// and overrides a master locks. +pub(crate) fn notes(cx: &mut Cx, l: &Value) { + if l.get("groupLayout").and_then(|g| str_field(g, "_class")).is_some_and(|c| c != "MSImmutableFreeformGroupLayout") { + cx.warn("Smart Layout and stacks were imported as laid out; they don't resize with their content"); + } + let locked = l.get("overrideProperties").and_then(Value::as_array).is_some_and(|p| p.iter().any(|o| bool_field(o, "canOverride") == Some(false))); + if locked { + cx.warn("overrides Sketch symbols locked can be changed in FigCraft"); + } +} diff --git a/crates/format/src/sketch/proto_tests.rs b/crates/format/src/sketch/proto_tests.rs new file mode 100644 index 00000000..be9b835d --- /dev/null +++ b/crates/format/src/sketch/proto_tests.rs @@ -0,0 +1,125 @@ +//! Sketch prototype links, hotspots, export options, layout grids, smart layout and locked +//! overrides. + +use figcraft_doc::{Document, ExportFormat, Node, NodeKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +fn open(layers: Vec) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", layers)], &[]); + import(&z, "f").unwrap() +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}")) +} + +fn screens(button: Value) -> Vec { + vec![artboard("A1", "Home", 0.0, 0.0, 100.0, 100.0, vec![button]), artboard("A2", "Detail", 200.0, 0.0, 100.0, 100.0, vec![])] +} + +#[test] +fn flow_navigates() { + let b = with( + rect("Go", 0.0, 0.0, 20.0, 20.0), + "flow", + json!({"_class": "MSImmutableFlowConnection", "destinationArtboardID": "A2", "animationType": 1}), + ); + let (doc, _) = open(screens(b)); + let go = node(&doc, "Go"); + assert_eq!(go.interactions.len(), 1, "{:?}", go.interactions); + let detail = node(&doc, "Detail").id; + let text = format!("{:?}", go.interactions[0]); + assert!(text.contains(&format!("{detail:?}")), "{text}"); +} + +#[test] +fn flow_back() { + let b = with( + rect("Back", 0.0, 0.0, 20.0, 20.0), + "flow", + json!({"_class": "MSImmutableFlowConnection", "destinationArtboardID": "back", "animationType": 0}), + ); + let (doc, _) = open(screens(b)); + let text = format!("{:?}", node(&doc, "Back").interactions); + assert!(text.contains("Back"), "{text}"); +} + +#[test] +fn hotspot_is_transparent_frame_with_interaction() { + let mut h = with( + rect("Hot", 0.0, 0.0, 20.0, 20.0), + "flow", + json!({"_class": "MSImmutableFlowConnection", "destinationArtboardID": "A2", "animationType": 0}), + ); + h["_class"] = json!("MSImmutableHotspotLayer"); + let (doc, _) = open(screens(h)); + let n = node(&doc, "Hot"); + assert!(matches!(n.kind, NodeKind::Frame)); + assert!(n.fills.is_empty()); + assert_eq!(n.interactions.len(), 1); +} + +#[test] +fn flow_home_is_starting_point() { + let mut home = artboard("A1", "Home", 0.0, 0.0, 100.0, 100.0, vec![]); + home["isFlowHome"] = json!(true); + let (doc, _) = open(vec![home]); + let page = doc.node(doc.pages[0]).unwrap(); + let NodeKind::Page { flows, .. } = &page.kind else { panic!("not a page") }; + assert_eq!(flows.len(), 1); + assert_eq!(flows[0].start, node(&doc, "Home").id); +} + +#[test] +fn export_options() { + let mut r = rect("Icon", 0.0, 0.0, 20.0, 20.0); + r["exportOptions"] = json!({"_class": "exportOptions", "exportFormats": [ + {"_class": "exportFormat", "fileFormat": "png", "scale": 2, "name": "@2x", "namingScheme": 0, "absoluteSize": 0, "visibleScaleType": 0}, + {"_class": "exportFormat", "fileFormat": "svg", "scale": 1, "name": "", "namingScheme": 0, "absoluteSize": 0, "visibleScaleType": 0}, + {"_class": "exportFormat", "fileFormat": "png", "scale": 1, "name": "", "namingScheme": 0, "absoluteSize": 64, "visibleScaleType": 1}, + ]}); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![r])]); + let e = &node(&doc, "Icon").exports; + assert_eq!(e.len(), 3); + assert_eq!(e[0].format, ExportFormat::Png); + assert_eq!(e[0].suffix, "@2x"); + assert_eq!(e[1].format, ExportFormat::Svg); + let text = format!("{:?}", e[2]); + assert!(text.contains("64"), "{text}"); +} + +#[test] +fn layout_and_square_grids() { + let mut a = artboard("A", "Board", 0.0, 0.0, 1200.0, 800.0, vec![]); + a["layout"] = json!({"_class": "layoutGrid", "isEnabled": true, "columnWidth": 84, "gutterWidth": 16, "horizontalOffset": 24, "numberOfColumns": 12, + "totalWidth": 1152, "guttersOutside": false, "drawVertical": true, "drawHorizontal": false, "gutterHeight": 8, "rowHeightMultiplication": 3}); + a["grid"] = json!({"_class": "simpleGrid", "isEnabled": true, "gridSize": 8, "thickGridTimes": 0}); + let (doc, _) = open(vec![a]); + let grids = format!("{:?}", node(&doc, "Board").grids); + assert_eq!(node(&doc, "Board").grids.len(), 2, "{grids}"); + assert!(grids.contains("12") && grids.contains("16"), "{grids}"); +} + +#[test] +fn smart_layout_warns_once() { + let g = |id: &str| { + with( + group(id, 0.0, 0.0, 10.0, 10.0, vec![rect(&format!("{id}r"), 0.0, 0.0, 10.0, 10.0)]), + "groupLayout", + json!({"_class": "MSImmutableInferredGroupLayout", "axis": 0}), + ) + }; + let (_, warnings) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![g("G1"), g("G2"), g("G3")])]); + assert_eq!(warnings.iter().filter(|w| w.contains("Smart Layout")).count(), 1, "{warnings:?}"); +} + +#[test] +fn locked_overrides_are_noted() { + let mut m = master("M", "S", "Locked", 50.0, 20.0, vec![text("L", 0.0, 0.0, 50.0, 20.0, "x", &[(0, 1, "Helvetica", 9.0, [0.0, 0.0, 0.0, 1.0])])]); + m["overrideProperties"] = json!([{"_class": "MSImmutableOverrideProperty", "overrideName": "L_stringValue", "canOverride": false}]); + let (_, warnings) = open(vec![m]); + assert!(warnings.iter().any(|w| w.contains("locked")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index 15079702..d25a6b56 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -102,6 +102,7 @@ pub(crate) fn component(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, ro if let Some(f) = master_fills(l) { node.insert("fills".into(), f); } + super::tree::extras(cx, l, h, &mut node); let sid = str_field(l, "symbolID").unwrap_or("").to_string(); cx.chain.push(sid); let kids = children(cx, l, world, (w, h), depth, false); @@ -139,6 +140,9 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo if let Some(st) = l.get("style") { super::style::apply(cx, st, w, h, &mut node); } + super::tree::extras(cx, l, h, &mut node); + // An instance shows its master's grids (else they'd differ, and count as an override). + super::proto::layout_grids(&m, h, &mut node); let overrides = overrides(l); let mut copy = m; if let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) { diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 481eec70..d63afa0f 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -75,6 +75,10 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { node.insert("name".into(), json!(str_field(page, "name").unwrap_or("Page"))); let kids = children(&mut cx, page, Affine::IDENTITY, (0.0, 0.0), 0, true); node.insert("children".into(), Value::Array(kids)); + let starts = super::proto::starting_points(page); + if !starts.is_empty() { + node.insert("flowStartingPoints".into(), Value::Array(starts)); + } canvases.push(Value::Object(node)); } let foreign = std::mem::take(&mut cx.symbols.foreign); @@ -120,6 +124,7 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: "slice" => "SLICE", "shapePath" | "star" | "polygon" | "triangle" => "VECTOR", "text" => "TEXT", + "MSImmutableHotspotLayer" | "hotspot" => "FRAME", other => { cx.warn(format!("Sketch {} layers are not supported yet and were skipped", clip(other))); return None; @@ -160,6 +165,11 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: _ => {} } styles(cx, l, w, h, &mut node); + extras(cx, l, h, &mut node); + if class == "MSImmutableHotspotLayer" || class == "hotspot" { + node.insert("clipsContent".into(), json!(false)); + node.remove("fills"); + } if matches!(ty, "FRAME" | "GROUP") { let kids = children(cx, l, world, (w, h), depth, false); node.insert("children".into(), Value::Array(kids)); @@ -175,6 +185,16 @@ pub(crate) fn styles(cx: &mut Cx, l: &Value, w: f64, h: f64, node: &mut JsonMap< cx.shared.link(&cx.style_defs, l, node); } +/// Layer `l`'s prototype link, export settings, layout grids (frames) and notes. +pub(crate) fn extras(cx: &mut Cx, l: &Value, h: f64, node: &mut JsonMap) { + super::proto::interactions(l, node); + super::proto::export_settings(cx, l, node); + if matches!(node.get("type").and_then(Value::as_str), Some("FRAME" | "COMPONENT" | "INSTANCE")) { + super::proto::layout_grids(l, h, node); + } + super::proto::notes(cx, l); +} + /// A clipping mask's flag and kind (outline or alpha). pub(crate) fn mask(l: &Value, node: &mut JsonMap) { if bool_field(l, "hasClippingMask") == Some(true) { From 233c8e97083dd80d598484740a2d0e6d0827d9f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 17:02:03 +0200 Subject: [PATCH 17/87] M6: Sketch import: local corpus check and docs FIGCRAFT_SKETCH_SAMPLES runs every .sketch file of a folder through file.open (opens with linked instances and finite geometry, or is refused as too old). File format parity gains a .sketch row; CLI, MCP, roadmap and ROADMAP mention the import. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 +- crates/engine/src/sketch_import_tests.rs | 44 ++++++++++++++++++++++++ docs/cli.md | 3 +- docs/file-format-parity.md | 4 ++- docs/mcp.md | 2 +- docs/roadmap.md | 5 +-- 6 files changed, 55 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f43cd015..3f57ed57 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-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (`.sketch` import, best effort (Sketch 48 and newer); community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .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 @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | | 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged | | 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index 10a7b9ca..c1ac6a59 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -175,3 +175,47 @@ fn sketch_instances_stay_live() { let b = doc.world_bounds(inst_tag).unwrap(); assert!((b.x0 - 180.0).abs() < 1e-6, "{b:?}"); } + +/// Local only (`FIGCRAFT_SKETCH_SAMPLES` names a folder of `.sketch` files): each opens (Sketch 48 +/// and newer) with its instances linked and finite geometry, or is refused as too old. +#[test] +fn local_sketch_samples_open() { + let Ok(dir) = std::env::var("FIGCRAFT_SKETCH_SAMPLES") else { return }; + let mut files: Vec = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).collect(); + files.retain(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sketch"))); + files.sort(); + assert!(!files.is_empty(), "no .sketch files in {dir}"); + for path in files { + let started = std::time::Instant::now(); + let mut s = Session::new(); + match s.execute("file.open", &json!({"path": path.to_string_lossy()})) { + Err(e) => { + let e = e.to_string(); + assert!(e.contains("older than 48"), "{}: {e}", path.display()); + eprintln!("{}: refused as too old", path.display()); + } + Ok(r) => { + let doc = s.doc().unwrap(); + let (mut layers, mut instances, mut unlinked) = (0, 0, 0); + for n in doc.nodes() { + layers += 1; + assert!( + n.width.is_finite() && n.height.is_finite() && n.transform.as_coeffs().iter().all(|c| c.is_finite()), + "{}: {}", + path.display(), + n.name + ); + if let NodeKind::Instance { component, .. } = n.kind { + instances += 1; + if !doc.contains(component) { + unlinked += 1; + } + } + } + assert_eq!(unlinked, 0, "{}", path.display()); + let notes = r.get("warnings").and_then(|w| w.as_array()).map_or(0, Vec::len); + eprintln!("{}: {layers} layers, {instances} instances, {notes} notes, {:?}", path.display(), started.elapsed()); + } + } + } +} diff --git a/docs/cli.md b/docs/cli.md index 699b6ba2..bf783d11 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -36,7 +36,8 @@ layers}]` with `--json`); their text renders in a fallback font. Every subcommand that reads a file also takes the public REST API file JSON (the response of `GET /v1/files/:key` or of its nodes endpoint, written to a `.json` file): it is imported on the fly. Image fills come from `images/.png` (or `.jpg`, …) next to the JSON when present, else a -grey placeholder. `.fig` files are read the same way (best effort, images from the file itself); +grey placeholder. `.fig` files are read the same way (best effort, images from the file itself), and +so are `.sketch` files saved by Sketch 48 or newer; `info` lists what wasn't imported as `note:` lines. ```text diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index adf0b0f2..ed2d3157 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-18 · **Change:** minor (REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (`.sketch` import, best effort, Sketch 48 and newer; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.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 @@ -18,6 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards (and Sketch 2025 frames) as frames with their background, groups, rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape or as boolean operations (mixed operations nested from the left), bitmaps, slices, hotspots; transforms (rotation, flips), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), resized by their constraints and still live; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Warnings: noise fills, motion and zoom blurs, embedded fonts (not used), Smart Layout and stacks (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (80,000 layers open in ~4 s, debug build); Sketch 2025 files on fixtures only | 5–10 (Smart Layout as auto layout, real Sketch 2025 files) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -165,6 +166,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Checked on fixtures and locally on one real Sketch 50 file and the IBM Carbon Design Kit (Sketch 47, 68, 83). Numbers unchanged | | 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..a56fbd95 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -303,7 +303,7 @@ Or in a project's `.mcp.json`: | `select {ids}` | Replace the selection (`[]` deselects). | | `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. | -| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open just gives its `index`) and the document's `document` outline. | +| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.sketch` (Sketch 48 and newer, best effort), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open just gives its `index`) and the document's `document` outline. | | `undo` / `redo` | Step through history. | | `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. A dragged layer drops into the frame or auto layout slot under the pointer, except one inside a group, a boolean group or an instance (outside its slots), which only moves. `pressure` (0–1) is pen pressure; a drag goes from `pressure` to `pressureTo` (see Brushes and pen pressure). | | `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity, arrows that pan the view when nothing is selected; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 8d63a469..fcca7553 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (`.sketch` import, best effort; community batch #753: Selection colors, smart-selection spacing, a Present tab; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -53,7 +53,7 @@ blockers. | M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 | | M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11; vector edit mode regions 2026-10-13) | spell check, Figma Draw multi-edit | 7–18 | | M5 Prototype & present | ~85% | media, controllers on macOS/Linux/FreeBSD, separate window | 2–5 | -| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 | +| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11; best-effort `.sketch` import 2026-10-21) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 | | M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 | | M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | | M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | M6: best-effort `.sketch` import (Sketch 48 and newer), see [file-format-parity](file-format-parity.md). Percentages unchanged | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | From 6640498195c07107c1b4c38a5ef2c9d4effe42a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 17:22:07 +0200 Subject: [PATCH 18/87] M6: Sketch import: hostile files can't panic, run out of memory or hang From the branch review: overrides that point at junk (shared styles that aren't objects, text runs that aren't) no longer panic; symbol expansion spends a work budget (JSON copied, overrides handed on to nested instances, grouped once by their first path segment), so small files can't expand into gigabytes; far path points stay in range; notes are capped and de-duplicated with a set; shared styles are looked up by index; a styled second mask's drawn copy isn't clipped by the first; JSON members are refused past 100 times their packed size or 2 GB in all, and an unreadable document.json keeps the pages. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/mod.rs | 56 ++++++++-- crates/format/src/sketch/path.rs | 6 +- crates/format/src/sketch/path_tests.rs | 13 +++ crates/format/src/sketch/shared.rs | 27 ++++- crates/format/src/sketch/style_tests.rs | 12 ++ crates/format/src/sketch/symbols.rs | 127 +++++++++++++++------- crates/format/src/sketch/symbols_tests.rs | 54 +++++++++ crates/format/src/sketch/tree.rs | 54 +++++++-- crates/format/src/sketch/tree_tests.rs | 52 +++++++++ 9 files changed, 337 insertions(+), 64 deletions(-) diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 3132e658..3c64b409 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -40,7 +40,7 @@ mod tree_tests; use std::collections::BTreeSet; use figcraft_doc::Document; -use serde_json::Value; +use serde_json::{Value, json}; use crate::FormatError; @@ -53,6 +53,15 @@ pub const NEWEST_TESTED_VERSION: u64 = 139; pub const TOO_OLD: &str = "saved by a Sketch older than 48; open it in a newer Sketch and save it again"; /// Largest JSON member read (a real page of a few thousand layers takes 33 MB). const MAX_JSON_MEMBER: u64 = 512 * 1024 * 1024; +/// Most bytes JSON members may unpack to, per compressed byte (real files pack 5–20 times; a +/// zero-filled array a thousand times), with a floor for small members. +const MAX_JSON_RATIO: u64 = 100; +const MIN_JSON_ALLOWANCE: u64 = 16 * 1024 * 1024; +/// Most JSON read from one file in all. +#[cfg(not(target_pointer_width = "32"))] +const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +const MAX_JSON_TOTAL: u64 = 512 * 1024 * 1024; /// Largest image member read. const MAX_IMAGE: u64 = 256 * 1024 * 1024; /// Most pages read from one file. @@ -165,22 +174,36 @@ pub(crate) struct Archive<'b> { impl<'b> Archive<'b> { fn open(bytes: &'b [u8], max_member: u64) -> Result { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| corrupt(e.to_string()))?; - let json = |z: &mut zip::ZipArchive>, member: &str, limit: u64| -> Result { - let data = crate::read_member(z, member, limit)?; - serde_json::from_slice(&data).map_err(|e| corrupt(format!("{member}: {e}"))) - }; - let meta = json(&mut z, "meta.json", MAX_JSON_MEMBER)?; + let mut total = 0u64; + let meta = json_member(&mut z, "meta.json", MAX_JSON_MEMBER, &mut total)?; if !meta.is_object() { return Err(corrupt("meta.json is not an object")); } if app_major(&meta).is_some_and(|v| v < MIN_SKETCH) { return Err(FormatError::Io(TOO_OLD.into())); } - let document = json(&mut z, "document.json", MAX_JSON_MEMBER)?; let mut warnings = Vec::new(); if meta.get("version").and_then(Value::as_u64).is_some_and(|v| v > NEWEST_TESTED_VERSION) { warnings.push("the file was saved by a newer Sketch than the import was checked with; some layers may differ".to_string()); } + // A document.json that can't be read (nested too deep, damaged) loses its shared styles and + // library copies, not the pages: they are then taken in archive order. + let document = match json_member(&mut z, "document.json", MAX_JSON_MEMBER, &mut total) { + Ok(d) if d.is_object() => d, + other => { + let why = other.err().map_or_else(|| "not an object".to_string(), |e| e.to_string()); + warnings.push(format!( + "the document's shared styles and library symbols were skipped: {}", + why.trim_start_matches("not a valid FigCraft file: ") + )); + let pages: Vec = z + .file_names() + .filter_map(|n| n.strip_prefix("pages/")?.strip_suffix(".json")) + .map(|id| json!({"_ref": format!("pages/{id}")})) + .collect(); + json!({"pages": pages}) + } + }; let refs = document.get("pages").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); let mut pages = Vec::new(); for r in refs.iter().take(MAX_PAGES) { @@ -188,7 +211,7 @@ impl<'b> Archive<'b> { warnings.push("pages the document lists without a valid reference were skipped".into()); continue; }; - match json(&mut z, &format!("pages/{id}.json"), max_member) { + match json_member(&mut z, &format!("pages/{id}.json"), max_member, &mut total) { Ok(page) if page.is_object() => pages.push((id.to_string(), page)), Ok(_) => warnings.push("pages that aren't JSON objects were skipped".into()), Err(e) => warnings.push(format!("a page was skipped: {}", e.to_string().trim_start_matches("not a valid FigCraft file: "))), @@ -212,6 +235,23 @@ impl<'b> Archive<'b> { } } +/// JSON member `name`, refused before unpacking when it would be larger than `limit`, than +/// [`MAX_JSON_RATIO`] times what it takes in the archive (past a floor), or than what is left of +/// the file's [`MAX_JSON_TOTAL`] (`total`: read so far). +fn json_member(z: &mut zip::ZipArchive>, name: &str, limit: u64, total: &mut u64) -> Result { + let (size, packed) = { + let f = z.by_name(name).map_err(|e| corrupt(format!("{name}: {e}")))?; + (f.size(), f.compressed_size()) + }; + let allowed = limit.min(packed.saturating_mul(MAX_JSON_RATIO).max(MIN_JSON_ALLOWANCE)).min(MAX_JSON_TOTAL.saturating_sub(*total)); + if size > allowed { + return Err(corrupt(format!("{name} is too large"))); + } + let data = crate::read_member(z, name, allowed)?; + *total += data.len() as u64; + serde_json::from_slice(&data).map_err(|e| corrupt(format!("{name}: {e}"))) +} + /// The major version of the Sketch that saved the file (`meta.json` `appVersion`, "50.2" → 50). fn app_major(meta: &Value) -> Option { meta.get("appVersion").and_then(Value::as_str)?.split('.').next()?.trim().parse().ok() diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 8fbe84cd..30a9f256 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -12,6 +12,8 @@ use serde_json::{Map as JsonMap, Value, json}; use super::tree::{Cx, base, layer, layers, local, size}; +/// Farthest a point or handle may be from its layer (the REST importer's own range). +const MAX_COORD: f64 = 1e7; /// Most points read from one path. const MAX_POINTS: usize = 100_000; @@ -36,7 +38,9 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option MAX_POINTS { cx.warn(format!("paths with more than {MAX_POINTS} points were cut")); } - let at = |p: &Value, key: &str| p.get(key).and_then(Value::as_str).and_then(parse_point).map(|(x, y)| (x * w, y * h)); + // Far points stay within the importer's coordinate range (a product could overflow). + let fit = |v: f64| if v.is_finite() { v.clamp(-MAX_COORD, MAX_COORD) } else { 0.0 }; + let at = |p: &Value, key: &str| p.get(key).and_then(Value::as_str).and_then(parse_point).map(|(x, y)| (fit(x * w), fit(y * h))); let mut vertices = Vec::new(); // Per vertex: its handles (relative to it) when it has them. let mut handles: Vec<((f64, f64), (f64, f64))> = Vec::new(); diff --git a/crates/format/src/sketch/path_tests.rs b/crates/format/src/sketch/path_tests.rs index 8e1528fb..fb03d74c 100644 --- a/crates/format/src/sketch/path_tests.rs +++ b/crates/format/src/sketch/path_tests.rs @@ -152,3 +152,16 @@ fn even_odd_winding() { let NodeKind::Vector { network } = &node(&doc, "Holes").kind else { panic!("not a vector") }; assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd); } + +#[test] +fn huge_points_stay_finite() { + let mut p = shape_path("Huge", 0.0, 0.0, 1e6, 1e6, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true); + p["points"][1]["point"] = json!("{1e308, 0}"); + p["points"][1]["curveMode"] = json!(2); + p["points"][1]["curveFrom"] = json!("{-1e308, 1e308}"); + let doc = open(vec![p]); + if let NodeKind::Vector { network } = &node(&doc, "Huge").kind { + assert!(network.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())); + assert!(network.segments.iter().all(|s| [s.ta.0, s.ta.1, s.tb.0, s.tb.1].iter().all(|c| c.is_finite()))); + } +} diff --git a/crates/format/src/sketch/shared.rs b/crates/format/src/sketch/shared.rs index 104a1931..ee122148 100644 --- a/crates/format/src/sketch/shared.rs +++ b/crates/format/src/sketch/shared.rs @@ -7,7 +7,7 @@ //! none. Layers link to them by `style.sharedObjectID`. Swatches (Color Variables, Sketch 69+) //! become colour variables in one collection; colours name theirs by `swatchID`. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Map as JsonMap, Value, json}; @@ -25,6 +25,9 @@ pub(crate) struct Shared { /// `(id, name, style)` of layer styles, then text styles. layer: Vec<(String, String, Value)>, text: Vec<(String, String, Value)>, + /// Positions in `layer` and `text` by id. + layer_index: BTreeMap, + text_index: BTreeMap, swatches: BTreeMap, } @@ -44,10 +47,16 @@ impl Shared { )) }; for o in objects("layerStyles").iter().chain(&foreign("foreignLayerStyles", "localSharedStyle")).take(MAX_SHARED) { - s.layer.extend(entry(o)); + if let Some(e) = entry(o).filter(|e| !s.layer_index.contains_key(&e.0)) { + s.layer_index.insert(e.0.clone(), s.layer.len()); + s.layer.push(e); + } } for o in objects("layerTextStyles").iter().chain(&foreign("foreignTextStyles", "localSharedStyle")).take(MAX_SHARED) { - s.text.extend(entry(o)); + if let Some(e) = entry(o).filter(|e| !s.text_index.contains_key(&e.0)) { + s.text_index.insert(e.0.clone(), s.text.len()); + s.text.push(e); + } } let foreign_swatches = [foreign("foreignSwatches", "localSwatch"), foreign("foreignSwatches", "localSharedSwatch")].concat(); for o in objects("sharedSwatches").iter().chain(&foreign_swatches).take(MAX_SHARED) { @@ -60,6 +69,12 @@ impl Shared { s } + /// The style of shared text (`text`) or layer style `id`. + pub(crate) fn style(&self, text: bool, id: &str) -> Option<&Value> { + let (index, list) = if text { (&self.text_index, &self.text) } else { (&self.layer_index, &self.layer) }; + list.get(*index.get(id)?).map(|e| &e.2) + } + pub(crate) fn has_swatch(&self, id: &str) -> bool { self.swatches.contains_key(id) } @@ -95,13 +110,13 @@ impl Shared { } /// Link `node` (layer `l`) to the styles made from its shared style. - pub(crate) fn link(&self, cx_defs: &[String], l: &Value, node: &mut JsonMap) { + pub(crate) fn link(&self, cx_defs: &BTreeSet, l: &Value, node: &mut JsonMap) { let Some(sid) = l.get("style").and_then(|s| str_field(s, "sharedObjectID")) else { return }; let mut refs = JsonMap::new(); let is_text = str_field(l, "_class") == Some("text"); - if is_text && self.text.iter().any(|(id, _, _)| id == sid) { + if is_text && self.text_index.contains_key(sid) { refs.insert("text".into(), json!(sid)); - } else if self.layer.iter().any(|(id, _, _)| id == sid) { + } else if self.layer_index.contains_key(sid) { for slot in ["fill", "stroke", "effect"] { let id = format!("{sid}:{slot}"); if cx_defs.contains(&id) { diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index ee428c88..b1b2801b 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -283,3 +283,15 @@ fn masked_icon_keeps_its_geometry() { assert!((b.x0 - 164.0).abs() < 1e-6 && (b.y0 - 41.0).abs() < 1e-6 && (b.width() - 45.0).abs() < 1e-6, "body {b:?}"); assert!(matches!(node(&doc, "Circle").kind, NodeKind::Ellipse { .. })); } + +#[test] +fn two_styled_masks_each_draw() { + let fill = json!([solid_fill(0.5, 0.5, 0.5, 1.0)]); + let m1 = styled(with(r("Mask1"), "hasClippingMask", json!(true)), "fills", fill.clone()); + let m2 = styled(with(rect("Mask2", 200.0, 0.0, 100.0, 50.0), "hasClippingMask", json!(true)), "fills", fill); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 300.0, 50.0, vec![m1, r("A"), m2, rect("B", 200.0, 0.0, 10.0, 10.0)])]); + // The drawn copy of the second mask isn't inside the first mask's group. + let copy = doc.nodes().find(|n| n.name == "Mask2 (drawn)").unwrap(); + let first_group = node(&doc, "Mask1").parent.unwrap(); + assert_ne!(copy.parent, Some(first_group)); +} diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index d25a6b56..b87b7138 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -14,8 +14,11 @@ use std::collections::BTreeMap; use figcraft_doc::geom::Affine; use serde_json::{Map as JsonMap, Value, json}; +use super::shared::Shared; use super::tree::{Cx, base, bool_field, children, local, size, str_field}; +/// Work units an override handed on to a nested instance costs (the JSON values it adds). +const OVERRIDE_COST: u64 = 8; /// Most masters read from one file. const MAX_MASTERS: usize = 100_000; @@ -23,6 +26,8 @@ const MAX_MASTERS: usize = 100_000; #[derive(Default)] pub(crate) struct Symbols { masters: BTreeMap, + /// What expanding each master costs (see [`weight`]). + weights: BTreeMap, /// Masters copied from libraries that no page holds (they get a page of their own), with the /// libraries' names. pub foreign: Vec, @@ -61,8 +66,11 @@ impl Symbols { if self.masters.len() >= MAX_MASTERS { return; } - if let Some(id) = str_field(&m, "symbolID") { - self.masters.entry(id.to_string()).or_insert(m); + if let Some(id) = str_field(&m, "symbolID") + && !self.masters.contains_key(id) + { + self.weights.insert(id.to_string(), weight(&m, 0)); + self.masters.insert(id.to_string(), m); } } @@ -71,6 +79,19 @@ impl Symbols { } } +/// What copying and mapping `v` costs, in work units: one per JSON value, one per 8 bytes of text. +fn weight(v: &Value, depth: usize) -> u64 { + if depth > 256 { + return 1; + } + match v { + Value::String(s) => 1 + s.len() as u64 / 8, + Value::Array(a) => 1 + a.iter().map(|x| weight(x, depth + 1)).sum::(), + Value::Object(o) => 1 + o.iter().map(|(k, x)| k.len() as u64 / 8 + weight(x, depth + 1)).sum::(), + _ => 1, + } +} + fn masters_in<'v>(l: &'v Value, out: &mut Vec<&'v Value>, depth: usize) { if depth > 64 { return; @@ -117,6 +138,12 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo let (w, h) = size(l); let world = parent * local(l, w, h); let sid = str_field(l, "symbolID").unwrap_or(""); + let cost = cx.symbols.weights.get(sid).copied().unwrap_or(0); + if cx.symbols.master(sid).is_some() && !cx.spend(cost) { + let mut node = base(l, "FRAME", parent, world, w, h, root); + node.insert("children".into(), json!([])); + return Some(Value::Object(node)); + } let Some(m) = cx.symbols.master(sid).cloned() else { cx.warn("instances of symbols that aren't in the file were imported as empty frames"); let mut node = base(l, "FRAME", parent, world, w, h, root); @@ -144,9 +171,17 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo // An instance shows its master's grids (else they'd differ, and count as an override). super::proto::layout_grids(&m, h, &mut node); let overrides = overrides(l); + let nested = nested(&overrides); let mut copy = m; + let mut budget = cx.work_left(); if let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) { - apply(layers, &overrides, cx.document, 0); + apply(layers, &overrides, &nested, &cx.shared, 0, &mut budget); + } + let used = cx.work_left().saturating_sub(budget); + cx.spend(used); + if budget == 0 { + // The budget ran out handing overrides on: noted. + cx.spend(1); } let (mw, mh) = size(©); cx.chain.push(sid.to_string()); @@ -172,9 +207,30 @@ fn overrides(l: &Value) -> BTreeMap { .collect() } +/// The overrides of layers inside nested instances, by the nested instance's id: `X/rest_prop` → +/// `X` → [(`rest_prop`, value)]. +fn nested(ov: &BTreeMap) -> BTreeMap<&str, Vec<(&str, &Value)>> { + let mut out: BTreeMap<&str, Vec<(&str, &Value)>> = BTreeMap::new(); + for (k, v) in ov { + if let Some((first, rest)) = k.split_once('/') { + out.entry(first).or_default().push((rest, v)); + } + } + out +} + /// Apply `ov` to the layers of a master's copy: to the layers it names, and passed on to nested /// instances for the layers inside them (an outer override wins over the nested instance's own). -fn apply(layers: &mut [Value], ov: &BTreeMap, document: &Value, depth: usize) { +/// Each override handed to a nested instance costs [`OVERRIDE_COST`] units of `budget`; once it is spent, the +/// rest aren't handed on. +fn apply( + layers: &mut [Value], + ov: &BTreeMap, + nested: &BTreeMap<&str, Vec<(&str, &Value)>>, + shared: &Shared, + depth: usize, + budget: &mut u64, +) { if depth > 64 || ov.is_empty() { return; } @@ -188,14 +244,12 @@ fn apply(layers: &mut [Value], ov: &BTreeMap, document: &Value, d Some(other) => l["symbolID"] = json!(other), None => {} } - let prefix = format!("{id}/"); - let inner: Vec = ov - .iter() - .filter_map(|(k, v)| k.strip_prefix(&prefix).map(|rest| json!({"_class": "overrideValue", "overrideName": rest, "value": v}))) - .collect(); - if !inner.is_empty() { + let inner = nested.get(id.as_str()).map(Vec::as_slice).unwrap_or_default(); + let take = usize::try_from(*budget / OVERRIDE_COST).unwrap_or(usize::MAX).min(inner.len()); + *budget -= take as u64 * OVERRIDE_COST; + if take > 0 { let mut own = l.get("overrideValues").and_then(Value::as_array).cloned().unwrap_or_default(); - own.extend(inner); + own.extend(inner.iter().take(take).map(|(rest, v)| json!({"_class": "overrideValue", "overrideName": rest, "value": v}))); l["overrideValues"] = Value::Array(own); } continue; @@ -212,24 +266,29 @@ fn apply(layers: &mut [Value], ov: &BTreeMap, document: &Value, d } _ => {} } - for (prop, list) in [("layerStyle", "layerStyles"), ("textStyle", "layerTextStyles")] { + for (prop, text) in [("layerStyle", false), ("textStyle", true)] { let sid = get(prop).and_then(Value::as_str); - if let Some((sid, style)) = sid.and_then(|sid| Some((sid, shared_style(document, list, sid)?))) { - l["style"] = style.clone(); + // Shared styles that aren't objects are junk: left alone. + let Some((sid, style)) = sid.and_then(|sid| Some((sid, shared.style(text, sid).filter(|s| s.is_object())?))) else { continue }; + let mut style = style.clone(); + if let Some(m) = style.as_object_mut() { // The layer now uses that shared style: it links to it. - l["style"]["sharedObjectID"] = json!(sid); - if prop == "textStyle" - && let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) - && let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) - { - for r in runs { - r["attributes"] = enc.clone(); - } + m.insert("sharedObjectID".into(), json!(sid)); + } + if text + && let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) + && let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) + { + for r in runs.iter_mut().filter_map(Value::as_object_mut) { + r.insert("attributes".into(), enc.clone()); } } + if let Some(m) = l.as_object_mut() { + m.insert("style".into(), style); + } } if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { - apply(kids, ov, document, depth + 1); + apply(kids, ov, nested, shared, depth + 1, budget); } } } @@ -241,28 +300,16 @@ fn set_string(l: &mut Value, s: &str) { let Some(a) = l.get_mut("attributedString").and_then(Value::as_object_mut) else { return }; a.insert("string".into(), json!(s)); let runs = match first { - Some(mut r) => { - r["location"] = json!(0); - r["length"] = json!(units); - vec![r] + Some(Value::Object(mut r)) => { + r.insert("location".into(), json!(0)); + r.insert("length".into(), json!(units)); + vec![Value::Object(r)] } - None => Vec::new(), + _ => Vec::new(), }; a.insert("attributes".into(), Value::Array(runs)); } -/// The style of shared style `id` from the document's `list` (local) or its library copies. -pub(crate) fn shared_style<'v>(document: &'v Value, list: &str, id: &str) -> Option<&'v Value> { - let local = document.get(list).and_then(|c| c.get("objects")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); - let foreign_key = if list == "layerStyles" { "foreignLayerStyles" } else { "foreignTextStyles" }; - let foreign = document.get(foreign_key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); - local - .iter() - .find(|s| str_field(s, "do_objectID") == Some(id)) - .or_else(|| foreign.iter().filter_map(|f| f.get("localSharedStyle")).find(|s| str_field(s, "do_objectID") == Some(id))) - .and_then(|s| s.get("value")) -} - /// Make the ids of an instance's layers its own (`;`): the master's layers keep /// theirs. fn prefix_ids(v: &mut Value, prefix: &str, depth: usize) { diff --git a/crates/format/src/sketch/symbols_tests.rs b/crates/format/src/sketch/symbols_tests.rs index e2204399..4c98ebe2 100644 --- a/crates/format/src/sketch/symbols_tests.rs +++ b/crates/format/src/sketch/symbols_tests.rs @@ -163,3 +163,57 @@ fn resized_instance_follows_constraints() { let b = doc.world_bounds(tag.id).unwrap(); assert!((b.x0 - 180.0).abs() < 1e-6 && (b.width() - 20.0).abs() < 1e-6, "{b:?}"); } + +/// Overrides pointing at junk (a shared style that isn't an object, text runs that aren't) never +/// panic. +#[test] +fn override_application_survives_junk() { + let mut d = document(&["P", "S"]); + d["layerStyles"]["objects"] = json!([{"_class": "sharedStyle", "do_objectID": "S1", "name": "Junk", "value": 5}]); + d["layerTextStyles"]["objects"] = + json!([{"_class": "sharedStyle", "do_objectID": "T1", "name": "Junk text", "value": {"textStyle": {"encodedAttributes": {}}}}]); + let mut t = text("Label", 0.0, 0.0, 50.0, 20.0, "x", &[(0, 1, "Helvetica", 9.0, BLACK)]); + t["attributedString"]["attributes"] = json!([5, "x", null]); + let m = master("M", "S", "Junk master", 50.0, 20.0, vec![rect("C", 0.0, 0.0, 10.0, 10.0), t]); + let inst = instance( + "I1", + "S", + 0.0, + 0.0, + 50.0, + 20.0, + &[("C_layerStyle", json!("S1")), ("Label_textStyle", json!("T1")), ("Label_stringValue", json!("y"))], + ); + let z = sketch_zip("50.2", 103, d, &[page("P", "Page", vec![inst]), page("S", "Symbols", vec![m])], &[]); + assert!(import(&z, "f").is_ok()); +} + +/// A huge master expanded by many instances stops at the file's work budget. +#[test] +fn expansion_work_is_bounded() { + let long = "x".repeat(200_000); + let m = master("M", "S", "Heavy", 50.0, 20.0, vec![text("Long", 0.0, 0.0, 50.0, 20.0, &long, &[(0, 200_000, "Helvetica", 9.0, BLACK)])]); + let many: Vec = (0..3000).map(|i| instance(&format!("I{i}"), "S", 0.0, 0.0, 50.0, 20.0, &[])).collect(); + let z = sketch_zip("50.2", 103, document(&["P", "S"]), &[page("P", "Page", many), page("S", "Symbols", vec![m])], &[]); + let (_, warnings) = import(&z, "f").unwrap(); + assert!(warnings.iter().any(|w| w.contains("expand")), "{warnings:?}"); +} + +/// Outer overrides fanned out to many nested instances stop at the work budget. +#[test] +fn override_fan_out_is_bounded() { + let inner = master("MB", "SB", "Cell", 10.0, 10.0, vec![text("T", 0.0, 0.0, 10.0, 10.0, "1", &[(0, 1, "Helvetica", 9.0, BLACK)])]); + let cells: Vec = (0..3000).map(|_| instance("X", "SB", 0.0, 0.0, 10.0, 10.0, &[])).collect(); + let grid = master("MA", "SA", "Grid", 100.0, 100.0, cells); + let overrides: Vec<(String, Value)> = (0..3000).map(|i| (format!("X/N{i}_stringValue"), json!("v"))).collect(); + let refs: Vec<(&str, Value)> = overrides.iter().map(|(k, v)| (k.as_str(), v.clone())).collect(); + let z = sketch_zip( + "50.2", + 103, + document(&["P", "S"]), + &[page("P", "Page", vec![instance("I", "SA", 0.0, 0.0, 100.0, 100.0, &refs)]), page("S", "Symbols", vec![inner, grid])], + &[], + ); + let (_, warnings) = import(&z, "f").unwrap(); + assert!(warnings.iter().any(|w| w.contains("expand")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index d63afa0f..c1bf0432 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -6,6 +6,16 @@ use serde_json::{Map as JsonMap, Value, json}; use super::Archive; +/// Most distinct notes kept. +const MAX_NOTES: usize = 200; +/// Work units symbol expansion may spend on one file (one per JSON value copied, see +/// `symbols::weight`): about three times what the largest design kit checked spends (21M, with +/// 2 GB at its peak), well below what runs out of memory; less on 32-bit targets (the web), where +/// such a kit keeps its first instances and notes the rest. +#[cfg(not(target_pointer_width = "32"))] +const MAX_WORK: u64 = 60_000_000; +#[cfg(target_pointer_width = "32")] +const MAX_WORK: u64 = 15_000_000; /// Deepest layer nesting followed (deeper layers are skipped with a warning). const MAX_DEPTH: usize = 200; /// Largest size and farthest position kept (the REST importer's own bounds). @@ -23,13 +33,16 @@ pub(crate) struct Map { } /// The mapping's state while walking the file. -pub(crate) struct Cx<'a> { - /// `document.json`: shared styles and library copies. - pub document: &'a Value, +pub(crate) struct Cx { pub symbols: super::symbols::Symbols, pub shared: super::shared::Shared, /// Ids of the styles made from the shared styles. - pub style_defs: Vec, + pub style_defs: std::collections::BTreeSet, + /// Work spent expanding symbols, and the most the file may take (see `symbols::weight`). + pub work: u64, + pub max_work: u64, + /// Notes given so far (each once). + pub seen: std::collections::BTreeSet, /// The symbols being expanded, outermost first (a symbol inside itself stops there). pub chain: Vec, pub warnings: Vec, @@ -40,13 +53,33 @@ pub(crate) struct Cx<'a> { pub max_nodes: usize, } -impl Cx<'_> { +impl Cx { pub(crate) fn warn(&mut self, msg: impl Into) { let msg = msg.into(); - if !self.warnings.contains(&msg) { + if self.seen.len() > MAX_NOTES || !self.seen.insert(msg.clone()) { + return; + } + if self.seen.len() > MAX_NOTES { + self.warnings.push("more notes were left out".into()); + } else { self.warnings.push(msg); } } + + /// Spend `n` units of the work budget; false (with a note, once) when it would run out. + pub(crate) fn spend(&mut self, n: u64) -> bool { + if self.work.saturating_add(n) > self.max_work { + self.work = self.max_work; + self.warn("the file's symbols expand to more than the import handles; the instances past that were imported as empty frames"); + return false; + } + self.work += n; + true + } + + pub(crate) fn work_left(&self) -> u64 { + self.max_work.saturating_sub(self.work) + } } /// Map the pages of `archive` to a REST file JSON named `name`. @@ -54,10 +87,12 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { let symbols = super::symbols::Symbols::read(&archive.document, &archive.pages); let shared = super::shared::Shared::read(&archive.document); let mut cx = Cx { - document: &archive.document, symbols, shared, - style_defs: Vec::new(), + style_defs: std::collections::BTreeSet::new(), + work: 0, + max_work: MAX_WORK, + seen: std::collections::BTreeSet::new(), chain: Vec::new(), warnings: Vec::new(), networks: Vec::new(), @@ -319,7 +354,8 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), .flat_map(|(breaks, n)| match drawn_mask(&n) { // Sketch also draws a mask with its own style, under what it clips; a FigCraft mask // only clips, so a plain copy of it goes underneath. - Some(copy) => vec![(false, copy), (breaks, n)], + // The copy ends an earlier mask's chain as the mask itself would. + Some(copy) => vec![(breaks, copy), (breaks, n)], None => vec![(breaks, n)], }) .collect(); diff --git a/crates/format/src/sketch/tree_tests.rs b/crates/format/src/sketch/tree_tests.rs index c9f99558..91aeba3a 100644 --- a/crates/format/src/sketch/tree_tests.rs +++ b/crates/format/src/sketch/tree_tests.rs @@ -179,3 +179,55 @@ fn sketch_2025_frames_and_graphics() { assert!(!node(&doc, "Frame").clip); assert!(matches!(node(&doc, "Graphic").kind, NodeKind::Group)); } + +#[test] +fn many_distinct_warnings_are_capped() { + let layers: Vec = (0..5000) + .map(|i| { + let mut l = rect(&format!("R{i}"), 0.0, 0.0, 1.0, 1.0); + l["_class"] = json!(format!("MSOdd{i}")); + l + }) + .collect(); + let (_, warnings) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, layers)]); + assert!(warnings.len() <= 250, "{}", warnings.len()); +} + +#[test] +fn bomb_pages_are_skipped() { + // 64 MB of `[0,0,…]` packs into a few hundred kilobytes. + let bomb = format!("{{\"_class\":\"page\",\"do_objectID\":\"B\",\"name\":\"Bomb\",\"layers\":[],\"junk\":[{}0]}}", "0,".repeat(32 * 1024 * 1024)); + let mut out = Vec::new(); + { + use std::io::Write; + let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out)); + let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + z.start_file("document.json", o).unwrap(); + z.write_all(document(&["B", "P"]).to_string().as_bytes()).unwrap(); + z.start_file("meta.json", o).unwrap(); + z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": "50.2", "version": 103}).to_string().as_bytes()).unwrap(); + z.start_file("pages/B.json", o).unwrap(); + z.write_all(bomb.as_bytes()).unwrap(); + z.start_file("pages/P.json", o).unwrap(); + z.write_all(page("P", "Fine", vec![]).to_string().as_bytes()).unwrap(); + z.finish().unwrap(); + } + let (doc, warnings) = import(&out, "f").unwrap(); + let names: Vec = doc.pages.iter().map(|p| doc.node(*p).unwrap().name.clone()).collect(); + assert!(!names.contains(&"Bomb".to_string()) && names.contains(&"Fine".to_string()), "{names:?}"); + assert!(warnings.iter().any(|w| w.contains("too large")), "{warnings:?}"); +} + +#[test] +fn deep_document_json_still_opens_the_pages() { + let mut deep = json!({"_class": "symbolMaster"}); + for _ in 0..150 { + deep = json!({"_class": "group", "layers": [deep]}); + } + let mut d = document(&["P"]); + d["foreignSymbols"] = json!([{"symbolMaster": deep}]); + let z = sketch_zip("50.2", 103, d, &[page("P", "Kept", vec![])], &[]); + let (doc, warnings) = import(&z, "f").unwrap(); + assert!(doc.pages.iter().any(|p| doc.node(*p).unwrap().name == "Kept")); + assert!(!warnings.is_empty()); +} From 5b9e18317d25736cf89523ce0505c41ccfd3da1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 18:05:36 +0200 Subject: [PATCH 19/87] M6: Sketch import: match Sketch's own rendering Compared artboard by artboard with Sketch's renders of a real Sketch 50 file (sketchtool, black-box). Shape groups whose children have no operation are one compound path filled by the group's winding rule (read from the layer), not a union: rings, map streets and icons keep their holes. Filled open paths close, as Sketch fills them. Flips apply after rotation. Text with a fixed line height sits where Sketch's line spacing behaviour puts it. Edited ovals and rectangles keep their points. Bitmap colour adjustments become image filters. scale 0 means unscaled. A local test compares sample files with sketchtool's renders. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sketch_import_tests.rs | 85 +++++++++ crates/format/src/sketch/path.rs | 199 +++++++++++++++++----- crates/format/src/sketch/path_tests.rs | 68 ++++++++ crates/format/src/sketch/style.rs | 15 ++ crates/format/src/sketch/style_tests.rs | 19 +++ crates/format/src/sketch/symbols.rs | 3 +- crates/format/src/sketch/symbols_tests.rs | 9 + crates/format/src/sketch/text.rs | 23 +++ crates/format/src/sketch/text_tests.rs | 20 +++ crates/format/src/sketch/tree.rs | 23 ++- crates/format/src/sketch/tree_tests.rs | 25 +++ docs/file-format-parity.md | 2 +- 12 files changed, 446 insertions(+), 45 deletions(-) diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index c1ac6a59..cee351b8 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -219,3 +219,88 @@ fn local_sketch_samples_open() { } } } + +/// Local only (`FIGCRAFT_SKETCH_SAMPLES` and `FIGCRAFT_SKETCHTOOL`, the path of Sketch's +/// `sketchtool`): every artboard of each sample (Sketch 48 and newer) drawn by FigCraft is compared +/// with Sketch's own export of it. Prints the share of pixels that differ per artboard (a channel +/// off by more than 40, on white); fails past 10% (a broken artboard, not antialiasing or fonts). +#[test] +fn local_sketch_samples_match_sketch() { + let (Ok(dir), Ok(tool)) = (std::env::var("FIGCRAFT_SKETCH_SAMPLES"), std::env::var("FIGCRAFT_SKETCHTOOL")) else { return }; + // Text draws in the fonts Sketch uses only once the system fonts are known. + figcraft_text::fontdb::scan_system_fonts(); + let mut files: Vec = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).collect(); + files.retain(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sketch"))); + files.sort(); + let mut worst: Vec<(f64, String)> = Vec::new(); + for path in files { + let mut s = Session::new(); + if s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err() { + continue; + } + let out = std::env::temp_dir().join(format!("figcraft-sketch-ref-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + let status = std::process::Command::new(&tool) + .args(["export", "artboards"]) + .arg(&path) + .arg(format!("--output={}", out.display())) + .args(["--formats=png", "--scales=1", "--use-id-for-name=YES", "--include-symbols=NO"]) + .output() + .unwrap(); + assert!(status.status.success(), "sketchtool failed on {}", path.display()); + // Artboards by page and order, from the file itself. + let archive = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap(); + let mut archive = archive; + let mut read = |name: &str| -> Value { + let mut s = String::new(); + std::io::Read::read_to_string(&mut archive.by_name(name).unwrap(), &mut s).unwrap(); + serde_json::from_str(&s).unwrap() + }; + let document = read("document.json"); + let doc = s.doc().unwrap(); + let refs: Vec = document["pages"].as_array().unwrap().iter().map(|r| r["_ref"].as_str().unwrap().to_string()).collect(); + for (page_ref, page_id) in refs.iter().zip(doc.pages.iter()) { + let page = read(&format!("{page_ref}.json")); + let boards: Vec = page["layers"] + .as_array() + .unwrap() + .iter() + .filter(|l| l["_class"] == "artboard") + .map(|l| l["do_objectID"].as_str().unwrap().to_string()) + .collect(); + let frames: Vec = + doc.node(*page_id).unwrap().children.iter().copied().filter(|c| matches!(doc.node(*c).unwrap().kind, NodeKind::Frame)).collect(); + assert_eq!(boards.len(), frames.len(), "{}: artboards of {page_ref}", path.display()); + for (id, frame) in boards.iter().zip(frames) { + let Ok(reference) = image::open(out.join(format!("{id}.png"))) else { continue }; + let reference = reference.to_rgba8(); + let ours = figcraft_render::render_node(doc, frame, 1.0).unwrap(); + let (w, h) = (reference.width().min(ours.width()), reference.height().min(ours.height())); + let ours_rgba = figcraft_render::to_rgba8(&ours); + // Both on white, straight alpha. + let on_white = |c: &[u8], i: usize| { + let a = f64::from(c[3]) / 255.0; + f64::from(c[i]) * a + 255.0 * (1.0 - a) + }; + let mut bad = 0u64; + for y in 0..h { + for x in 0..w { + let r = reference.get_pixel(x, y).0; + let at = ((y * ours.width() + x) * 4) as usize; + let Some(o) = ours_rgba.get(at..at + 4) else { continue }; + if (0..3).any(|i| (on_white(&r, i) - on_white(o, i)).abs() > 40.0) { + bad += 1; + } + } + } + let share = bad as f64 * 100.0 / (f64::from(w) * f64::from(h)).max(1.0); + let name = doc.node(frame).unwrap().name.clone(); + eprintln!("{share:6.2}% {} {name}", path.file_name().unwrap().to_string_lossy()); + worst.push((share, format!("{}: {name}", path.display()))); + } + } + let _ = std::fs::remove_dir_all(&out); + } + worst.sort_by(|a, b| b.0.total_cmp(&a.0)); + assert!(worst.first().is_none_or(|w| w.0 < 10.0), "{worst:?}"); +} diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 30a9f256..149b4870 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -4,7 +4,10 @@ //! and `curveTo` (the handle arriving at it) are `"{x, y}"` strings in 0–1 of the layer's frame; //! values outside that range are allowed. Sketch 51 and older wrap every shape in a `shapeGroup` //! that carries the style; its children combine left to right, each with its own -//! `booleanOperation` (−1 none and 0 union, 1 subtract, 2 intersect, 3 difference). +//! `booleanOperation` (0 union, 1 subtract, 2 intersect, 3 difference). Children with −1 (none) +//! don't combine: they join the path before them into one compound path, filled by the winding +//! rule (`windingRule` on the layer, 1 even-odd: inner paths are holes). Sketch fills an open path +//! as if it were closed. use figcraft_doc::geom::{Affine, polygon_points, star_points}; use figcraft_doc::{Region, Segment, VectorNetwork, Vertex, WindingRule}; @@ -32,8 +35,26 @@ pub(crate) fn parse_point(s: &str) -> Option<(f64, f64)> { Some((n(x)?, n(y)?)) } -/// The network of a path layer of `w`×`h` (`None` when it has no usable points). -pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option { +/// A layer's winding rule: on the layer (Sketch 51 and older), else in its style. +pub(crate) fn winding(l: &Value) -> WindingRule { + let rule = l.get("windingRule").or_else(|| l.get("style").and_then(|s| s.get("windingRule"))); + match rule.and_then(Value::as_i64) { + Some(1) => WindingRule::EvenOdd, + _ => WindingRule::NonZero, + } +} + +/// Whether a style shows any fill or border. +fn shows(style: Option<&Value>, key: &str) -> bool { + style + .and_then(|s| s.get(key)) + .and_then(Value::as_array) + .is_some_and(|l| l.iter().any(|f| f.get("isEnabled").and_then(Value::as_bool) != Some(false))) +} + +/// The network of a path layer of `w`×`h` (`None` when it has no usable points). `fill_open`: an +/// open path gets a closing segment and a fill region, as Sketch fills it. +pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64, fill_open: bool) -> Option { let pts = l.get("points").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); if pts.len() > MAX_POINTS { cx.warn(format!("paths with more than {MAX_POINTS} points were cut")); @@ -61,20 +82,20 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option 1; + let open = !l.get("isClosed").and_then(Value::as_bool).unwrap_or(false); + let closed = n > 1 && (!open || (fill_open && n > 2)); let count = if closed { n } else { n - 1 }; let segments: Vec = (0..count) .map(|i| { let j = (i + 1) % n; - let ta = handles.get(i).map_or((0.0, 0.0), |h| h.0); - let tb = handles.get(j).map_or((0.0, 0.0), |h| h.1); + // The edge that closes an open path for its fill is straight. + let closing = open && j == 0; + let ta = if closing { (0.0, 0.0) } else { handles.get(i).map_or((0.0, 0.0), |h| h.0) }; + let tb = if closing { (0.0, 0.0) } else { handles.get(j).map_or((0.0, 0.0), |h| h.1) }; Segment { a: i, b: j, ta, tb } }) .collect(); - let winding = match l.get("style").and_then(|s| s.get("windingRule")).and_then(Value::as_i64) { - Some(1) => WindingRule::EvenOdd, - _ => WindingRule::NonZero, - }; + let winding = winding(l); let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding }] } else { Vec::new() }; Some(VectorNetwork { vertices, segments, regions }) } @@ -82,7 +103,14 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option Option { - let net = network(cx, l, w, h)?; + // Inside a shape group the group's fill decides; a shape of its own, its style. + let style = l.get("style"); + let filled = cx.operand_fill.unwrap_or_else(|| shows(style, "fills")); + let open = !l.get("isClosed").and_then(Value::as_bool).unwrap_or(false); + if open && filled && (shows(style, "borders") || cx.operand_border) { + cx.warn("open paths with both a fill and a border were closed; the border runs along the closing edge too"); + } + let net = network(cx, l, w, h, filled)?; let class = l.get("_class").and_then(Value::as_str).unwrap_or(""); let count = l.get("numberOfPoints").and_then(Value::as_u64).filter(|c| (3..=120).contains(c)).and_then(|c| u32::try_from(c).ok()); let plain = net.vertices.iter().all(|v| v.radius == 0.0) && net.segments.iter().all(|s| s.ta == (0.0, 0.0) && s.tb == (0.0, 0.0)); @@ -123,41 +151,98 @@ pub(crate) fn put_shape(cx: &mut Cx, shape: Shape, node: &mut JsonMap Option { let (w, h) = size(l); let world = parent * local(l, w, h); let kids = layers(l); - if let [only] = kids { - let mut node = layer(cx, only, world, depth + 1, false)?; - let (cw, ch) = size(only); - let child_world = world * local(only, cw, ch); - let group = base(l, "GROUP", parent, child_world, cw, ch, root); - if let Some(m) = node.as_object_mut() { - for key in ["id", "name", "relativeTransform", "absoluteBoundingBox", "constraints", "locked"] { - match group.get(key) { - Some(v) => m.insert(key.into(), v.clone()), - None => m.remove(key), - }; - } - let visible = group.get("visible").and_then(Value::as_bool).unwrap_or(true) && m.get("visible").and_then(Value::as_bool).unwrap_or(true); - m.insert("visible".into(), json!(visible)); - if l.get("style").is_some() { - super::style::clear(m); - m.remove("styles"); - super::tree::styles(cx, l, cw, ch, m); - super::tree::extras(cx, l, ch, m); - } - super::tree::mask(l, m); + let saved = (cx.operand_fill, cx.operand_border); + cx.operand_border = shows(l.get("style"), "borders"); + let out = if let [only] = kids { + cx.operand_fill = Some(shows(l.get("style"), "fills")); + single(cx, l, only, parent, world, depth, root) + } else { + // Operands of a boolean operation (or a compound path) give area: open ones close. + cx.operand_fill = Some(true); + combined(cx, l, kids, parent, world, (w, h), depth, root) + }; + (cx.operand_fill, cx.operand_border) = saved; + out +} + +/// A shape group of one shape: that shape in the group's place, with its name and style. +fn single(cx: &mut Cx, l: &Value, only: &Value, parent: Affine, world: Affine, depth: usize, root: bool) -> Option { + let mut node = layer(cx, only, world, depth + 1, false)?; + let (cw, ch) = size(only); + let child_world = world * local(only, cw, ch); + let group = base(l, "GROUP", parent, child_world, cw, ch, root); + if let Some(m) = node.as_object_mut() { + for key in ["id", "name", "relativeTransform", "absoluteBoundingBox", "constraints", "locked"] { + match group.get(key) { + Some(v) => m.insert(key.into(), v.clone()), + None => m.remove(key), + }; } - return Some(node); + let visible = group.get("visible").and_then(Value::as_bool).unwrap_or(true) && m.get("visible").and_then(Value::as_bool).unwrap_or(true); + m.insert("visible".into(), json!(visible)); + if l.get("style").is_some() { + super::style::clear(m); + m.remove("styles"); + super::tree::styles(cx, l, cw, ch, m); + super::tree::extras(cx, l, ch, m); + } + super::tree::mask(l, m); + } + Some(node) +} + +/// A shape group of several shapes: one compound path, or boolean operations over runs. +fn combined(cx: &mut Cx, l: &Value, kids: &[Value], parent: Affine, world: Affine, (w, h): (f64, f64), depth: usize, root: bool) -> Option { + // Runs: a child with an operation starts one, children without (−1) join it. + let mut runs: Vec> = Vec::new(); + for (i, c) in kids.iter().enumerate() { + match runs.last_mut() { + Some(run) if i > 0 && c.get("booleanOperation").and_then(Value::as_i64).unwrap_or(-1) < 0 => run.push(c), + _ => runs.push(vec![c]), + } + } + let winding = winding(l); + if let [run] = runs.as_slice() + && let Some(net) = compound(cx, run, winding) + { + // No operations at all: the group is one path. + let mut node = base(l, "VECTOR", parent, world, w, h, root); + node.insert("vectorNetworkRef".into(), json!(cx.networks.len())); + cx.networks.push(Some(net)); + super::tree::mask(l, &mut node); + super::tree::styles(cx, l, w, h, &mut node); + super::tree::extras(cx, l, h, &mut node); + return Some(Value::Object(node)); } let mut chain: Vec = Vec::new(); let mut op = "UNION"; - for (i, c) in kids.iter().enumerate() { - let Some(child) = layer(cx, c, world, depth + 2, false) else { continue }; - let this = boolean_op(c); + for (i, run) in runs.iter().enumerate() { + let operands: Vec = match run.as_slice() { + [one] => layer(cx, one, world, depth + 2, false).into_iter().collect(), + more => match compound(cx, more, winding) { + Some(net) => { + let mut node = base(more.first().copied().unwrap_or(l), "VECTOR", world, world, w, h, false); + node.remove("id"); + node.insert("vectorNetworkRef".into(), json!(cx.networks.len())); + cx.networks.push(Some(net)); + vec![Value::Object(node)] + } + // Not all paths: each one an operand of its own. + None => more.iter().filter_map(|c| layer(cx, c, world, depth + 2, false)).collect(), + }, + }; + if operands.is_empty() { + continue; + } + let this = run.first().map_or("UNION", |c| boolean_op(c)); if i > 0 && this != op && chain.len() > 1 { let inner = synthetic(l, op, parent, world, w, h, std::mem::take(&mut chain)); chain.push(inner); @@ -165,7 +250,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, if i > 0 { op = this; } - chain.push(child); + chain.extend(operands); } if chain.is_empty() { return None; @@ -179,6 +264,42 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, Some(Value::Object(node)) } +/// The paths `run` as one network in their group's space, filled by `winding`; `None` when one of +/// them isn't a path (a group, text, …). +fn compound(cx: &mut Cx, run: &[&Value], winding: WindingRule) -> Option { + const PATHS: [&str; 6] = ["shapePath", "rectangle", "oval", "star", "polygon", "triangle"]; + if run.iter().any(|c| !c.get("_class").and_then(Value::as_str).is_some_and(|k| PATHS.contains(&k))) { + return None; + } + let mut out = VectorNetwork::default(); + let mut loops = Vec::new(); + for c in run { + let (cw, ch) = size(c); + let Some(net) = network(cx, c, cw, ch, true) else { continue }; + let xf = local(c, cw, ch); + let [a, b, cc, d, _, _] = xf.as_coeffs(); + let lin = |(x, y): (f64, f64)| (a * x + cc * y, b * x + d * y); + let (v0, s0) = (out.vertices.len(), out.segments.len()); + for v in net.vertices { + let p = xf * figcraft_doc::geom::Point::new(v.x, v.y); + out.vertices.push(Vertex { x: p.x, y: p.y, ..v }); + } + for sg in net.segments { + out.segments.push(Segment { a: sg.a + v0, b: sg.b + v0, ta: lin(sg.ta), tb: lin(sg.tb) }); + } + for r in net.regions { + loops.extend(r.loops.into_iter().map(|lp| lp.into_iter().map(|i| i + s0).collect::>())); + } + } + if out.vertices.is_empty() { + return None; + } + if !loops.is_empty() { + out.regions.push(Region { loops, winding }); + } + Some(out) +} + /// A boolean operation the shape group doesn't have as a layer of its own: placed like the group. fn synthetic(l: &Value, op: &str, parent: Affine, world: Affine, w: f64, h: f64, children: Vec) -> Value { let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, false); diff --git a/crates/format/src/sketch/path_tests.rs b/crates/format/src/sketch/path_tests.rs index fb03d74c..70ad9527 100644 --- a/crates/format/src/sketch/path_tests.rs +++ b/crates/format/src/sketch/path_tests.rs @@ -165,3 +165,71 @@ fn huge_points_stay_finite() { assert!(network.segments.iter().all(|s| [s.ta.0, s.ta.1, s.tb.0, s.tb.1].iter().all(|c| c.is_finite()))); } } + +/// Sketch 51 and older: a shape group whose children have no operation (−1) is one compound path, +/// filled by the group's winding rule (stored on the layer itself). +#[test] +fn compound_shape_group_has_holes() { + let outer = with_op(rect("Outer", 0.0, 0.0, 100.0, 100.0), -1); + let inner = with_op(rect("Inner", 25.0, 25.0, 50.0, 50.0), -1); + let mut g = shape_group("Ring", 10.0, 10.0, 100.0, 100.0, vec![outer, inner]); + g["windingRule"] = json!(1); + let doc = open(vec![g]); + let n = node(&doc, "Ring"); + let NodeKind::Vector { network } = &n.kind else { panic!("not one vector: {:?}", n.kind) }; + assert_eq!(network.regions.len(), 1); + assert_eq!(network.regions[0].loops.len(), 2); + assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd); + let b = doc.world_bounds(n.id).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (10.0, 10.0, 100.0, 100.0)); +} + +#[test] +fn compound_runs_are_boolean_operands() { + let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1); + let b = with_op(rect("B", 60.0, 0.0, 20.0, 20.0), -1); + let c = with_op(rect("C", 10.0, 10.0, 10.0, 10.0), 1); + let doc = open(vec![shape_group("Mixed", 0.0, 0.0, 80.0, 50.0, vec![a, b, c])]); + let n = node(&doc, "Mixed"); + assert!(matches!(n.kind, NodeKind::BooleanOp { op: BoolOp::Subtract }), "{:?}", n.kind); + let first = doc.node(n.children[0]).unwrap(); + let NodeKind::Vector { network } = &first.kind else { panic!("first operand isn't the compound path") }; + assert_eq!(network.regions[0].loops.len(), 2); +} + +#[test] +fn layer_winding_rule_counts() { + let mut p = shape_path("Wound", 0.0, 0.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true); + p["windingRule"] = json!(1); + let doc = open(vec![p]); + let NodeKind::Vector { network } = &node(&doc, "Wound").kind else { panic!("not a vector") }; + assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd); +} + +/// Sketch fills an open path as if it were closed. +#[test] +fn filled_open_paths_are_closed() { + let open_path = shape_path("Lid", 0.0, 0.0, 40.0, 20.0, &[(0.0, 1.0), (0.5, 0.0), (1.0, 1.0)], false); + let filled = styled(shape_group("Filled", 0.0, 0.0, 40.0, 20.0, vec![open_path.clone()]), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let stroked = styled( + shape_group("Stroked", 0.0, 50.0, 40.0, 20.0, vec![open_path]), + "borders", + json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": 0, "thickness": 1}]), + ); + let doc = open(vec![filled, stroked]); + let NodeKind::Vector { network } = &node(&doc, "Filled").kind else { panic!("not a vector") }; + assert_eq!(network.regions.len(), 1); + assert_eq!(network.segments.len(), 3); + let NodeKind::Vector { network } = &node(&doc, "Stroked").kind else { panic!("not a vector") }; + assert!(network.regions.is_empty()); + assert_eq!(network.segments.len(), 2); +} + +#[test] +fn open_operands_of_booleans_are_closed() { + let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1); + let b = with_op(shape_path("Snout", 40.0, 10.0, 30.0, 20.0, &[(0.0, 0.0), (1.0, 0.5), (0.0, 1.0)], false), 0); + let doc = open(vec![styled(shape_group("Head", 0.0, 0.0, 70.0, 50.0, vec![a, b]), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)]))]); + let NodeKind::Vector { network } = &node(&doc, "Snout").kind else { panic!("not a vector") }; + assert_eq!(network.regions.len(), 1); +} diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs index de2529aa..f13515a6 100644 --- a/crates/format/src/sketch/style.rs +++ b/crates/format/src/sketch/style.rs @@ -243,6 +243,21 @@ pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec { out } +/// A bitmap's colour adjustments (`colorControls`: saturation and contrast 0–2 with 1 neutral, +/// brightness −1–1, hue in radians) as REST image filters; hue has no filter (a note). +pub(crate) fn color_controls(cx: &mut Cx, style: Option<&Value>) -> Option { + let c = style?.get("colorControls").filter(|c| c.get("isEnabled").and_then(Value::as_bool) == Some(true))?; + let get = |k: &str, neutral: f64| num(c, k).unwrap_or(neutral); + if get("hue", 0.0).abs() > 1e-6 { + cx.warn("hue changes of bitmaps were skipped"); + } + Some(json!({ + "saturation": (get("saturation", 1.0) - 1.0).clamp(-1.0, 1.0), + "contrast": (get("contrast", 1.0) - 1.0).clamp(-1.0, 1.0), + "exposure": get("brightness", 0.0).clamp(-1.0, 1.0), + })) +} + /// The variable binding of a colour that names a swatch the file has (`{color: alias}`). pub(crate) fn swatch(cx: &Cx, c: Option<&Value>) -> Option { let id = c?.get("swatchID").and_then(Value::as_str).filter(|id| cx.shared.has_swatch(id))?; diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index b1b2801b..242b0478 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -295,3 +295,22 @@ fn two_styled_masks_each_draw() { let first_group = node(&doc, "Mask1").parent.unwrap(); assert_ne!(copy.parent, Some(first_group)); } + +#[test] +fn bitmap_color_controls_become_filters() { + let png = { + let mut out = Vec::new(); + image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + }; + let mut bitmap = r("Photo"); + bitmap["_class"] = json!("bitmap"); + bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref": "images/a.png"}); + bitmap["style"]["colorControls"] = + json!({"_class": "colorControls", "isEnabled": true, "brightness": 0, "contrast": 1, "hue": 0.5, "saturation": 0}); + let (doc, warnings) = open_doc(document(&["P"]), vec![bitmap], &[("images/a.png", &png)]); + let PaintKind::Image(img) = &node(&doc, "Photo").fills[0].kind else { panic!("no image") }; + assert_eq!(img.filters.saturation, -1.0); + assert_eq!(img.filters.contrast, 0.0); + assert!(warnings.iter().any(|w| w.contains("hue")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index b87b7138..cabc2fd4 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -155,7 +155,8 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo cx.warn(format!("symbol {name} contains itself; the inner copy was left out")); return None; } - if l.get("scale").and_then(Value::as_f64).is_some_and(|s| s.is_finite() && (s - 1.0).abs() > 1e-9) { + // Sketch 50 stores "not scaled" as 0. + if l.get("scale").and_then(Value::as_f64).is_some_and(|s| s.is_finite() && s != 0.0 && (s - 1.0).abs() > 1e-9) { cx.warn("scaled symbol instances were imported at their size, unscaled"); } let mut node = base(l, "INSTANCE", parent, world, w, h, root); diff --git a/crates/format/src/sketch/symbols_tests.rs b/crates/format/src/sketch/symbols_tests.rs index 4c98ebe2..64c7ae7f 100644 --- a/crates/format/src/sketch/symbols_tests.rs +++ b/crates/format/src/sketch/symbols_tests.rs @@ -217,3 +217,12 @@ fn override_fan_out_is_bounded() { let (_, warnings) = import(&z, "f").unwrap(); assert!(warnings.iter().any(|w| w.contains("expand")), "{warnings:?}"); } + +/// Sketch 50 stores "not scaled" as `scale: 0`. +#[test] +fn scale_zero_is_unscaled() { + let mut i = instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[]); + i["scale"] = json!(0); + let (_, warnings) = open(vec![i], vec![button()]); + assert!(!warnings.iter().any(|w| w.contains("scaled")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index 3cdb2afc..14357cfc 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -93,6 +93,29 @@ pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap) { } } +/// How far a text layer moves so its lines sit where Sketch draws them. With a fixed line height, +/// Sketch puts a line's extra height (line height − the font's own, taken as 1.2 × its size) above +/// the text (`lineSpacingBehaviour` 0) or below it (1); FigCraft, like Sketch's default (2), puts +/// half on each side. Every line gets the same extra, so the whole layer moves. +pub(crate) fn baseline_shift(l: &Value) -> f64 { + let first = l + .get("attributedString") + .and_then(|a| a.get("attributes")) + .and_then(Value::as_array) + .and_then(|r| r.first()) + .and_then(|r| r.get("attributes")); + let Some(a) = first else { return 0.0 }; + let size = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes")).and_then(|f| f.get("size")).and_then(Value::as_f64); + let line = a.get("paragraphStyle").and_then(|p| p.get("maximumLineHeight").or_else(|| p.get("minimumLineHeight"))).and_then(Value::as_f64); + let (Some(size), Some(line)) = (size.filter(|s| s.is_finite() && *s > 0.0), line.filter(|v| v.is_finite() && *v > 0.0)) else { return 0.0 }; + let extra = (line - size * 1.2).clamp(-1e4, 1e4); + match l.get("lineSpacingBehaviour").and_then(Value::as_i64) { + Some(0) => extra / 2.0, + Some(1) => -extra / 2.0, + _ => 0.0, + } +} + /// A run's attributes as a REST type style (with its colour as `fills`). pub(crate) fn type_style(cx: &mut Cx, a: &Value) -> Value { let font = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes")); diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs index a7ee96b0..f6b96738 100644 --- a/crates/format/src/sketch/text_tests.rs +++ b/crates/format/src/sketch/text_tests.rs @@ -187,3 +187,23 @@ fn embedded_fonts_are_reported() { let w = warnings.iter().find(|w| w.contains("embedded")).expect("a note about embedded fonts"); assert!(w.contains("IBM Plex Mono") && w.contains("IBM Plex Sans"), "{w}"); } + +/// Where Sketch puts a line's extra height: above the text (`lineSpacingBehaviour` 0), below it +/// (1) or around it (2, like FigCraft); the text layer moves by the difference. +#[test] +fn line_spacing_behaviour_moves_text() { + let mk = |name: &str, beh: i64| { + let mut t = text(name, 0.0, 100.0, 100.0, 30.0, "Hg", &[(0, 2, "Helvetica", 14.0, BLACK)]); + t["lineSpacingBehaviour"] = json!(beh); + let a = &mut t["attributedString"]["attributes"][0]["attributes"]["paragraphStyle"]; + a["maximumLineHeight"] = json!(30); + a["minimumLineHeight"] = json!(30); + t + }; + let (doc, _) = open(vec![mk("B0", 0), mk("B1", 1), mk("B2", 2)]); + let y = |n: &str| doc.world_transform(doc.nodes().find(|x| x.name == n).unwrap().id).as_coeffs()[5]; + // Extra height: 30 − 14 × 1.2 = 13.2; half of it either way. + assert!((y("B2") - 100.0).abs() < 1e-6, "{}", y("B2")); + assert!((y("B0") - 106.6).abs() < 1e-6, "{}", y("B0")); + assert!((y("B1") - 93.4).abs() < 1e-6, "{}", y("B1")); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index c1bf0432..6642eb9f 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -43,6 +43,10 @@ pub(crate) struct Cx { pub max_work: u64, /// Notes given so far (each once). pub seen: std::collections::BTreeSet, + /// Inside a shape group: whether its shapes are filled (open ones then close) and whether the + /// group has a border. Outside one: `None` / false (a shape's own style decides). + pub operand_fill: Option, + pub operand_border: bool, /// The symbols being expanded, outermost first (a symbol inside itself stops there). pub chain: Vec, pub warnings: Vec, @@ -93,6 +97,8 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { work: 0, max_work: MAX_WORK, seen: std::collections::BTreeSet::new(), + operand_fill: None, + operand_border: false, chain: Vec::new(), warnings: Vec::new(), networks: Vec::new(), @@ -154,6 +160,8 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: let ty = match class { "artboard" | "frame" => "FRAME", "group" | "graphic" => "GROUP", + // Shapes whose points were moved are paths. + "rectangle" | "oval" if bool_field(l, "edited") == Some(true) => "VECTOR", "rectangle" | "bitmap" => "RECTANGLE", "oval" => "ELLIPSE", "slice" => "SLICE", @@ -169,7 +177,10 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: return None; } let (w, h) = size(l); - let world = parent * local(l, w, h); + let mut world = parent * local(l, w, h); + if ty == "TEXT" { + world *= Affine::translate((0.0, super::text::baseline_shift(l))); + } let mut node = base(l, ty, parent, world, w, h, root); if ty == "TEXT" { super::text::node(cx, l, &mut node); @@ -192,7 +203,10 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: "rectangle" => corner_radii(l, &mut node), "bitmap" => match l.get("image").and_then(|i| str_field(i, "_ref")) { Some(r) => { - let paint = json!({"type": "IMAGE", "scaleMode": "STRETCH", "imageRef": r, "imageTransform": [[1, 0, 0], [0, 1, 0]]}); + let mut paint = json!({"type": "IMAGE", "scaleMode": "STRETCH", "imageRef": r, "imageTransform": [[1, 0, 0], [0, 1, 0]]}); + if let Some(f) = super::style::color_controls(cx, l.get("style")) { + paint["filters"] = f; + } node.insert("fills".into(), json!([paint])); } None => cx.warn("images stored inside the JSON were skipped"), @@ -281,7 +295,8 @@ pub(crate) fn size(l: &Value) -> (f64, f64) { } /// A layer's transform within its parent: its frame's position, then its rotation (degrees, -/// counter-clockwise on screen) and flips about its centre. +/// counter-clockwise on screen) and flips about its centre. Sketch flips on screen, after rotating +/// (a quarter-turned layer flipped left to right mirrors its own up and down). pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine { let f = l.get("frame"); let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-MAX_COORD, MAX_COORD); @@ -289,8 +304,8 @@ pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine { let sx = if bool_field(l, "isFlippedHorizontal") == Some(true) { -1.0 } else { 1.0 }; let sy = if bool_field(l, "isFlippedVertical") == Some(true) { -1.0 } else { 1.0 }; Affine::translate((n("x") + w / 2.0, n("y") + h / 2.0)) - * Affine::rotate(-rot.to_radians()) * Affine::scale_non_uniform(sx, sy) + * Affine::rotate(-rot.to_radians()) * Affine::translate((-w / 2.0, -h / 2.0)) } diff --git a/crates/format/src/sketch/tree_tests.rs b/crates/format/src/sketch/tree_tests.rs index 91aeba3a..4b0ab511 100644 --- a/crates/format/src/sketch/tree_tests.rs +++ b/crates/format/src/sketch/tree_tests.rs @@ -231,3 +231,28 @@ fn deep_document_json_still_opens_the_pages() { assert!(doc.pages.iter().any(|p| doc.node(*p).unwrap().name == "Kept")); assert!(!warnings.is_empty()); } + +/// Sketch flips a rotated layer on screen, after rotating it. +#[test] +fn flip_applies_after_rotation() { + let r = with(with(rect("Turned", 0.0, 0.0, 40.0, 20.0), "rotation", json!(90)), "isFlippedHorizontal", json!(true)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 300.0, 300.0, vec![r])]); + let t = doc.world_transform(node(&doc, "Turned").id).as_coeffs(); + // Rotated a quarter turn counter-clockwise (x → up, y → right), then mirrored left to right. + assert!(t[0].abs() < 1e-9 && (t[1] + 1.0).abs() < 1e-9 && (t[2] + 1.0).abs() < 1e-9 && t[3].abs() < 1e-9, "{t:?}"); +} + +#[test] +fn edited_shapes_keep_their_points() { + let mut drop = rect("Drop", 0.0, 0.0, 20.0, 30.0); + drop["_class"] = json!("oval"); + drop["edited"] = json!(true); + drop["points"] = json!([point(0.5, 0.0), point(1.0, 0.7), point(0.5, 1.0), point(0.0, 0.7)]); + let mut box_ = rect("Slanted", 50.0, 0.0, 20.0, 20.0); + box_["edited"] = json!(true); + box_["points"][1]["point"] = json!("{1, 0.3}"); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 300.0, 300.0, vec![drop, box_])]); + let NodeKind::Vector { network } = &node(&doc, "Drop").kind else { panic!("not a vector") }; + assert_eq!((network.vertices[0].x, network.vertices[0].y), (10.0, 0.0)); + assert!(matches!(node(&doc, "Slanted").kind, NodeKind::Vector { .. })); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ed2d3157..508cd12a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards (and Sketch 2025 frames) as frames with their background, groups, rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape or as boolean operations (mixed operations nested from the left), bitmaps, slices, hotspots; transforms (rotation, flips), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), resized by their constraints and still live; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Warnings: noise fills, motion and zoom blurs, embedded fonts (not used), Smart Layout and stacks (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (80,000 layers open in ~4 s, debug build); Sketch 2025 files on fixtures only | 5–10 (Smart Layout as auto layout, real Sketch 2025 files) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards (and Sketch 2025 frames) as frames with their background, groups, rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), resized by their constraints and still live; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Warnings: noise fills, motion and zoom blurs, embedded fonts (not used), Smart Layout and stacks (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%; Sketch 2025 files on fixtures only | 5–10 (Smart Layout as auto layout, real Sketch 2025 files) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From ad78ee94c383e1f86ea6ca689879a3a6be88359f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 21:55:34 +0200 Subject: [PATCH 20/87] M6: Sketch import: instances, artboards and Apple fonts like Sketch Compared with Sketch's renders of a Sketch 64 file. Resized instances lay out their layers the way Sketch does (measured: pins share the change in proportion, a fixed size keeps its centre's share, and files before format 110 just scale); masters' backgrounds show in instances only when Sketch includes them; swaps to a missing symbol keep the original; artboards don't draw a style they carry. Apple's system fonts are found under the names designs use (SF Pro, SF UI Text and Display, SF Compact), the optical size follows the text size for named styles too, and imported text keeps the platform's trak tracking as letter spacing. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 39 ++++++++ crates/engine/src/sketch_import_tests.rs | 26 ++++- crates/format/src/sketch/mod.rs | 25 +---- crates/format/src/sketch/symbols.rs | 75 +++++++++++++-- crates/format/src/sketch/symbols_tests.rs | 79 ++++++++++++++- crates/format/src/sketch/text.rs | 2 + crates/format/src/sketch/text_tests.rs | 4 + crates/format/src/sketch/tree.rs | 10 ++ crates/format/src/sketch/tree_tests.rs | 18 +++- crates/text/src/fontdb.rs | 112 ++++++++++++++++++++-- crates/text/src/fontdb_tests.rs | 61 ++++++++++++ docs/file-format-parity.md | 2 +- 12 files changed, 413 insertions(+), 40 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 179f1c06..c6f892c1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -558,7 +558,11 @@ fn load_with( fn load_sketch(bytes: &[u8], name: &str) -> Option> { let r = figcraft_format::sketch::try_import(bytes, name)?; Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + // Both read the installed fonts: the system's must be known first (the desktop app scans + // them in the background; this waits for that scan). + figcraft_text::fontdb::scan_system_fonts(); match_font_families(&mut doc); + platform_tracking(&mut doc); link_instances(&mut doc); figcraft_format::Loaded { issues: warnings, ..doc.into() } })) @@ -587,6 +591,41 @@ fn match_font_families(doc: &mut Document) { } } +/// Sketch draws text with the tracking a font's `trak` table gives at its size (Apple's text engine +/// adds it; FigCraft's, like Figma's, doesn't): the imported text keeps it as letter spacing, so +/// lines measure and break as they did there. +fn platform_tracking(doc: &mut Document) { + let mut cache: std::collections::BTreeMap<(String, String), Option>> = + std::collections::BTreeMap::new(); + let mut face = |style: &figcraft_doc::TextStyle| { + cache + .entry((style.font_family.clone(), style.font_style.clone())) + .or_insert_with(|| { + figcraft_text::fontdb::has_family(&style.font_family) + .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) + .flatten() + }) + .clone() + }; + let mut track = |style: &mut figcraft_doc::TextStyle| { + let Some(f) = face(style) else { return }; + let t = f.tracking(style.font_size); + if t != 0.0 && t.is_finite() { + let px = style.letter_spacing.resolve(style.font_size); + style.letter_spacing = figcraft_doc::LetterSpacing::Pixels(px + t); + } + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { + track(&mut td.style); + for r in &mut td.runs { + track(&mut r.style); + } + } + } +} + /// The installed family `family` means when it isn't installed under that exact name: the one /// whose name is the same without spaces, ignoring case. pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option { diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index cee351b8..d78db77c 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -25,7 +25,9 @@ fn sketch_with(app_version: &str, pages: &[(&str, &str)], layers: &[Value]) -> V z.start_file("document.json", o).unwrap(); z.write_all(json!({"_class": "document", "pages": refs}).to_string().as_bytes()).unwrap(); z.start_file("meta.json", o).unwrap(); - z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": app_version, "version": 103}).to_string().as_bytes()).unwrap(); + // The format version that Sketch writes (103 for 50; 110 and up from 52). + let version = if app_version.split('.').next().and_then(|v| v.parse::().ok()).is_some_and(|v| v >= 52) { 125 } else { 103 }; + z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": app_version, "version": version}).to_string().as_bytes()).unwrap(); for (i, (id, name)) in pages.iter().enumerate() { let ls = if i == 0 { layers.to_vec() } else { Vec::new() }; z.start_file(format!("pages/{id}.json"), o).unwrap(); @@ -154,7 +156,7 @@ fn sketch_instances_stay_live() { let tag = shape("rectangle", "Tag", [80.0, 0.0, 20.0, 40.0], json!({"resizingConstraint": 60, "points": []})); let master = shape("symbolMaster", "Bar", [0.0, 0.0, 100.0, 40.0], json!({"symbolID": "S", "layers": [tag]})); let inst = shape("symbolInstance", "Wide", [0.0, 100.0, 200.0, 40.0], json!({"symbolID": "S", "overrideValues": []})); - let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("50.2", &[("A", "Page")], &[master, inst])); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[master, inst])); let mut s = Session::new(); s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); let find = |s: &Session, inst: bool| { @@ -304,3 +306,23 @@ fn local_sketch_samples_match_sketch() { worst.sort_by(|a, b| b.0.total_cmp(&a.0)); assert!(worst.first().is_none_or(|w| w.0 < 10.0), "{worst:?}"); } + +/// Sketch draws Apple's fonts with the tracking their `trak` table gives at each size (the +/// platform does it on its own); the imported text carries it as letter spacing (macOS only). +#[test] +fn sketch_text_keeps_the_platform_tracking() { + if !std::path::Path::new("/System/Library/Fonts/SFNS.ttf").exists() { + return; + } + figcraft_text::fontdb::scan_system_fonts(); + let font = json!({"name": "SFUIText-Regular", "size": 13}); + let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}}); + let t = shape("text", "Label", [0.0, 0.0, 100.0, 20.0], json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0})); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[t])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") }; + let ls = td.style.letter_spacing.resolve(13.0); + assert!((ls - (-12.0 * 13.0 / 2048.0)).abs() < 1e-6, "{ls}"); +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 3c64b409..576dbe06 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -120,7 +120,6 @@ fn import_parts(bytes: &[u8], name: &str, max_member: u64, max_nodes: usize) -> if archive.document.get("colorSpace").and_then(Value::as_i64) == Some(2) { doc.color_profile = figcraft_doc::color::ColorProfile::DisplayP3; } - fit_instances(&mut doc); warnings.extend(more); Ok((doc, warnings)) } @@ -138,25 +137,6 @@ fn embedded_fonts(document: &Value) -> Option { Some(format!("the file has embedded fonts FigCraft doesn't use ({}); install them to see the text as designed", list.join(", "))) } -/// Instances come in with their master's layers laid out at the master's size; resized ones lay -/// them out for their own size by their constraints, innermost first. -fn fit_instances(doc: &mut Document) { - let mut insts: Vec<(usize, figcraft_doc::NodeId, f64, f64)> = doc - .nodes() - .filter_map(|n| match n.kind { - figcraft_doc::NodeKind::Instance { component, .. } => { - let c = doc.node(component)?; - Some((doc.ancestors(n.id).len(), n.id, c.width, c.height)) - } - _ => None, - }) - .collect(); - insts.sort_by_key(|i| std::cmp::Reverse(i.0)); - for (_, id, w, h) in insts { - figcraft_layout::apply_constraints(doc, id, w, h); - } -} - fn corrupt(msg: impl Into) -> FormatError { FormatError::Corrupt(format!("Sketch file: {}", msg.into())) } @@ -164,6 +144,8 @@ fn corrupt(msg: impl Into) -> FormatError { /// The parts of a `.sketch` archive: the document, the pages in document order and the images. pub(crate) struct Archive<'b> { pub document: Value, + /// `meta.json`'s format version (0 when missing). + pub version: u64, /// `(id, page JSON)` in the document's order. pub pages: Vec<(String, Value)>, /// Problems met while reading (pages skipped). @@ -220,7 +202,8 @@ impl<'b> Archive<'b> { if refs.len() > MAX_PAGES { warnings.push(format!("the file has more than {MAX_PAGES} pages; the rest were skipped")); } - Ok(Archive { document, pages, warnings, zip: z }) + let version = meta.get("version").and_then(Value::as_u64).unwrap_or(0); + Ok(Archive { document, version, pages, warnings, zip: z }) } /// The bytes of the image a layer references (`images/`). diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index cabc2fd4..1292c025 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -162,7 +162,10 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo let mut node = base(l, "INSTANCE", parent, world, w, h, root); node.insert("componentId".into(), json!(str_field(&m, "do_objectID").unwrap_or(sid))); node.insert("clipsContent".into(), json!(true)); - if let Some(f) = master_fills(&m) { + // Instances show the master's background only when it says so. + if bool_field(&m, "includeBackgroundColorInInstance") == Some(true) + && let Some(f) = master_fills(&m) + { node.insert("fills".into(), f); } if let Some(st) = l.get("style") { @@ -176,7 +179,7 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo let mut copy = m; let mut budget = cx.work_left(); if let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) { - apply(layers, &overrides, &nested, &cx.shared, 0, &mut budget); + apply(layers, &overrides, &nested, &cx.shared, &cx.symbols, 0, &mut budget); } let used = cx.work_left().saturating_sub(budget); cx.spend(used); @@ -184,9 +187,15 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo // The budget ran out handing overrides on: noted. cx.spend(1); } + // The master's layers laid out for the instance's size, as Sketch does it. let (mw, mh) = size(©); + if ((mw - w).abs() > 1e-9 || (mh - h).abs() > 1e-9) + && let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) + { + resize(layers, (mw, mh), (w, h), cx.legacy_resize, 0); + } cx.chain.push(sid.to_string()); - let mut kids = children(cx, ©, world, (mw, mh), depth, false); + let mut kids = children(cx, ©, world, (w, h), depth, false); cx.chain.pop(); let prefix = str_field(l, "do_objectID").unwrap_or("I").to_string(); for k in &mut kids { @@ -196,6 +205,58 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo Some(Value::Object(node)) } +/// Lay `layers` (the children of a box of `old` size) out for the box at `new` size, the way Sketch +/// resizes a symbol instance's layers: groups pass the change on to their own layers. Files older +/// than format 110 scale everything; newer ones follow each layer's `resizingConstraint`. +fn resize(layers: &mut [Value], old: (f64, f64), new: (f64, f64), legacy: bool, depth: usize) { + if depth > 64 { + return; + } + for l in layers { + let mask = if legacy { 63 } else { l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63) }; + let Some(f) = l.get_mut("frame").and_then(Value::as_object_mut) else { continue }; + let n = |k: &str| f.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0); + let (x, y, w, h) = (n("x"), n("y"), n("width").max(0.0), n("height").max(0.0)); + // A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32. + let pinned = |bit: i64| mask & bit == 0; + let (nx, nw) = resize_axis(x, w, old.0, new.0, (pinned(4), pinned(2), pinned(1))); + let (ny, nh) = resize_axis(y, h, old.1, new.1, (pinned(32), pinned(16), pinned(8))); + for (k, v) in [("x", nx), ("y", ny), ("width", nw), ("height", nh)] { + f.insert(k.into(), json!(v)); + } + if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { + resize(kids, (w, h), (nw, nh), legacy, depth + 1); + } + } +} + +/// One axis of [`resize`]: a layer at `pos` of `size` in a parent going from `old` to `new`, with +/// its start, size and end pinned or not. Nothing pinned (or everything): it scales. One side +/// pinned: the size and the free side share the change, in proportion to their lengths. Only the +/// size fixed: its centre scales. Both sides pinned: the size takes the change. +fn resize_axis(pos: f64, size: f64, old: f64, new: f64, (start, fixed, end): (bool, bool, bool)) -> (f64, f64) { + if old <= 1e-9 || !new.is_finite() { + return (pos, size); + } + let k = new / old; + let d = new - old; + let far = old - pos - size; + let share = |part: f64, other: f64| if part + other > 1e-9 { d * part / (part + other) } else { d }; + let (p, s) = match (start, fixed, end) { + (true, false, false) => (pos, size + share(size, far)), + (false, false, true) => { + let s = size + share(size, pos); + (new - far - s, s) + } + (false, true, false) => ((pos + size / 2.0) * k - size / 2.0, size), + (true, false, true) => (pos, size + d), + (true, true, false) => (pos, size), + (false, true, true) => (new - far - size, size), + _ => (pos * k, size * k), + }; + (if p.is_finite() { p } else { pos }, if s.is_finite() { s.max(0.0) } else { size }) +} + /// An instance's overrides by name (`_` → value). fn overrides(l: &Value) -> BTreeMap { l.get("overrideValues") @@ -229,6 +290,7 @@ fn apply( ov: &BTreeMap, nested: &BTreeMap<&str, Vec<(&str, &Value)>>, shared: &Shared, + symbols: &Symbols, depth: usize, budget: &mut u64, ) { @@ -242,8 +304,9 @@ fn apply( Some("symbolInstance") => { match get("symbolID").map(|v| v.as_str().unwrap_or("")) { Some("") => l["isVisible"] = json!(false), - Some(other) => l["symbolID"] = json!(other), - None => {} + // A swap to a symbol the file doesn't have keeps the original, as in Sketch. + Some(other) if symbols.master(other).is_some() => l["symbolID"] = json!(other), + _ => {} } let inner = nested.get(id.as_str()).map(Vec::as_slice).unwrap_or_default(); let take = usize::try_from(*budget / OVERRIDE_COST).unwrap_or(usize::MAX).min(inner.len()); @@ -289,7 +352,7 @@ fn apply( } } if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { - apply(kids, ov, nested, shared, depth + 1, budget); + apply(kids, ov, nested, shared, symbols, depth + 1, budget); } } } diff --git a/crates/format/src/sketch/symbols_tests.rs b/crates/format/src/sketch/symbols_tests.rs index 64c7ae7f..7c2d73db 100644 --- a/crates/format/src/sketch/symbols_tests.rs +++ b/crates/format/src/sketch/symbols_tests.rs @@ -14,6 +14,12 @@ fn open_doc(doc_json: Value, layers: Vec, masters: Vec) -> (Docume import(&z, "f").unwrap() } +/// [`open`] for a file saved by another Sketch. +fn open_version(app: &str, version: u64, layers: Vec, masters: Vec) -> (Document, Vec) { + let z = sketch_zip(app, version, document(&["P", "S"]), &[page("P", "Page", layers), page("S", "Symbols", masters)], &[]); + import(&z, "f").unwrap() +} + fn open(layers: Vec, masters: Vec) -> (Document, Vec) { let mut d = document(&["P", "S"]); d["do_objectID"] = json!("DOC"); @@ -154,10 +160,10 @@ fn instance_explosion_is_bounded() { #[test] fn resized_instance_follows_constraints() { - // Pinned right with a fixed width (bits 1 and 2 cleared). + // Pinned right with a fixed width (bits 1 and 2 cleared); a Sketch 64 file. let pinned = with(rect("Tag", 80.0, 0.0, 20.0, 40.0), "resizingConstraint", json!(60)); let m = master("M", "S", "Bar", 100.0, 40.0, vec![pinned]); - let (doc, _) = open(vec![instance("Wide", "S", 0.0, 0.0, 200.0, 40.0, &[])], vec![m]); + let (doc, _) = open_version("64", 125, vec![instance("Wide", "S", 0.0, 0.0, 200.0, 40.0, &[])], vec![m]); let (inst, _) = instance_of(&doc, "Wide"); let tag = inside(&doc, inst, "Tag"); let b = doc.world_bounds(tag.id).unwrap(); @@ -226,3 +232,72 @@ fn scale_zero_is_unscaled() { let (_, warnings) = open(vec![i], vec![button()]); assert!(!warnings.iter().any(|w| w.contains("scaled")), "{warnings:?}"); } + +/// A master's background shows in its instances only when Sketch says so. +#[test] +fn master_background_in_instances_only_when_included() { + let bg = |id: &str, sid: &str, include: bool| { + let mut m = master(id, sid, id, 20.0, 20.0, vec![]); + m["hasBackgroundColor"] = json!(true); + m["backgroundColor"] = json!({"_class": "color", "red": 0, "green": 0, "blue": 0, "alpha": 1}); + m["includeBackgroundColorInInstance"] = json!(include); + m + }; + let (doc, _) = open( + vec![instance("Hidden", "SH", 0.0, 0.0, 20.0, 20.0, &[]), instance("Shown", "SS", 50.0, 0.0, 20.0, 20.0, &[])], + vec![bg("MH", "SH", false), bg("MS", "SS", true)], + ); + let (hidden, main) = instance_of(&doc, "Hidden"); + assert!(hidden.fills.is_empty(), "{:?}", hidden.fills); + assert_eq!(main.fills.len(), 1, "the master shows its background"); + assert_eq!(instance_of(&doc, "Shown").0.fills.len(), 1); +} + +/// How Sketch lays out a resized instance's layers, measured on its renders: a master of 200×100 +/// with a 100×40 rectangle at (20, 30), shown at 320×60. +#[test] +fn resized_instances_lay_out_like_sketch() { + let case = |mask: i64, version: u64| { + let r = with(rect("R", 20.0, 30.0, 100.0, 40.0), "resizingConstraint", json!(mask)); + let m = master("M", "S", "Box", 200.0, 100.0, vec![r]); + let (doc, _) = open_version("64", version, vec![instance("I", "S", 0.0, 0.0, 320.0, 60.0, &[])], vec![m]); + let (inst, _) = instance_of(&doc, "I"); + let b = doc.world_bounds(inside(&doc, inst, "R").id).unwrap(); + (b.x0, b.y0, b.width(), b.height()) + }; + let near = |a: (f64, f64, f64, f64), b: (f64, f64, f64, f64)| { + (a.0 - b.0).abs() < 0.5 && (a.1 - b.1).abs() < 0.5 && (a.2 - b.2).abs() < 0.5 && (a.3 - b.3).abs() < 0.5 + }; + // Sketch 52 and newer (format 110+). + for (mask, want) in [ + (63, (32.0, 18.0, 160.0, 24.0)), + (59, (20.0, 18.0, 166.67, 24.0)), + (62, (40.0, 18.0, 200.0, 24.0)), + (61, (62.0, 18.0, 100.0, 24.0)), + (58, (20.0, 18.0, 220.0, 24.0)), + (57, (20.0, 18.0, 100.0, 24.0)), + (60, (140.0, 18.0, 100.0, 24.0)), + (54, (40.0, 12.86, 200.0, 17.14)), + (0, (32.0, 18.0, 160.0, 24.0)), + (47, (32.0, 10.0, 160.0, 40.0)), + ] { + let got = case(mask, 125); + assert!(near(got, want), "mask {mask}: {got:?} vs {want:?}"); + } + // Older files: everything scales. + for mask in [59, 62, 61, 0] { + let got = case(mask, 103); + assert!(near(got, (32.0, 18.0, 160.0, 24.0)), "legacy mask {mask}: {got:?}"); + } +} + +/// A nested swap to a symbol the file doesn't have keeps the original, as Sketch shows it. +#[test] +fn swap_to_a_missing_symbol_keeps_the_original() { + let icon = master("MA", "SA", "Icon A", 20.0, 20.0, vec![rect("Dot A", 0.0, 0.0, 20.0, 20.0)]); + let card = master("MC", "SC", "Card", 100.0, 40.0, vec![instance("N", "SA", 0.0, 0.0, 20.0, 20.0, &[])]); + let (doc, warnings) = open(vec![instance("I", "SC", 0.0, 0.0, 100.0, 40.0, &[("N_symbolID", json!("GONE"))])], vec![icon, card]); + let (inst, _) = instance_of(&doc, "I"); + assert!(doc.subtree(inst.id).into_iter().filter_map(|i| doc.node(i)).any(|n| n.name == "Dot A")); + assert!(!warnings.iter().any(|w| w.contains("empty frames")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index 14357cfc..e1f0c263 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -170,6 +170,8 @@ pub(crate) fn font_name(ps: &str) -> (String, String) { ".SFNSDisplay" | ".SFUIDisplay" | "SanFranciscoDisplay" | ".SFProDisplay" => "SF Pro Display".into(), // Apple's CJK families whose names don't split at case changes. "PingFangSC" | "PingFangTC" | "PingFangHK" => format!("PingFang {}", family.get(8..).unwrap_or("")), + // Apple's SF families: "SF" and then words ("SFUIText" → "SF UI Text"). + f if f.starts_with("SF") && f.chars().nth(2).is_some_and(char::is_uppercase) => format!("SF {}", words(f.get(2..).unwrap_or(""))), f => words(f.trim_start_matches('.')), }; (family, style) diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs index f6b96738..41b86f68 100644 --- a/crates/format/src/sketch/text_tests.rs +++ b/crates/format/src/sketch/text_tests.rs @@ -152,6 +152,10 @@ fn font_names() { (".SFNSDisplay-Bold", "SF Pro Display", "Bold"), ("SanFranciscoText-Semibold", "SF Pro Text", "Semi Bold"), ("Impact", "Impact", "Regular"), + ("SFUIText-Semibold", "SF UI Text", "Semi Bold"), + ("SFUIDisplay-Regular", "SF UI Display", "Regular"), + ("SFCompactDisplay-Semibold", "SF Compact Display", "Semi Bold"), + ("SFProText-Bold", "SF Pro Text", "Bold"), ("IBMPlexSans-MediumItalic", "IBM Plex Sans", "Medium Italic"), ("Roboto-Italic", "Roboto", "Italic"), ("", "Helvetica", "Regular"), diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 6642eb9f..9a5bfaa3 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -47,6 +47,9 @@ pub(crate) struct Cx { /// group has a border. Outside one: `None` / false (a shape's own style decides). pub operand_fill: Option, pub operand_border: bool, + /// The file is older than format 110 (Sketch 52): Sketch scales a resized instance's layers + /// instead of following their pins. + pub legacy_resize: bool, /// The symbols being expanded, outermost first (a symbol inside itself stops there). pub chain: Vec, pub warnings: Vec, @@ -99,6 +102,7 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { seen: std::collections::BTreeSet::new(), operand_fill: None, operand_border: false, + legacy_resize: archive.version > 0 && archive.version < 110, chain: Vec::new(), warnings: Vec::new(), networks: Vec::new(), @@ -228,6 +232,10 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: /// Layer `l`'s style into `node`, and its links to the styles made from its shared style. pub(crate) fn styles(cx: &mut Cx, l: &Value, w: f64, h: f64, node: &mut JsonMap) { + // An artboard shows its background colour only; Sketch doesn't draw a style it carries. + if str_field(l, "_class") == Some("artboard") { + return; + } if let Some(st) = l.get("style") { super::style::apply(cx, st, w, h, node); } @@ -315,6 +323,8 @@ pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine { fn constraints(mask: i64) -> Value { let pinned = |bit: i64| mask & bit == 0; let axis = |low: i64, size: i64, high: i64, low_name: &str, high_name: &str, both: &str| match (pinned(low), pinned(high)) { + // Both sides and the size pinned can't all hold: Sketch scales such a layer. + (true, true) if pinned(size) => "SCALE".into(), (true, true) => both.to_string(), (true, false) => low_name.to_string(), (false, true) => high_name.to_string(), diff --git a/crates/format/src/sketch/tree_tests.rs b/crates/format/src/sketch/tree_tests.rs index 4b0ab511..5399a3df 100644 --- a/crates/format/src/sketch/tree_tests.rs +++ b/crates/format/src/sketch/tree_tests.rs @@ -82,7 +82,8 @@ fn resizing_constraint_bits() { (63 - 8 - 32, H::Scale, V::TopBottom), (63 - 2 - 16, H::Center, V::Center), (63 - 1 - 2, H::Right, V::Scale), - (0, H::LeftRight, V::TopBottom), + // Everything pinned is over-constrained: Sketch scales it. + (0, H::Scale, V::Scale), ]; let layers: Vec = cases .iter() @@ -256,3 +257,18 @@ fn edited_shapes_keep_their_points() { assert_eq!((network.vertices[0].x, network.vertices[0].y), (10.0, 0.0)); assert!(matches!(node(&doc, "Slanted").kind, NodeKind::Vector { .. })); } + +/// Sketch draws an artboard's background colour only; a style it may carry (borders, shadows) isn't +/// drawn. Sketch 2025 frames do draw theirs. +#[test] +fn artboards_ignore_their_style() { + let border = json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": {"_class": "color", "red": 0, "green": 0, "blue": 0, "alpha": 1}, "position": 2, "thickness": 1}]); + let mut a = artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![]); + a["style"]["borders"] = border.clone(); + let mut f = artboard("F", "Frame", 200.0, 0.0, 100.0, 100.0, vec![]); + f["_class"] = json!("frame"); + f["style"]["borders"] = border; + let (doc, _) = open(vec![a, f]); + assert!(node(&doc, "Board").strokes.is_empty()); + assert_eq!(node(&doc, "Frame").strokes.len(), 1); +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 604f3eee..22ca4b84 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -106,6 +106,8 @@ pub struct Face { /// Variation axes (empty for static fonts) and named instances. pub axes: Vec, pub instances: Vec, + /// The `trak` table's normal track: (size in points, tracking in font units), by size. + track: Vec<(f64, f64)>, charmap: HashMap, outlines: Mutex>>, advances: Mutex>, @@ -123,6 +125,29 @@ impl std::fmt::Debug for Face { } impl Face { + /// The tracking the font's `trak` table gives text of `size` (pixels per glyph; 0 without a + /// table): Apple's platform text engine adds it on its own, FigCraft's doesn't. + pub fn tracking(&self, size: f64) -> f64 { + let (Some(first), Some(last)) = (self.track.first(), self.track.last()) else { return 0.0 }; + if !size.is_finite() || self.upem <= 0.0 { + return 0.0; + } + let units = if size <= first.0 { + first.1 + } else if size >= last.0 { + last.1 + } else { + self.track + .windows(2) + .find_map(|w| match w { + [a, b] if size >= a.0 && size <= b.0 && b.0 > a.0 => Some(a.1 + (b.1 - a.1) * (size - a.0) / (b.0 - a.0)), + _ => None, + }) + .unwrap_or(0.0) + }; + units * size / self.upem + } + pub fn from_bytes(bytes: FontBytes, index: u32, family_override: Option<&str>, style_override: Option<&str>) -> Option { let data = bytes.data(); let f = skrifa::FontRef::from_index(data, index).ok()?; @@ -147,6 +172,7 @@ impl Face { let charmap: HashMap = f.charmap().mappings().map(|(c, g)| (c, g.to_u32())).collect(); let (axes, instances) = variation_info(&f); let has_table = |tag: &[u8; 4]| f.data_for_tag(skrifa::Tag::new(tag)).is_some(); + let track = f.data_for_tag(skrifa::Tag::new(b"trak")).map(|t| normal_track(t.as_bytes())).unwrap_or_default(); let bitmap_strikes = has_table(b"sbix") || has_table(b"CBDT") || has_table(b"EBDT"); let colr = has_table(b"COLR"); let under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06)); @@ -173,6 +199,7 @@ impl Face { strike_pos: strike, shaper, gsub_features, + track, axes, instances, charmap, @@ -287,6 +314,12 @@ impl Face { for (slot, c) in v.iter_mut().zip(&inst.coords) { *slot = *c; } + // The optical size follows the text size for a named style too, as on the platforms. + for (slot, a) in v.iter_mut().zip(&self.axes) { + if a.tag == "opsz" && style.font_size.is_finite() { + *slot = style.font_size; + } + } } else { let own_style = norm_style(&self.style) == norm_style(&style.font_style); let italic = style.font_style.to_ascii_lowercase().contains("italic") && !self.italic; @@ -398,6 +431,31 @@ fn name_string(f: &skrifa::FontRef, id: StringId) -> Option { (!s.is_empty()).then_some(s) } +/// The normal (0) horizontal track of a `trak` table: (size, tracking in font units) by size. Empty +/// when the table is missing, damaged or has no normal track. +fn normal_track(t: &[u8]) -> Vec<(f64, f64)> { + let u16_at = |o: usize| t.get(o..o + 2).map(|b| u16::from_be_bytes([b[0], b[1]])); + let i16_at = |o: usize| t.get(o..o + 2).map(|b| i16::from_be_bytes([b[0], b[1]])); + let fixed_at = |o: usize| t.get(o..o + 4).map(|b| f64::from(i32::from_be_bytes([b[0], b[1], b[2], b[3]])) / 65536.0); + let Some(horiz) = u16_at(6).map(usize::from).filter(|o| *o > 0) else { return Vec::new() }; + let (Some(tracks), Some(sizes)) = (u16_at(horiz).map(usize::from), u16_at(horiz + 2).map(usize::from)) else { return Vec::new() }; + let Some(size_table) = t.get(horiz + 4..horiz + 8).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize) else { return Vec::new() }; + let sizes = sizes.min(256); + for k in 0..tracks.min(64) { + let entry = horiz + 8 + k * 8; + if fixed_at(entry) != Some(0.0) { + continue; + } + let Some(values) = u16_at(entry + 6).map(usize::from) else { return Vec::new() }; + let out: Vec<(f64, f64)> = (0..sizes) + .filter_map(|i| Some((fixed_at(size_table + i * 4)?, f64::from(i16_at(values + i * 2)?)))) + .filter(|(s, _)| s.is_finite()) + .collect(); + return if out.windows(2).all(|w| w[0].0 < w[1].0) { out } else { Vec::new() }; + } + Vec::new() +} + /// (family, style) preferring the typographic names. fn names(f: &skrifa::FontRef) -> (Option, Option) { let family = name_string(f, StringId::TYPOGRAPHIC_FAMILY_NAME).or_else(|| name_string(f, StringId::FAMILY_NAME)); @@ -528,7 +586,7 @@ impl FontDb { /// Style names of a family, by weight then italic. pub fn styles(&mut self, family: &str) -> Vec { self.load_family(family, None); - let Some((_, idx)) = self.families.get(&family.to_lowercase()) else { return vec![] }; + let Some((_, idx)) = self.families.get(&self.key(family)) else { return vec![] }; // (weight, italic, order in the font, name): named instances keep the font's order. let mut v: Vec<(u16, bool, usize, String)> = Vec::new(); for f in idx.iter().filter_map(|i| self.faces.get(*i)) { @@ -549,15 +607,31 @@ impl FontDb { v.into_iter().map(|x| x.3).collect() } - pub fn has_family(&self, family: &str) -> bool { + /// The key `family` is known under: itself, or for a name designs use for Apple's system font + /// (SF Pro, SF UI Text, …) the system font when no font of that name is installed. + pub fn key(&self, family: &str) -> String { let k = family.to_lowercase(); + let known = |k: &str| self.families.contains_key(k) || self.pending.contains_key(k); + if known(&k) { + return k; + } + match apple_alias(&k) { + Some(target) if known(target) => target.to_string(), + _ => k, + } + } + + pub fn has_family(&self, family: &str) -> bool { + let k = self.key(family); self.families.contains_key(&k) || self.pending.contains_key(&k) } /// Read a system family's faces from disk; with `only`, just the face nearest that style (the /// rest stay pending). fn load_family(&mut self, family: &str, only: Option<&str>) { - let key = family.to_lowercase(); + let key = self.key(family); + // Faces load under the family name the scan gave them (a hidden family's public name). + let display = self.pending.get(&key).map(|(d, _)| d.clone()); let entries = match only { None => match self.pending.remove(&key) { Some((_, entries)) => entries, @@ -591,7 +665,7 @@ impl FontDb { Err(_) => continue, }, }; - if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, None, Some(&e.style)) { + if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, display.as_deref(), Some(&e.style)) { self.add(face); self.loaded_files.insert((e.path, e.index)); } @@ -608,7 +682,7 @@ impl FontDb { /// `style`: a fallback for missing characters needs one face, not every file of a CJK family /// (10–20 MB each). The family's other faces load when it is used by name. pub fn find_one(&mut self, family: &str, style: &str) -> Option> { - if !self.families.contains_key(&family.to_lowercase()) { + if !self.families.contains_key(&self.key(family)) { self.load_family(family, Some(style)); } self.best(family, style) @@ -616,7 +690,7 @@ impl FontDb { /// The loaded face of `family` nearest `style`. fn best(&self, family: &str, style: &str) -> Option> { - let (_, idx) = self.families.get(&family.to_lowercase())?; + let (_, idx) = self.families.get(&self.key(family))?; let faces: Vec<&Arc> = idx.iter().filter_map(|i| self.faces.get(*i)).collect(); let want = norm_style(style); if let Some(f) = faces.iter().find(|f| norm_style(&f.style) == want).or_else(|| faces.iter().find(|f| f.instance(style).is_some())) { @@ -884,12 +958,36 @@ fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { .any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some()) .then_some(())?; let (family, style) = names(&f); - let family = family.filter(|f| !f.starts_with('.'))?; + let family = public_family(&family?)?; Some((i, family, style.unwrap_or_else(|| "Regular".into()))) }) .collect() } +/// The name a font's family is offered under: its own, a public name for Apple's hidden system +/// families that designs use (`.SF Compact` → SF Compact), none for other hidden (`.`) families. +fn public_family(family: &str) -> Option { + let Some(hidden) = family.strip_prefix('.') else { return Some(family.to_string()) }; + match hidden { + "SF Compact" | "SF Compact Rounded" => Some(hidden.to_string()), + "SF NS Rounded" => Some("SF Pro Rounded".into()), + _ => None, + } +} + +/// The family Apple's system fonts are installed under, for the names designs use for them +/// (lowercase): SF Pro and SF UI are the variable `SFNS.ttf` ("System Font"), its text and display +/// cuts its optical sizes. +fn apple_alias(key: &str) -> Option<&'static str> { + Some(match key.trim_start_matches('.') { + "sf pro" | "sf pro text" | "sf pro display" | "sf ui text" | "sf ui display" | "sf ns" | "sf ns text" | "sf ns display" | "san francisco" => { + "system font" + } + "sf compact text" | "sf compact display" => "sf compact", + _ => return None, + }) +} + /// Convenience: find a face with the global db. pub fn find_face(family: &str, style: &str) -> Option> { let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index f2456d28..0b83e1d3 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -263,3 +263,64 @@ fn font_bytes_load_into_the_db() { assert_eq!(db().read().unwrap().face_count(), faces); } } + +/// Apple's system font answers to the names designs use for it (SF Pro, SF UI Text, …), unless a +/// font of that name is installed. +#[test] +fn apple_system_font_names_find_the_system_font() { + let mut db = FontDb::default(); + db.pending.insert( + "system font".into(), + ("System Font".into(), vec![Pending { path: "no-such-dir/SFNS.ttf".into(), index: 0, style: "Regular".into() }]), + ); + for name in ["SF Pro", "SF Pro Text", "SF Pro Display", "SF UI Text", "SF UI Display", ".SF NS Text", "San Francisco"] { + assert!(db.has_family(name), "{name}"); + } + assert!(!db.has_family("SF Mono")); + db.pending.insert("sf pro".into(), ("SF Pro".into(), Vec::new())); + assert_eq!(db.key("SF Pro"), "sf pro", "an installed SF Pro is itself"); + assert_eq!(db.key("SF UI Text"), "system font"); +} + +#[test] +fn hidden_apple_families_take_public_names() { + assert_eq!(public_family(".SF Compact").as_deref(), Some("SF Compact")); + assert_eq!(public_family(".SF Compact Rounded").as_deref(), Some("SF Compact Rounded")); + assert_eq!(public_family(".SF NS Rounded").as_deref(), Some("SF Pro Rounded")); + assert_eq!(public_family(".Keyboard"), None); + assert_eq!(public_family("Inter").as_deref(), Some("Inter")); +} + +/// Like the platforms, a variable font's optical size follows the text size, also for a named +/// style (macOS only: the system font is the one variable font with an optical size axis at hand). +#[test] +fn optical_size_follows_the_text_size() { + let Ok(bytes) = std::fs::read("/System/Library/Fonts/SFNS.ttf") else { return }; + let face = Face::from_bytes(FontBytes::Shared(std::sync::Arc::new(bytes)), 0, None, None).unwrap(); + let opsz = face.axes.iter().position(|a| a.tag == "opsz").unwrap(); + let at = |size: f64| { + let style = + figcraft_doc::TextStyle { font_family: "System Font".into(), font_style: "Semibold".into(), font_size: size, ..Default::default() }; + face.axis_values(&style)[opsz] + }; + assert_eq!(at(12.0), 17.0); + assert_eq!(at(40.0), 40.0); +} + +/// The tracking a font's `trak` table gives at a size (Apple's fonts carry one; the platform text +/// engine applies it on its own), in pixels, interpolated between the sizes the table lists. +#[test] +fn trak_tracking_by_size() { + let Ok(bytes) = std::fs::read("/System/Library/Fonts/SFNS.ttf") else { return }; + let face = Face::from_bytes(FontBytes::Shared(std::sync::Arc::new(bytes)), 0, None, None).unwrap(); + // Track 0 of SF: −12 units at 13 pt, 0 at 12 pt, −40 at 16 pt (2048 units per em). + assert!((face.tracking(13.0) - (-12.0 * 13.0 / 2048.0)).abs() < 1e-9, "{}", face.tracking(13.0)); + assert!(face.tracking(12.0).abs() < 1e-9); + assert!((face.tracking(12.5) - (-6.0 * 12.5 / 2048.0)).abs() < 1e-9); + // Past the table's ends: its last values. + assert!(face.tracking(500.0).is_finite()); + let plain = crate::fontdb::db().write().unwrap().default_face(); + if let Some(f) = plain.filter(|f| f.family != "System Font") { + let _ = f.tracking(12.0); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 508cd12a..8759a359 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards (and Sketch 2025 frames) as frames with their background, groups, rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), resized by their constraints and still live; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Warnings: noise fills, motion and zoom blurs, embedded fonts (not used), Smart Layout and stacks (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%; Sketch 2025 files on fixtures only | 5–10 (Smart Layout as auto layout, real Sketch 2025 files) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards (and Sketch 2025 frames) as frames with their background, groups, rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Warnings: noise fills, motion and zoom blurs, embedded fonts (not used), Smart Layout and stacks (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%; Sketch 2025 files on fixtures only | 5–10 (Smart Layout as auto layout, real Sketch 2025 files) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From c639cc2b76de3e5e81daaaccab281298efea8190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 22:51:11 +0200 Subject: [PATCH 21/87] M6: Sketch import: Sketch 2025 frames, stacks, tints and corners Groups as frames and graphics, stacks as auto layout (instances laid out by their stack), corners and per-side borders from the style, pins and sizing, tints (layeringType, fillColor overrides), text colour and visibility overrides, blur lists (progressive, glass), inner shadows in the shadow list, angular gradients starting on the right, text sizing, and texts an override changed measured again on open. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 39 +++ crates/engine/src/sketch_import_tests.rs | 75 +++++ crates/format/src/sketch/fixture.rs | 18 ++ crates/format/src/sketch/frames_tests.rs | 385 +++++++++++++++++++++++ crates/format/src/sketch/mod.rs | 3 + crates/format/src/sketch/path.rs | 59 ++++ crates/format/src/sketch/proto.rs | 7 +- crates/format/src/sketch/stack.rs | 99 ++++++ crates/format/src/sketch/style.rs | 140 ++++++++- crates/format/src/sketch/style_tests.rs | 8 +- crates/format/src/sketch/symbols.rs | 115 ++++++- crates/format/src/sketch/text.rs | 19 +- crates/format/src/sketch/tree.rs | 107 ++++++- docs/file-format-parity.md | 4 +- 14 files changed, 1044 insertions(+), 34 deletions(-) create mode 100644 crates/format/src/sketch/frames_tests.rs create mode 100644 crates/format/src/sketch/stack.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c6f892c1..5de7e0fd 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -564,10 +564,49 @@ fn load_sketch(bytes: &[u8], name: &str) -> Option = doc + .nodes() + .filter(|n| match (&n.kind, n.source.and_then(|s| doc.node(s)).map(|s| &s.kind)) { + (NodeKind::Text(t), Some(NodeKind::Text(main))) => t.characters != main.characters, + _ => false, + }) + .map(|n| n.id) + .collect(); + let m = crate::measure::TextMeasure; + // Instances of a stack at another size than their main: the stack at their size, innermost first. + let mut resized: Vec<(usize, NodeId)> = doc + .nodes() + .filter(|n| n.layout.is_some()) + .filter(|n| match &n.kind { + NodeKind::Instance { component, .. } => { + doc.node(*component).is_some_and(|c| (c.width - n.width).abs() > 1e-6 || (c.height - n.height).abs() > 1e-6) + } + _ => false, + }) + .map(|n| (doc.ancestors(n.id).len(), n.id)) + .collect(); + resized.sort_by_key(|r| std::cmp::Reverse(r.0)); + for (_, id) in resized { + figcraft_layout::layout_frame(doc, id, &m); + } + for id in changed { + if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) { + figcraft_layout::set_size(doc, id, w, h); + } + figcraft_layout::layout_up(doc, id, &m); + } +} + /// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from /// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and /// case. diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index d78db77c..5dc4b31a 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -326,3 +326,78 @@ fn sketch_text_keeps_the_platform_tracking() { let ls = td.style.letter_spacing.resolve(13.0); assert!((ls - (-12.0 * 13.0 / 2048.0)).abs() < 1e-6, "{ls}"); } + +/// A text an override changed is measured again, and the stack around it re-flows: a hugging chip +/// grows to its new label. +#[test] +fn sketch_overridden_text_reflows_its_stack() { + let font = json!({"name": "Helvetica", "size": 12}); + let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}}); + let label = shape( + "text", + "Label", + [20.0, 4.0, 14.0, 16.0], + json!({"attributedString": {"string": "Go", "attributes": [run]}, "horizontalSizing": 1, "verticalSizing": 1}), + ); + let dot = shape("oval", "Dot", [8.0, 9.0, 6.0, 6.0], json!({"points": []})); + let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 0, "justifyContent": 0, "alignItems": 1, "allGuttersGap": 6, "stackingOrder": 0}); + let master = shape( + "symbolMaster", + "Chip", + [0.0, 0.0, 44.0, 24.0], + json!({"symbolID": "S", "groupBehavior": 1, "groupLayout": layout, "horizontalSizing": 1, "verticalSizing": 1, + "leftPadding": 8, "rightPadding": 10, "topPadding": 4, "bottomPadding": 4, "layers": [label, dot]}), + ); + let ov = json!([{"_class": "overrideValue", "overrideName": "Label_stringValue", "value": "A much longer label here"}]); + let inst = shape( + "symbolInstance", + "Long", + [0.0, 100.0, 160.0, 24.0], + json!({"symbolID": "S", "overrideValues": ov, "horizontalSizing": 1, "verticalSizing": 1}), + ); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[master, inst])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let inst = doc.nodes().find(|n| n.name == "Long").unwrap().id; + let label = doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Label")).unwrap(); + let dot = doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Dot")).unwrap(); + let (i, l, d) = (doc.world_bounds(inst).unwrap(), doc.world_bounds(label).unwrap(), doc.world_bounds(dot).unwrap()); + assert!(l.x1 - l.x0 > 60.0, "the label was measured: {l:?}"); + assert!((d.x0 - i.x0 - 8.0).abs() < 0.5, "the dot after the padding: {d:?} in {i:?}"); + assert!((l.x0 - d.x1 - 6.0).abs() < 0.5, "the label after the gap: {l:?}"); + assert!((i.x1 - l.x1 - 10.0).abs() < 0.5, "the chip hugs: {i:?} {l:?}"); +} + +/// An instance sized apart from its master lays its master's stack out at its own size: a wide +/// button keeps its label and icon together in the middle. +#[test] +fn sketch_resized_stack_instances_reflow() { + let a = shape("rectangle", "A", [22.0, 12.0, 40.0, 20.0], json!({"points": []})); + let b = shape("rectangle", "B", [70.0, 12.0, 20.0, 20.0], json!({"points": []})); + let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 0, "justifyContent": 1, "alignItems": 1, "allGuttersGap": 8, "stackingOrder": 0}); + let master = shape( + "symbolMaster", + "Button", + [0.0, 0.0, 112.0, 44.0], + json!({"symbolID": "S", "groupBehavior": 1, "groupLayout": layout, "horizontalSizing": 1, "verticalSizing": 1, + "leftPadding": 22, "rightPadding": 22, "topPadding": 12, "bottomPadding": 12, "layers": [b, a]}), + ); + let inst = shape( + "symbolInstance", + "Wide", + [0.0, 100.0, 342.0, 44.0], + json!({"symbolID": "S", "overrideValues": [], "horizontalSizing": 0, "verticalSizing": 1}), + ); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[master, inst])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let inst = doc.nodes().find(|n| n.name == "Wide").unwrap().id; + let at = |name: &str| doc.world_bounds(doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == name)).unwrap()).unwrap(); + let (a, b) = (at("A"), at("B")); + // 40 + 8 + 20 = 68 wide, centred in 342: from 137. + assert!((a.x0 - 137.0).abs() < 0.5, "{a:?}"); + assert!((b.x0 - a.x1 - 8.0).abs() < 0.5, "{a:?} {b:?}"); + assert!((doc.world_bounds(inst).unwrap().x1 - 342.0).abs() < 1e-6); +} diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index ac426f0e..19d23eca 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -186,3 +186,21 @@ pub(crate) fn instance(id: &str, symbol_id: &str, x: f64, y: f64, w: f64, h: f64 Value::Array(overrides.iter().map(|(name, value)| json!({"_class": "overrideValue", "overrideName": name, "value": value})).collect()); l } + +/// A Sketch 2025+ frame (a group that behaves as a frame) holding `layers`. +pub(crate) fn frame_group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + let mut l = group(id, x, y, w, h, layers); + l["groupBehavior"] = json!(1); + l["clippingBehavior"] = json!(0); + l +} + +/// `l` as a stack (Sketch 2025+): `direction` 0 row, 1 column. +pub(crate) fn stack(mut l: Value, direction: i64, justify: i64, align: i64, gap: f64, padding: [f64; 4]) -> Value { + l["groupLayout"] = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": direction, "justifyContent": justify, "alignItems": align, + "allGuttersGap": gap, "crossAxisGutterGap": 0, "wrappingEnabled": false, "alignContent": 0, "stackingOrder": 0, "bordersAffectLayout": false}); + for (k, v) in ["leftPadding", "topPadding", "rightPadding", "bottomPadding"].iter().zip(padding) { + l[*k] = json!(v); + } + l +} diff --git a/crates/format/src/sketch/frames_tests.rs b/crates/format/src/sketch/frames_tests.rs new file mode 100644 index 00000000..aadc4b5f --- /dev/null +++ b/crates/format/src/sketch/frames_tests.rs @@ -0,0 +1,385 @@ +//! Sketch 2025 and newer: frames and graphics (groups with a `groupBehavior`), stacks, corners in +//! the style, pins and sizing, border sides. + +use figcraft_doc::{Document, LayoutMode, Node, NodeKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +fn open(layers: Vec) -> (Document, Vec) { + let z = sketch_zip("2026.3.1", 196, document(&["P"]), &[page("P", "Page", layers)], &[]); + import(&z, "f").unwrap() +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}")) +} + +#[test] +fn frames_graphics_and_clipping() { + let mut clip_none = frame_group("Loose", 0.0, 0.0, 50.0, 50.0, vec![]); + clip_none["clippingBehavior"] = json!(2); + let mut graphic = frame_group("Icon", 100.0, 0.0, 24.0, 24.0, vec![]); + graphic["groupBehavior"] = json!(2); + let mut plain = group("Plain", 200.0, 0.0, 10.0, 10.0, vec![rect("Inside", 0.0, 0.0, 10.0, 10.0)]); + plain["groupBehavior"] = json!(0); + let mut filled = frame_group("Filled", 300.0, 0.0, 50.0, 50.0, vec![]); + filled["style"]["fills"] = json!([solid_fill(1.0, 0.0, 0.0, 1.0)]); + let (doc, _) = open(vec![frame_group("Screen", 0.0, 100.0, 390.0, 844.0, vec![]), clip_none, graphic, plain, filled]); + assert!(matches!(node(&doc, "Screen").kind, NodeKind::Frame) && node(&doc, "Screen").clip); + assert!(!node(&doc, "Loose").clip); + assert!(matches!(node(&doc, "Icon").kind, NodeKind::Frame) && node(&doc, "Icon").clip); + assert!(matches!(node(&doc, "Plain").kind, NodeKind::Group)); + assert_eq!(node(&doc, "Filled").fills.len(), 1); + // Frames show what their style says: no white background of their own. + assert!(node(&doc, "Screen").fills.is_empty()); +} + +#[test] +fn corners_from_the_style() { + let mut a = rect("Each", 0.0, 0.0, 40.0, 40.0); + a["style"]["corners"] = + json!({"_class": "MSImmutableStyleCorners", "radii": [1, 2, 3, 4], "style": 0, "smoothing": 0.6, "prefersConcentric": false}); + let mut b = rect("Smooth", 50.0, 0.0, 40.0, 40.0); + b["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [12], "style": 1, "smoothing": 0.6, "prefersConcentric": false}); + let mut f = frame_group("Card", 100.0, 0.0, 100.0, 100.0, vec![]); + f["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [16, 16, 16, 16], "style": 0, "smoothing": 0.6}); + let (doc, _) = open(vec![a, b, f]); + assert_eq!(node(&doc, "Each").corners.radii, [1.0, 2.0, 3.0, 4.0]); + assert_eq!(node(&doc, "Smooth").corners.radii, [12.0; 4]); + assert!((node(&doc, "Smooth").corners.smoothing - 0.6).abs() < 1e-9); + assert_eq!(node(&doc, "Each").corners.smoothing, 0.0); + assert_eq!(node(&doc, "Card").corners.radii, [16.0; 4]); +} + +#[test] +fn stacks_become_auto_layout() { + // A row: Sketch lists the first item last (on top); justify evenly, centred, gap 8. + let a = rect("First", 0.0, 0.0, 20.0, 20.0); + let b = with(rect("Second", 0.0, 0.0, 30.0, 10.0), "horizontalSizing", json!(2)); + let mut free = rect("Free", 0.0, 0.0, 5.0, 5.0); + free["flexItem"] = json!({"_class": "MSImmutableFlexItem", "ignoreLayout": true, "alignSelf": 5, "preserveSpaceWhenHidden": false}); + let mut row = stack(frame_group("Row", 0.0, 0.0, 200.0, 40.0, vec![free, b, a]), 0, 5, 1, 8.0, [4.0, 6.0, 10.0, 2.0]); + row["horizontalSizing"] = json!(0); + row["verticalSizing"] = json!(1); + let col = stack(frame_group("Column", 0.0, 100.0, 100.0, 100.0, vec![rect("One", 0.0, 0.0, 10.0, 10.0)]), 1, 3, 3, 0.0, [0.0; 4]); + let (doc, _) = open(vec![row, col]); + let r = node(&doc, "Row"); + let l = r.layout.as_ref().expect("auto layout"); + assert_eq!(l.mode, LayoutMode::Horizontal); + assert_eq!(l.gap, 8.0); + assert_eq!(l.padding, [6.0, 10.0, 2.0, 4.0], "top, right, bottom, left"); + assert!(l.reverse_z, "the first item is on top, as in Sketch"); + let text = format!("{l:?}"); + assert!(text.contains("SpaceEvenly") || text.contains("Evenly"), "{text}"); + let order: Vec<&str> = r.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(order, ["First", "Second", "Free"], "flow order is the reverse of Sketch's list"); + assert!(node(&doc, "Free").child_layout.absolute); + assert_eq!(node(&doc, "Second").child_layout.sizing_h, figcraft_doc::Sizing::Fill); + assert_eq!(r.child_layout.sizing_v, figcraft_doc::Sizing::Hug); + let c = node(&doc, "Column").layout.as_ref().unwrap(); + assert_eq!(c.mode, LayoutMode::Vertical); + assert!(format!("{c:?}").contains("SpaceBetween"), "{c:?}"); +} + +/// A frame resized as an instance's layer follows its pins and sizing (Sketch 2025+ keep them in +/// `horizontalPins`/`horizontalSizing`, `resizingConstraint` stays 63). +#[test] +fn pins_and_sizing_resize_instances() { + let mut r = rect("R", 20.0, 30.0, 100.0, 40.0); + r["horizontalPins"] = json!(1); // left + r["horizontalSizing"] = json!(0); // fixed + r["verticalPins"] = json!(0); + r["verticalSizing"] = json!(3); // relative + let mut m = master("M", "S", "Box", 200.0, 100.0, vec![r]); + m["groupBehavior"] = json!(1); + let (doc, _) = { + let z = sketch_zip( + "2026.3.1", + 196, + document(&["P", "S"]), + &[page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 320.0, 60.0, &[])]), page("S", "Symbols", vec![m])], + &[], + ); + import(&z, "f").unwrap() + }; + let inst = doc.nodes().find(|n| n.name == "I").unwrap(); + let r = doc.subtree(inst.id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "R").unwrap(); + let b = doc.world_bounds(r.id).unwrap(); + assert!((b.x0 - 20.0).abs() < 0.5 && (b.width() - 100.0).abs() < 0.5, "{b:?}"); + assert!((b.y0 - 18.0).abs() < 0.5 && (b.height() - 24.0).abs() < 0.5, "{b:?}"); +} + +#[test] +fn graphics_scale_their_layers() { + let mut r = rect("R", 20.0, 30.0, 100.0, 40.0); + r["horizontalPins"] = json!(5); + r["horizontalSizing"] = json!(0); + let mut m = master("M", "S", "Glyph", 200.0, 100.0, vec![r]); + m["groupBehavior"] = json!(2); + let z = sketch_zip( + "2026.3.1", + 196, + document(&["P", "S"]), + &[page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 400.0, 200.0, &[])]), page("S", "Symbols", vec![m])], + &[], + ); + let (doc, _) = import(&z, "f").unwrap(); + let inst = doc.nodes().find(|n| n.name == "I").unwrap(); + let r = doc.subtree(inst.id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "R").unwrap(); + let b = doc.world_bounds(r.id).unwrap(); + assert!((b.x0 - 40.0).abs() < 0.5 && (b.width() - 200.0).abs() < 0.5, "{b:?}"); +} + +#[test] +fn border_sides() { + let r = styled( + rect("Sided", 0.0, 0.0, 40.0, 40.0), + "borders", + json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": 1, "thickness": 8, + "sides": {"_class": "MSImmutableStyleBorderSides", "leftInset": 7, "rightInset": 7, "topInset": 0, "bottomInset": 5}}]), + ); + // A side is as thick as the border less its inset. + let (doc, _) = open(vec![r]); + assert_eq!(node(&doc, "Sided").stroke.sides, Some([8.0, 1.0, 3.0, 1.0])); +} + +#[test] +fn swatches_bind_solid_fills_only() { + let mut d = document(&["P"]); + d["sharedSwatches"] = json!({"_class": "swatchContainer", "objects": [{"_class": "swatch", "do_objectID": "SW", "name": "Brand", "value": color(0.0, 0.0, 1.0, 1.0)}]}); + let mut g = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 1.0, 1.0), + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(1.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 1.0, 1.0)}]}}); + g["color"]["swatchID"] = json!("SW"); + let z = sketch_zip("2026.3.1", 196, d, &[page("P", "Page", vec![styled(rect("G", 0.0, 0.0, 10.0, 10.0), "fills", json!([g]))])], &[]); + let (_, warnings) = import(&z, "f").unwrap(); + assert!(!warnings.iter().any(|w| w.contains("not supported")), "{warnings:?}"); +} + +/// Sketch 2025+ masters keep their background, corners and stack in their style and layout, and +/// their instances show the same. +#[test] +fn masters_and_instances_keep_frame_styles_and_stacks() { + let mut m = master("M", "S", "Button", 120.0, 44.0, vec![rect("Icon", 0.0, 0.0, 20.0, 20.0), rect("Label", 0.0, 0.0, 60.0, 20.0)]); + m["groupBehavior"] = json!(1); + m["clippingBehavior"] = json!(2); + m["style"]["fills"] = json!([solid_fill(0.0, 0.0, 1.0, 1.0)]); + m["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [8], "style": 1, "smoothing": 0.6}); + let m = stack(m, 0, 1, 1, 10.0, [16.0, 12.0, 16.0, 12.0]); + let z = sketch_zip( + "2026.3.1", + 196, + document(&["P", "S"]), + &[page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 120.0, 44.0, &[])]), page("S", "Symbols", vec![m])], + &[], + ); + let (doc, _) = import(&z, "f").unwrap(); + let main = doc.nodes().find(|n| matches!(n.kind, NodeKind::Component { .. })).unwrap(); + let inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap(); + for n in [main, inst] { + assert_eq!(n.fills.len(), 1, "{}", n.name); + assert_eq!(n.corners.radii, [8.0; 4], "{}", n.name); + assert!(n.layout.is_some(), "{}", n.name); + assert!(!n.clip, "{}", n.name); + let order: Vec<&str> = n.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(order, ["Label", "Icon"], "{}", n.name); + } +} + +fn solid_of(n: &Node) -> Option<[f32; 3]> { + n.fills.iter().find_map(|p| match &p.kind { + figcraft_doc::color::PaintKind::Solid { color } => Some([color.r, color.g, color.b]), + _ => None, + }) +} + +fn open_symbols(layers: Vec, masters: Vec) -> Document { + let z = sketch_zip("2026.3.1", 196, document(&["P", "S"]), &[page("P", "Page", layers), page("S", "Symbols", masters)], &[]); + import(&z, "f").unwrap().0 +} + +fn in_instance<'d>(doc: &'d Document, inst: &str, name: &str) -> &'d Node { + let i = doc.nodes().find(|n| n.name == inst && matches!(n.kind, NodeKind::Instance { .. })).unwrap_or_else(|| panic!("no {inst}")); + doc.subtree(i.id).into_iter().filter_map(|id| doc.node(id)).find(|n| n.name == name).unwrap_or_else(|| panic!("no {name} in {inst}")) +} + +/// A graphic's fill tints its layers (it's no background); an instance's fill, or a `fillColor` +/// override, tints the instance's layers. +#[test] +fn fills_of_graphics_and_instances_are_tints() { + let glyph = styled(rect("Glyph", 0.0, 0.0, 24.0, 24.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let mut icon = master("MI", "SI", "Icon", 24.0, 24.0, vec![glyph]); + icon["groupBehavior"] = json!(2); + let mut tint = solid_fill(1.0, 0.0, 0.0, 1.0); + tint["layeringType"] = json!(1); + icon["style"]["fills"] = json!([tint]); + let nested = instance("N", "SI", 0.0, 0.0, 24.0, 24.0, &[]); + let mut button = master("MB", "SB", "Button", 100.0, 40.0, vec![nested]); + button["groupBehavior"] = json!(1); + let own = instance("Own", "SI", 0.0, 0.0, 24.0, 24.0, &[("Own_fillColor", color(0.0, 0.0, 1.0, 1.0))]); + let via = instance("Via", "SB", 0.0, 100.0, 100.0, 40.0, &[("N_fillColor", color(0.0, 1.0, 0.0, 1.0))]); + let doc = open_symbols(vec![own, via], vec![icon, button]); + let main = doc.nodes().find(|n| n.name == "Icon" && matches!(n.kind, NodeKind::Component { .. })).unwrap(); + assert!(main.fills.is_empty(), "a graphic's fill isn't its background"); + let in_main = doc.subtree(main.id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "Glyph").unwrap(); + assert_eq!(solid_of(in_main), Some([1.0, 0.0, 0.0])); + assert_eq!(solid_of(in_instance(&doc, "Own", "Glyph")), Some([0.0, 0.0, 1.0])); + assert!(doc.nodes().find(|n| n.name == "Own").unwrap().fills.is_empty()); + assert_eq!(solid_of(in_instance(&doc, "Via", "Glyph")), Some([0.0, 1.0, 0.0])); +} + +#[test] +fn text_color_overrides() { + let label = text("Label", 0.0, 0.0, 80.0, 20.0, "Go", &[(0, 2, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]); + let m = master("M", "S", "Button", 100.0, 40.0, vec![label]); + let doc = open_symbols(vec![instance("I", "S", 0.0, 0.0, 100.0, 40.0, &[("Label_textColor", color(1.0, 0.0, 0.0, 1.0))])], vec![m]); + let n = in_instance(&doc, "I", "Label"); + let red = |p: &figcraft_doc::color::Paint| matches!(p.kind, figcraft_doc::color::PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0); + let NodeKind::Text(td) = &n.kind else { panic!("not text") }; + assert!(n.fills.iter().any(red) || td.style.fills.as_ref().is_some_and(|f| f.iter().any(red)), "{:?} {:?}", n.fills, td.style.fills); +} + +#[test] +fn blurs_list() { + let blur = |ty: i64, r: f64| json!({"_class": "blur", "isEnabled": true, "type": ty, "radius": r, "center": "{0.5, 0.5}", "motionAngle": 25}); + let g = styled(rect("Glow", 0.0, 0.0, 40.0, 40.0), "blurs", json!([blur(0, 90.0)])); + let m = styled(rect("Comet", 50.0, 0.0, 40.0, 4.0), "blurs", json!([blur(1, 6.0)])); + let b = styled(rect("Glass", 100.0, 0.0, 40.0, 40.0), "blurs", json!([blur(3, 10.0)])); + let (doc, warnings) = open(vec![g, m, b]); + assert!(matches!(node(&doc, "Glow").effects[0].kind, figcraft_doc::EffectKind::LayerBlur(_))); + assert!(matches!(node(&doc, "Glass").effects[0].kind, figcraft_doc::EffectKind::BackgroundBlur(_))); + assert!(matches!(node(&doc, "Comet").effects[0].kind, figcraft_doc::EffectKind::LayerBlur(_)), "motion blur drawn as a layer blur"); + assert!(warnings.iter().any(|w| w.contains("motion")), "{warnings:?}"); +} + +#[test] +fn text_sizing_2025() { + let mut fixed = text("Fixed", 0.0, 0.0, 100.0, 40.0, "Wraps here", &[(0, 10, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]); + fixed.as_object_mut().unwrap().remove("textBehaviour"); + fixed["horizontalSizing"] = json!(0); + fixed["verticalSizing"] = json!(1); + let mut fit = text("Fit", 0.0, 50.0, 100.0, 20.0, "Grows", &[(0, 5, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]); + fit.as_object_mut().unwrap().remove("textBehaviour"); + fit["horizontalSizing"] = json!(1); + fit["verticalSizing"] = json!(1); + let (doc, _) = open(vec![fixed, fit]); + let auto = |n: &str| match &node(&doc, n).kind { + NodeKind::Text(td) => td.auto_resize, + _ => panic!("not text"), + }; + assert_eq!(auto("Fixed"), figcraft_doc::TextAutoResize::Height); + assert_eq!(auto("Fit"), figcraft_doc::TextAutoResize::WidthAndHeight); +} + +#[test] +fn stacks_are_no_smart_layout_note() { + let (_, warnings) = open(vec![stack(frame_group("Row", 0.0, 0.0, 100.0, 40.0, vec![rect("A", 0.0, 0.0, 10.0, 10.0)]), 0, 0, 0, 0.0, [0.0; 4])]); + assert!(!warnings.iter().any(|w| w.contains("Smart Layout")), "{warnings:?}"); +} + +/// Sketch 2025+: a fill with `layeringType` 1 is a tint (of a frame or master too), 0 a background. +#[test] +fn layering_type_decides_tint_or_background() { + let dot = styled(rect("Dot", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let mut tint = solid_fill(0.0, 0.0, 1.0, 1.0); + tint["layeringType"] = json!(1); + let mut bar = master("MB", "SB", "Bar", 100.0, 20.0, vec![dot]); + bar["groupBehavior"] = json!(1); + bar["style"]["fills"] = json!([tint]); + let mut bg = solid_fill(1.0, 0.0, 0.0, 1.0); + bg["layeringType"] = json!(0); + let mut btn = master("MC", "SC", "Btn", 100.0, 20.0, vec![]); + btn["groupBehavior"] = json!(1); + btn["style"]["fills"] = json!([bg]); + let doc = open_symbols(vec![instance("B", "SB", 0.0, 0.0, 100.0, 20.0, &[]), instance("C", "SC", 0.0, 50.0, 100.0, 20.0, &[])], vec![bar, btn]); + let b = doc.nodes().find(|n| n.name == "B").unwrap(); + assert!(b.fills.is_empty(), "a tint isn't a background"); + assert_eq!(solid_of(in_instance(&doc, "B", "Dot")), Some([0.0, 0.0, 1.0])); + assert_eq!(solid_of(doc.nodes().find(|n| n.name == "C").unwrap()), Some([1.0, 0.0, 0.0])); +} + +#[test] +fn inner_shadows_in_the_shadow_list() { + let mut sh = + json!({"_class": "shadow", "isEnabled": true, "blurRadius": 4, "offsetX": 0, "offsetY": 2, "spread": 0, "color": color(0.0, 0.0, 0.0, 0.5)}); + sh["isInnerShadow"] = json!(true); + let (doc, _) = open(vec![styled(rect("Inset", 0.0, 0.0, 40.0, 40.0), "shadows", json!([sh]))]); + assert!(matches!(node(&doc, "Inset").effects[0].kind, figcraft_doc::EffectKind::InnerShadow(_))); +} + +#[test] +fn visibility_overrides() { + let m = master("M", "S", "Input", 100.0, 40.0, vec![rect("Label", 0.0, 0.0, 50.0, 10.0)]); + let doc = open_symbols(vec![instance("I", "S", 0.0, 0.0, 100.0, 40.0, &[("Label_isVisible", json!("0"))])], vec![m]); + assert!(!in_instance(&doc, "I", "Label").visible); +} + +/// An instance that fits its content hugs it even when its master has a fixed size. +#[test] +fn instance_sizing_is_its_own() { + let m = stack(master("M", "S", "Chip", 60.0, 24.0, vec![rect("A", 0.0, 0.0, 20.0, 20.0)]), 0, 0, 1, 0.0, [0.0; 4]); + let mut i = instance("I", "S", 0.0, 0.0, 60.0, 24.0, &[]); + i["horizontalSizing"] = json!(1); + let doc = open_symbols(vec![i], vec![m]); + let n = doc.nodes().find(|n| n.name == "I").unwrap(); + assert_eq!(n.layout.as_ref().map(|_| format!("{:?}", n.child_layout.sizing_h)), Some("Hug".to_string())); +} + +/// A tint keeps each layer's own opacity. +#[test] +fn tints_keep_opacity() { + let faint = styled(rect("Faint", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 0.25)])); + let mut g = group("Tinted", 0.0, 0.0, 10.0, 10.0, vec![faint]); + let mut tint = solid_fill(1.0, 0.0, 0.0, 1.0); + tint["layeringType"] = json!(1); + g["groupBehavior"] = json!(0); + g["style"] = json!({"_class": "style", "fills": [tint]}); + let (doc, _) = open(vec![g]); + let f = &node(&doc, "Faint").fills[0]; + let figcraft_doc::color::PaintKind::Solid { color } = &f.kind else { panic!("not solid") }; + assert_eq!((color.r, color.g), (1.0, 0.0)); + assert!((color.a * f.opacity - 0.25).abs() < 1e-3, "{color:?} {}", f.opacity); + assert!(node(&doc, "Tinted").fills.is_empty()); +} + +#[test] +fn glass_and_progressive_blurs() { + let glass = json!({"_class": "blur", "isEnabled": true, "type": 4, "radius": 10, "distortion": 0.6, "depth": 0.7, "center": "{0.5, 0.5}"}); + let prog = json!({"_class": "blur", "isEnabled": true, "type": 3, "radius": 24, "isProgressive": true, "center": "{0.5, 0.5}", + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 0.0, 0.0)}]}}); + let (doc, warnings) = open(vec![ + styled(rect("Glass", 0.0, 0.0, 40.0, 40.0), "blurs", json!([glass])), + styled(rect("Prog", 50.0, 0.0, 40.0, 40.0), "blurs", json!([prog])), + ]); + assert!(matches!(node(&doc, "Glass").effects[0].kind, figcraft_doc::EffectKind::Glass(_)), "{warnings:?}"); + let figcraft_doc::EffectKind::BackgroundBlur(b) = &node(&doc, "Prog").effects[0].kind else { panic!("not a background blur") }; + assert_eq!(b.blur_type, figcraft_doc::BlurType::Progressive); + assert_eq!((b.start_radius, b.radius), (24.0, 0.0)); +} + +/// Angled (2), inside-square (3) and inside-arc (4) corners make the rectangle a path of that outline. +#[test] +fn cut_corners_become_paths() { + let cut = |name: &str, style: i64| { + let mut r = rect(name, 0.0, 0.0, 100.0, 80.0); + r["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [10], "style": style, "smoothing": 0.6}); + r + }; + let (doc, _) = open(vec![cut("Angled", 2), cut("Square", 3), cut("Arc", 4)]); + let net = |n: &str| match &node(&doc, n).kind { + NodeKind::Vector { network } => network.clone(), + other => panic!("{n}: {other:?}"), + }; + let a = net("Angled"); + assert_eq!(a.vertices.len(), 8); + assert!(a.vertices.iter().any(|v| (v.x, v.y) == (0.0, 10.0)) && a.vertices.iter().any(|v| (v.x, v.y) == (10.0, 0.0))); + let s = net("Square"); + assert_eq!(s.vertices.len(), 12); + assert!(s.vertices.iter().any(|v| (v.x, v.y) == (10.0, 10.0)) && s.vertices.iter().any(|v| (v.x, v.y) == (90.0, 70.0))); + let arc = net("Arc"); + assert_eq!(arc.vertices.len(), 8); + assert!(arc.segments.iter().any(|g| g.ta != (0.0, 0.0)), "the corners are arcs"); +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 576dbe06..8d8a465e 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -13,6 +13,8 @@ #[cfg(test)] mod fixture; +#[cfg(test)] +mod frames_tests; mod path; #[cfg(test)] mod path_tests; @@ -22,6 +24,7 @@ mod proto_tests; mod shared; #[cfg(test)] mod shared_tests; +mod stack; mod style; #[cfg(test)] mod style_tests; diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 149b4870..24ceca15 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -100,6 +100,65 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64, fill_open: bool) - Some(VectorNetwork { vertices, segments, regions }) } +/// Rectangle `l` with angled (corner style 2), inside-square (3) or inside-arc (4) corners in its +/// style (Sketch 2025 and newer) as an edited rectangle whose points draw that outline, the +/// corners' `radii` (top left, top right, bottom right, bottom left; repeating) being how far +/// each cut reaches along both edges. `None` for any other layer. +pub(crate) fn cut_corners(l: &Value) -> Option { + if l.get("_class").and_then(Value::as_str) != Some("rectangle") || l.get("edited").and_then(Value::as_bool) == Some(true) { + return None; + } + let c = l.get("style")?.get("corners")?; + let style = c.get("style").and_then(Value::as_i64).filter(|s| (2..=4).contains(s))?; + let radii: Vec = c.get("radii")?.as_array()?.iter().filter_map(Value::as_f64).filter(|r| r.is_finite()).take(4).collect(); + if radii.iter().all(|r| *r <= 0.0) { + return None; + } + let (w, h) = size(l); + if w <= 0.0 || h <= 0.0 { + return None; + } + // Arc handles: a quarter circle's. + const K: f64 = 0.552_284_749_8; + let fmt = |(x, y): (f64, f64)| format!("{{{}, {}}}", x / w, y / h); + let curve = |p: (f64, f64), from: Option<(f64, f64)>, to: Option<(f64, f64)>| { + json!({"_class": "curvePoint", "cornerRadius": 0, "point": fmt(p), "curveMode": if from.or(to).is_some() { 4 } else { 1 }, + "hasCurveFrom": from.is_some(), "hasCurveTo": to.is_some(), "curveFrom": fmt(from.unwrap_or(p)), "curveTo": fmt(to.unwrap_or(p))}) + }; + // Corners clockwise from the top left, each with the directions to the edge before and after. + let corners = [ + ((0.0, 0.0), (0.0, 1.0), (1.0, 0.0)), + ((w, 0.0), (-1.0, 0.0), (0.0, 1.0)), + ((w, h), (0.0, -1.0), (-1.0, 0.0)), + ((0.0, h), (1.0, 0.0), (0.0, -1.0)), + ]; + let mut points = Vec::new(); + for (i, ((cx, cy), (px, py), (nx, ny))) in corners.into_iter().enumerate() { + let r = radii.get(i % radii.len()).copied().unwrap_or(0.0).clamp(0.0, w.min(h) / 2.0); + if r <= 0.0 { + points.push(curve((cx, cy), None, None)); + continue; + } + let a = (cx + r * px, cy + r * py); + let b = (cx + r * nx, cy + r * ny); + match style { + 2 => points.extend([curve(a, None, None), curve(b, None, None)]), + 3 => points.extend([curve(a, None, None), curve((cx + r * (px + nx), cy + r * (py + ny)), None, None), curve(b, None, None)]), + // A quarter circle about the corner, bulging into the shape. + _ => points + .extend([curve(a, Some((a.0 + K * r * nx, a.1 + K * r * ny)), None), curve(b, None, Some((b.0 + K * r * px, b.1 + K * r * py)))]), + } + } + let mut out = l.clone(); + out["edited"] = json!(true); + out["isClosed"] = json!(true); + out["points"] = Value::Array(points); + if let Some(st) = out.get_mut("style").and_then(Value::as_object_mut) { + st.remove("corners"); + } + Some(out) +} + /// A path, star, polygon or triangle layer: a star or polygon while its points are still the /// generated ones (same as FigCraft's, within half a percent), else an editable vector. pub(crate) fn shape(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option { diff --git a/crates/format/src/sketch/proto.rs b/crates/format/src/sketch/proto.rs index afa6df48..1a08d268 100644 --- a/crates/format/src/sketch/proto.rs +++ b/crates/format/src/sketch/proto.rs @@ -104,11 +104,12 @@ pub(crate) fn layout_grids(l: &Value, h: f64, node: &mut JsonMap) } } -/// Notes about what the layer has that FigCraft keeps only as laid out: Smart Layout and Stacks, +/// Notes about what the layer has that FigCraft keeps only as laid out: Smart Layout, /// and overrides a master locks. pub(crate) fn notes(cx: &mut Cx, l: &Value) { - if l.get("groupLayout").and_then(|g| str_field(g, "_class")).is_some_and(|c| c != "MSImmutableFreeformGroupLayout") { - cx.warn("Smart Layout and stacks were imported as laid out; they don't resize with their content"); + // Stacks become auto layout; the older Smart Layout doesn't. + if l.get("groupLayout").and_then(|g| str_field(g, "_class")) == Some("MSImmutableInferredGroupLayout") { + cx.warn("Smart Layout was imported as laid out; it doesn't resize with its content"); } let locked = l.get("overrideProperties").and_then(Value::as_array).is_some_and(|p| p.iter().any(|o| bool_field(o, "canOverride") == Some(false))); if locked { diff --git a/crates/format/src/sketch/stack.rs b/crates/format/src/sketch/stack.rs new file mode 100644 index 00000000..a6b2ddb2 --- /dev/null +++ b/crates/format/src/sketch/stack.rs @@ -0,0 +1,99 @@ +//! Sketch 2025+ stacks (`groupLayout` `MSImmutableFlexGroupLayout`) → REST auto layout. +//! +//! The enums follow the order Sketch's plugin API documents them in (checked against files): +//! direction row, column; justify start, center, end, between, around, evenly; align items start, +//! center, end, stretch, none; stacking order first on top, last on top. Sizing (`horizontalSizing`, +//! `verticalSizing`) is fixed, fit (hug), fill, relative. Sketch lists a stack's first item last +//! (on top in the layer list), so the flow order is the reverse of its `layers`. + +use serde_json::{Map as JsonMap, Value, json}; + +/// Whether `l` is a stack. +pub(crate) fn is_stack(l: &Value) -> bool { + l.get("groupLayout").and_then(|g| g.get("_class")).and_then(Value::as_str) == Some("MSImmutableFlexGroupLayout") +} + +/// Make `node` (the REST node of frame `l`, its children already built in Sketch's order) an auto +/// layout frame when `l` is a stack. +pub(crate) fn apply(l: &Value, node: &mut JsonMap) { + if !is_stack(l) || !matches!(node.get("type").and_then(Value::as_str), Some("FRAME" | "COMPONENT" | "INSTANCE")) { + return; + } + let Some(g) = l.get("groupLayout") else { return }; + let int = |k: &str| g.get(k).and_then(Value::as_i64).unwrap_or(0); + let num = |v: &Value, k: &str| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(0.0).clamp(0.0, 1e5); + let row = int("flexDirection") == 0; + node.insert("layoutMode".into(), json!(if row { "HORIZONTAL" } else { "VERTICAL" })); + node.insert("itemSpacing".into(), json!(num(g, "allGuttersGap"))); + node.insert( + "primaryAxisAlignItems".into(), + json!(match int("justifyContent") { + 1 => "CENTER", + 2 => "MAX", + 3 => "SPACE_BETWEEN", + 4 => "SPACE_AROUND", + 5 => "SPACE_EVENLY", + _ => "MIN", + }), + ); + let stretch = int("alignItems") == 3; + node.insert( + "counterAxisAlignItems".into(), + json!(match int("alignItems") { + 1 => "CENTER", + 2 => "MAX", + _ => "MIN", + }), + ); + if g.get("wrappingEnabled").and_then(Value::as_bool) == Some(true) { + node.insert("layoutWrap".into(), json!("WRAP")); + node.insert("counterAxisSpacing".into(), json!(num(g, "crossAxisGutterGap"))); + } + for (k, rest) in + [("leftPadding", "paddingLeft"), ("topPadding", "paddingTop"), ("rightPadding", "paddingRight"), ("bottomPadding", "paddingBottom")] + { + node.insert(rest.into(), json!(num(l, k))); + } + node.insert("itemReverseZIndex".into(), json!(int("stackingOrder") == 0)); + node.insert("strokesIncludedInLayout".into(), json!(g.get("bordersAffectLayout").and_then(Value::as_bool) == Some(true))); + // The stack's own sizing: fit hugs its content. + let (main, cross) = if row { ("horizontalSizing", "verticalSizing") } else { ("verticalSizing", "horizontalSizing") }; + let mode = |k: &str| if l.get(k).and_then(Value::as_i64) == Some(1) { "AUTO" } else { "FIXED" }; + node.insert("primaryAxisSizingMode".into(), json!(mode(main))); + node.insert("counterAxisSizingMode".into(), json!(mode(cross))); + // Items: flow order, sizing, absolute ones. + let sources: Vec<&Value> = super::tree::layers(l).iter().collect(); + if let Some(kids) = node.get_mut("children").and_then(Value::as_array_mut) { + // Children were built one per source layer, skipped ones aside: match them by id. + for kid in kids.iter_mut() { + let id = kid.get("id").and_then(Value::as_str).map(str::to_string); + let Some(src) = sources.iter().find(|s| id.is_some() && s.get("do_objectID").and_then(Value::as_str) == id.as_deref()) else { continue }; + let Some(m) = kid.as_object_mut() else { continue }; + item(src, m, row, stretch); + } + kids.reverse(); + } +} + +/// An item's place in its stack: absolute, or sized fixed, hugging or filling. +fn item(src: &Value, m: &mut JsonMap, row: bool, stretch: bool) { + if src.get("flexItem").and_then(|f| f.get("ignoreLayout")).and_then(Value::as_bool) == Some(true) { + m.insert("layoutPositioning".into(), json!("ABSOLUTE")); + return; + } + let sizing = |k: &str| match src.get(k).and_then(Value::as_i64) { + Some(1) => "HUG", + Some(2) => "FILL", + _ => "FIXED", + }; + let (h, v) = (sizing("horizontalSizing"), sizing("verticalSizing")); + m.insert("layoutSizingHorizontal".into(), json!(h)); + m.insert("layoutSizingVertical".into(), json!(v)); + let (main, cross) = if row { (h, v) } else { (v, h) }; + if main == "FILL" { + m.insert("layoutGrow".into(), json!(1)); + } + if cross == "FILL" || stretch { + m.insert("layoutAlign".into(), json!("STRETCH")); + } +} diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs index f13515a6..13320673 100644 --- a/crates/format/src/sketch/style.rs +++ b/crates/format/src/sketch/style.rs @@ -38,7 +38,8 @@ pub(crate) fn apply(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonM /// A style's fills as REST paints. pub(crate) fn fills(cx: &mut Cx, style: &Value, w: f64, h: f64) -> Vec { - list(style, "fills").iter().filter_map(|f| paint(cx, f, w, h)).collect() + // Tints (`layeringType` 1) colour the layers inside; they aren't paints of the layer. + list(style, "fills").iter().filter(|f| f.get("layeringType").and_then(Value::as_i64) != Some(1)).filter_map(|f| paint(cx, f, w, h)).collect() } /// A style's borders as REST paints. @@ -101,7 +102,10 @@ fn paint(cx: &mut Cx, f: &Value, w: f64, h: f64) -> Option { } }; p["visible"] = json!(f.get("isEnabled").and_then(Value::as_bool).unwrap_or(true)); - if let Some(var) = swatch(cx, f.get("color")) { + // A colour variable binds a plain colour (a gradient's or image's `color` is unused). + if f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0 + && let Some(var) = swatch(cx, f.get("color")) + { p["boundVariables"] = var; } if let Some(ctx) = f.get("contextSettings") { @@ -131,7 +135,8 @@ fn gradient(g: &Value, w: f64, h: f64) -> Option { let across = if w > 0.0 && h > 0.0 { (from.0 - dy * e / w, from.1 + dx * e / h) } else { from }; ("GRADIENT_RADIAL", json!([pt(from), pt(to), pt(across)])) } - 2 => ("GRADIENT_ANGULAR", json!([pt((0.5, 0.5)), pt((0.5, 0.0))])), + // Sketch starts on the right and turns clockwise; from/to don't change it. + 2 => ("GRADIENT_ANGULAR", json!([pt((0.5, 0.5)), pt((1.0, 0.5)), pt((0.5, 1.0))])), _ => ("GRADIENT_LINEAR", json!([pt(from), pt(to)])), }; Some(json!({"type": ty, "gradientHandlePositions": handles, "gradientStops": stops})) @@ -167,6 +172,18 @@ fn borders(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap = o .get("dashPattern") @@ -218,6 +235,8 @@ pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec { let mut out = Vec::new(); for (key, ty) in [("shadows", "DROP_SHADOW"), ("innerShadows", "INNER_SHADOW")] { for s in list(style, key) { + // Sketch 2025 and newer keep inner shadows in the one list, marked. + let ty = if s.get("isInnerShadow").and_then(Value::as_bool) == Some(true) { "INNER_SHADOW" } else { ty }; let mut c = s.get("color").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 0.5})); let o = s.get("contextSettings").and_then(|c| num(c, "opacity")).unwrap_or(1.0).clamp(0.0, 1.0); c["a"] = json!(c["a"].as_f64().unwrap_or(1.0) * o); @@ -231,18 +250,54 @@ pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec { })); } } - if let Some(b) = style.get("blur").filter(|b| b.get("isEnabled").and_then(Value::as_bool) == Some(true)) { + // One `blur` (older files) or a list of `blurs` (Sketch 2025 and newer). + let one = style.get("blur").into_iter(); + let many = style.get("blurs").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default().iter().take(MAX_LIST); + for b in one.chain(many).filter(|b| b.get("isEnabled").and_then(Value::as_bool) == Some(true)) { let radius = num(b, "radius").unwrap_or(0.0).clamp(0.0, 1e6); match b.get("type").and_then(Value::as_i64).unwrap_or(0) { 0 => out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius})), - 3 => out.push(json!({"type": "BACKGROUND_BLUR", "visible": true, "radius": radius})), - _ => cx.warn("motion and zoom blurs were skipped"), + 3 => out.push(background_blur(b, radius)), + 4 => out.push(json!({ + "type": "GLASS", + "visible": true, + "radius": radius, + "refraction": num(b, "distortion").unwrap_or(0.5).clamp(0.0, 1.0), + "depth": num(b, "depth").unwrap_or(0.5).clamp(0.0, 1.0), + })), + _ => { + // Motion and zoom blurs: a plain blur of half the radius softens about as much. + cx.warn("motion and zoom blurs were drawn as plain blurs"); + out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius / 2.0})); + } } } out.truncate(MAX_LIST); out } +/// A background blur; a progressive one (`isProgressive`) ramps along its gradient, whose stops' +/// opacity scales the radius at each end. +fn background_blur(b: &Value, radius: f64) -> Value { + let g = b.get("gradient").filter(|_| b.get("isProgressive").and_then(Value::as_bool) == Some(true)); + let Some(g) = g else { return json!({"type": "BACKGROUND_BLUR", "visible": true, "radius": radius}) }; + let stops = g.get("stops").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let at = |s: Option<&Value>| s.and_then(|s| s.get("color")).and_then(|c| num(c, "alpha")).unwrap_or(1.0).clamp(0.0, 1.0) * radius; + let end = |k: &str, d: (f64, f64)| { + let (x, y) = g.get(k).and_then(Value::as_str).and_then(super::path::parse_point).unwrap_or(d); + json!({"x": x.clamp(-1e3, 1e3), "y": y.clamp(-1e3, 1e3)}) + }; + json!({ + "type": "BACKGROUND_BLUR", + "visible": true, + "blurType": "PROGRESSIVE", + "startRadius": at(stops.first()), + "radius": at(stops.last()), + "startOffset": end("from", (0.5, 0.0)), + "endOffset": end("to", (0.5, 1.0)), + }) +} + /// A bitmap's colour adjustments (`colorControls`: saturation and contrast 0–2 with 1 neutral, /// brightness −1–1, hue in radians) as REST image filters; hue has no filter (a note). pub(crate) fn color_controls(cx: &mut Cx, style: Option<&Value>) -> Option { @@ -264,6 +319,79 @@ pub(crate) fn swatch(cx: &Cx, c: Option<&Value>) -> Option { Some(json!({"color": {"type": "VARIABLE_ALIAS", "id": id}})) } +/// The tint a style gives the layers inside: its first enabled plain fill with `layeringType` 1 +/// (Sketch 2025 and newer), or with no `layeringType` when `legacy` (older files: a group's or +/// instance's fill was its tint). Sketch draws a tint as the colour of everything inside. +pub(crate) fn tint_of(style: Option<&Value>, legacy: bool) -> Option { + let fills = style?.get("fills")?.as_array()?; + let f = fills.iter().filter(|f| f.get("isEnabled").and_then(Value::as_bool) != Some(false)).find(|f| { + match f.get("layeringType").and_then(Value::as_i64) { + Some(t) => t == 1, + None => legacy, + } + })?; + (f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0).then(|| f.get("color").cloned()).flatten() +} + +/// Recolour `layers` (Sketch JSON) with tint `color`: every enabled fill and border takes it, +/// text takes it, nested instances take it as their own tint. Opacity stays as it was. +pub(crate) fn tint_layers(layers: &mut [Value], color: &Value, depth: usize) { + if depth > 64 { + return; + } + for l in layers { + if let Some(st) = l.get_mut("style").and_then(Value::as_object_mut) { + for key in ["fills", "borders"] { + for p in st.get_mut(key).and_then(Value::as_array_mut).into_iter().flatten() { + if let Some(m) = p.as_object_mut() + && m.get("isEnabled").and_then(Value::as_bool) != Some(false) + && !matches!(m.get("fillType").and_then(Value::as_i64), Some(4)) + { + let c = tinted(m.get("color"), color); + m.insert("fillType".into(), json!(0)); + m.insert("color".into(), c); + } + } + } + if let Some(enc) = st.get_mut("textStyle").and_then(|t| t.get_mut("encodedAttributes")).and_then(Value::as_object_mut) { + let c = tinted(enc.get("MSAttributedStringColorAttribute"), color); + enc.insert("MSAttributedStringColorAttribute".into(), c); + } + } + if let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) { + for r in runs.iter_mut().filter_map(|r| r.get_mut("attributes")).filter_map(Value::as_object_mut) { + let c = tinted(r.get("MSAttributedStringColorAttribute"), color); + r.insert("MSAttributedStringColorAttribute".into(), c); + } + } + if l.get("_class").and_then(Value::as_str) == Some("symbolInstance") { + set_tint(l, color); + } + if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { + tint_layers(kids, color, depth + 1); + } + } +} + +/// Colour `old` (Sketch JSON) tinted with `tint`: the tint's colour, with both opacities. +fn tinted(old: Option<&Value>, tint: &Value) -> Value { + let alpha = |c: Option<&Value>| c.and_then(|c| num(c, "alpha")).unwrap_or(1.0).clamp(0.0, 1.0); + let mut c = tint.clone(); + c["alpha"] = json!(alpha(Some(tint)) * alpha(old)); + c +} + +/// Give layer `l` (Sketch JSON) tint `color`: as its one fill. +pub(crate) fn set_tint(l: &mut Value, color: &Value) { + let fill = json!({"_class": "fill", "isEnabled": true, "fillType": 0, "color": color}); + if let Some(m) = l.as_object_mut() { + let st = m.entry("style").or_insert_with(|| json!({"_class": "style"})); + if let Some(st) = st.as_object_mut() { + st.insert("fills".into(), json!([fill])); + } + } +} + /// Sketch's blend modes (0 normal … 17 plus lighter) by REST name. fn blend(mode: i64) -> Option<&'static str> { const MODES: [&str; 18] = [ diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index 242b0478..b5d24bdf 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -78,7 +78,10 @@ fn gradients() { assert_eq!(lin.stops.len(), 2); assert_eq!(lin.stops[1].color.b, 1.0); assert_eq!(g("Radial").kind, GradientKind::Radial); - assert_eq!(g("Angular").kind, GradientKind::Angular); + // Sketch starts an angular gradient on the right and turns clockwise, whatever its from/to. + let ang = g("Angular"); + assert_eq!(ang.kind, GradientKind::Angular); + assert_eq!((ang.from, ang.to, ang.width), ((0.5, 0.5), (1.0, 0.5), (0.5, 1.0))); // The radial gradient's width handle: half the length, across the main axis (in pixels). let rad = g("Radial"); let (dx, dy) = ((rad.width.0 - rad.from.0) * 100.0, (rad.width.1 - rad.from.1) * 50.0); @@ -160,7 +163,8 @@ fn shadows_and_blurs() { assert!(matches!(fx[2].kind, EffectKind::InnerShadow(_))); assert!(matches!(node(&doc, "Blurred").effects[0].kind, EffectKind::LayerBlur(_))); assert!(matches!(node(&doc, "Frosted").effects[0].kind, EffectKind::BackgroundBlur(_))); - assert!(node(&doc, "Moving").effects.is_empty()); + // Motion blur: drawn as a plain blur of half the radius. + assert!(matches!(node(&doc, "Moving").effects[0].kind, EffectKind::LayerBlur(_))); assert!(warnings.iter().any(|w| w.contains("motion")), "{warnings:?}"); } diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index 1292c025..06a39f68 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -114,21 +114,49 @@ pub(crate) fn master_fills(m: &Value) -> Option { Some(json!([{"type": "SOLID", "color": c}])) } +/// What a master shows as a frame (its instances the same): clipping, and in Sketch 2025 and newer +/// its style (fills, borders, effects) and corners. +fn frame_look(cx: &mut Cx, m: &Value, w: f64, h: f64, node: &mut JsonMap) { + // `clippingBehavior` 2 doesn't clip. + node.insert("clipsContent".into(), json!(m.get("clippingBehavior").and_then(Value::as_i64) != Some(2))); + if m.get("groupBehavior").is_some() + && let Some(st) = m.get("style") + { + // Tint fills aren't painted (see `style::fills`). + super::style::apply(cx, st, w, h, node); + } + super::tree::style_corners(m, node); +} + /// A symbol master as a COMPONENT node. pub(crate) fn component(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { let (w, h) = size(l); let world = parent * local(l, w, h); let mut node = base(l, "COMPONENT", parent, world, w, h, root); - node.insert("clipsContent".into(), json!(true)); + frame_look(cx, l, w, h, &mut node); if let Some(f) = master_fills(l) { node.insert("fills".into(), f); } super::tree::extras(cx, l, h, &mut node); let sid = str_field(l, "symbolID").unwrap_or("").to_string(); + // A graphic's fill tints its layers. + let tinted; + let l = match super::style::tint_of(l.get("style"), false) { + Some(t) => { + let mut c = l.clone(); + if let Some(kids) = c.get_mut("layers").and_then(Value::as_array_mut) { + super::style::tint_layers(kids, &t, 0); + } + tinted = c; + &tinted + } + _ => l, + }; cx.chain.push(sid); let kids = children(cx, l, world, (w, h), depth, false); cx.chain.pop(); node.insert("children".into(), Value::Array(kids)); + super::stack::apply(l, &mut node); Some(Value::Object(node)) } @@ -161,15 +189,20 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo } let mut node = base(l, "INSTANCE", parent, world, w, h, root); node.insert("componentId".into(), json!(str_field(&m, "do_objectID").unwrap_or(sid))); - node.insert("clipsContent".into(), json!(true)); + frame_look(cx, &m, w, h, &mut node); // Instances show the master's background only when it says so. if bool_field(&m, "includeBackgroundColorInInstance") == Some(true) && let Some(f) = master_fills(&m) { node.insert("fills".into(), f); } + // An instance's fill is a tint for its layers, not a paint of its own. if let Some(st) = l.get("style") { - super::style::apply(cx, st, w, h, &mut node); + let mut st = st.clone(); + if let Some(o) = st.as_object_mut() { + o.remove("fills"); + } + super::style::apply(cx, &st, w, h, &mut node); } super::tree::extras(cx, l, h, &mut node); // An instance shows its master's grids (else they'd differ, and count as an override). @@ -187,21 +220,45 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo // The budget ran out handing overrides on: noted. cx.spend(1); } - // The master's layers laid out for the instance's size, as Sketch does it. - let (mw, mh) = size(©); - if ((mw - w).abs() > 1e-9 || (mh - h).abs() > 1e-9) + // The tint: the instance's own (a colour override on it, or its fill), else a graphic master's. + let own = str_field(l, "do_objectID").map(|id| format!("{id}_fillColor")); + let tint = own + .and_then(|k| overrides.get(&k).filter(|c| c.is_object()).cloned()) + .or_else(|| super::style::tint_of(l.get("style"), true)) + .or_else(|| super::style::tint_of(copy.get("style"), false)); + if let Some(t) = tint && let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) { - resize(layers, (mw, mh), (w, h), cx.legacy_resize, 0); + super::style::tint_layers(layers, &t, 0); + } + // The master's layers laid out for the instance's size, as Sketch does it. + let (mw, mh) = size(©); + // A graphic's layers resize in proportion, whatever their pins. + let graphic = copy.get("groupBehavior").and_then(Value::as_i64) == Some(2); + // A stack's layers are placed by its auto layout (when the file is opened), not by their pins. + let stacked = !graphic && super::stack::is_stack(©); + if ((mw - w).abs() > 1e-9 || (mh - h).abs() > 1e-9) + && !stacked + && let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) + { + resize(layers, (mw, mh), (w, h), cx.legacy_resize || graphic, 0); } cx.chain.push(sid.to_string()); let mut kids = children(cx, ©, world, (w, h), depth, false); cx.chain.pop(); + node.insert("children".into(), Value::Array(std::mem::take(&mut kids))); + // The master's stack lays the instance out too (matched by the master's layer ids), sized as + // the instance says. + for k in ["horizontalSizing", "verticalSizing"] { + if let Some(v) = l.get(k) { + copy[k] = v.clone(); + } + } + super::stack::apply(©, &mut node); let prefix = str_field(l, "do_objectID").unwrap_or("I").to_string(); - for k in &mut kids { + for k in node.get_mut("children").and_then(Value::as_array_mut).into_iter().flatten() { prefix_ids(k, &prefix, 0); } - node.insert("children".into(), Value::Array(kids)); Some(Value::Object(node)) } @@ -213,7 +270,7 @@ fn resize(layers: &mut [Value], old: (f64, f64), new: (f64, f64), legacy: bool, return; } for l in layers { - let mask = if legacy { 63 } else { l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63) }; + let mask = if legacy { 63 } else { super::tree::pin_mask(l) }; let Some(f) = l.get_mut("frame").and_then(Value::as_object_mut) else { continue }; let n = |k: &str| f.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0); let (x, y, w, h) = (n("x"), n("y"), n("width").max(0.0), n("height").max(0.0)); @@ -224,8 +281,10 @@ fn resize(layers: &mut [Value], old: (f64, f64), new: (f64, f64), legacy: bool, for (k, v) in [("x", nx), ("y", ny), ("width", nw), ("height", nh)] { f.insert(k.into(), json!(v)); } + // A graphic's layers resize in proportion, whatever their pins. + let graphic = l.get("groupBehavior").and_then(Value::as_i64) == Some(2); if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { - resize(kids, (w, h), (nw, nh), legacy, depth + 1); + resize(kids, (w, h), (nw, nh), legacy || graphic, depth + 1); } } } @@ -316,19 +375,46 @@ fn apply( own.extend(inner.iter().take(take).map(|(rest, v)| json!({"_class": "overrideValue", "overrideName": rest, "value": v}))); l["overrideValues"] = Value::Array(own); } + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } + // A colour override on an instance is its tint. + if let Some(c) = get("fillColor").filter(|c| c.is_object()) { + super::style::set_tint(l, c); + } continue; } Some("text") => { + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } if let Some(s) = get("stringValue").and_then(Value::as_str) { set_string(l, s); } + if let Some(c) = get("textColor").filter(|c| c.is_object()) { + super::style::tint_layers(std::slice::from_mut(l), c, 0); + } } Some("bitmap") => { + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } if let Some(img) = get("image").filter(|v| v.get("_ref").is_some()) { l["image"] = img.clone(); } } - _ => {} + _ => { + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } + // A colour override on a shape or group: its first fill's colour. + if let Some(c) = get("fillColor").filter(|c| c.is_object()) + && let Some(f) = l.get_mut("style").and_then(|s| s.get_mut("fills")).and_then(Value::as_array_mut).and_then(|f| f.first_mut()) + { + f["fillType"] = json!(0); + f["color"] = c.clone(); + } + } } for (prop, text) in [("layerStyle", false), ("textStyle", true)] { let sid = get(prop).and_then(Value::as_str); @@ -357,6 +443,11 @@ fn apply( } } +/// A visibility override's value: "0", 0 or false hide. +fn visible(v: &Value) -> bool { + !matches!(v, Value::Bool(false)) && v.as_str() != Some("0") && v.as_i64() != Some(0) +} + /// A text layer's string replaced, in the style of its first run. fn set_string(l: &mut Value, s: &str) { let units = s.encode_utf16().count(); diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index e1f0c263..0dd8d596 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -60,14 +60,17 @@ pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap) { _ => "TOP", }), ); - m.insert( - "textAutoResize".into(), - json!(match l.get("textBehaviour").and_then(Value::as_i64) { - Some(1) => "HEIGHT", - Some(2) => "NONE", - _ => "WIDTH_AND_HEIGHT", - }), - ); + // Sketch 2025 and newer size text by `horizontalSizing`/`verticalSizing` (1 fits). + let fits = |k: &str| l.get(k).and_then(Value::as_i64) == Some(1); + let resize = match l.get("textBehaviour").and_then(Value::as_i64) { + Some(1) => "HEIGHT", + Some(2) => "NONE", + Some(_) => "WIDTH_AND_HEIGHT", + None if l.get("horizontalSizing").is_none() || fits("horizontalSizing") => "WIDTH_AND_HEIGHT", + None if fits("verticalSizing") => "HEIGHT", + None => "NONE", + }; + m.insert("textAutoResize".into(), json!(resize)); } node.insert("characters".into(), json!(string)); node.insert("style".into(), style); diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 9a5bfaa3..3d6a48d3 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -151,6 +151,15 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: cx.too_old = true; return None; } + // A rectangle with angled or inside corners is a path of that outline. + let outlined; + let l = match super::path::cut_corners(l) { + Some(p) => { + outlined = p; + &outlined + } + None => l, + }; let class = str_field(l, "_class")?; if class == "shapeGroup" { return count(cx).then(|| super::path::shape_group(cx, l, parent, depth, root)).flatten(); @@ -163,6 +172,8 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: } let ty = match class { "artboard" | "frame" => "FRAME", + // Sketch 2025 and newer: a group that behaves as a frame (1) or a graphic (2), or clips. + "group" if is_frame(l) => "FRAME", "group" | "graphic" => "GROUP", // Shapes whose points were moved are paths. "rectangle" | "oval" if bool_field(l, "edited") == Some(true) => "VECTOR", @@ -204,6 +215,10 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: node.insert("fills".into(), json!([{"type": "SOLID", "color": c}])); } } + "group" if ty == "FRAME" => { + // `clippingBehavior`: 0 the default (frames clip), 1 clip, 2 don't. + node.insert("clipsContent".into(), json!(l.get("clippingBehavior").and_then(Value::as_i64) != Some(2))); + } "rectangle" => corner_radii(l, &mut node), "bitmap" => match l.get("image").and_then(|i| str_field(i, "_ref")) { Some(r) => { @@ -219,17 +234,80 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: } styles(cx, l, w, h, &mut node); extras(cx, l, h, &mut node); + style_corners(l, &mut node); if class == "MSImmutableHotspotLayer" || class == "hotspot" { node.insert("clipsContent".into(), json!(false)); node.remove("fills"); } + // A group's or graphic's fill tints its layers (a frame's is its background). + let tinted; + let gb = l.get("groupBehavior").and_then(Value::as_i64); + let l = match (class, super::style::tint_of(l.get("style"), gb != Some(1))) { + ("group", Some(t)) => { + // A plain group's legacy fill is all tint; a frame keeps its background fills. + if gb != Some(1) { + node.remove("fills"); + } + let mut c = l.clone(); + if let Some(kids) = c.get_mut("layers").and_then(Value::as_array_mut) { + super::style::tint_layers(kids, &t, 0); + } + tinted = c; + &tinted + } + _ => l, + }; if matches!(ty, "FRAME" | "GROUP") { let kids = children(cx, l, world, (w, h), depth, false); node.insert("children".into(), Value::Array(kids)); + super::stack::apply(l, &mut node); } Some(Value::Object(node)) } +/// A Sketch 2025+ group that is a frame: it behaves as a frame or a graphic, or clips. +pub(crate) fn is_frame(l: &Value) -> bool { + matches!(l.get("groupBehavior").and_then(Value::as_i64), Some(1 | 2)) || l.get("clippingBehavior").and_then(Value::as_i64) == Some(1) +} + +/// Corners kept in the style (Sketch 2025+: one radius for all, or one per corner from the top +/// left, clockwise) on shapes and frames that have corners; smooth corners keep their smoothing. +pub(crate) fn style_corners(l: &Value, node: &mut JsonMap) { + let Some(c) = l.get("style").and_then(|s| s.get("corners")) else { return }; + if !matches!(node.get("type").and_then(Value::as_str), Some("RECTANGLE" | "FRAME" | "COMPONENT" | "INSTANCE")) { + return; + } + let radii: Vec = c + .get("radii") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(Value::as_f64) + .filter(|r| r.is_finite()) + .map(|r| r.clamp(0.0, MAX_SIZE)) + .take(4) + .collect(); + match radii.as_slice() { + [] => return, + [r] => { + node.insert("cornerRadius".into(), json!(r)); + node.remove("rectangleCornerRadii"); + } + more => { + // Values repeat to fill the four corners. + let all: Vec = (0..4).map(|i| more.get(i % more.len()).copied().unwrap_or(0.0)).collect(); + node.insert("rectangleCornerRadii".into(), json!(all)); + } + } + // Style 1 is smooth: its smoothing applies. + if c.get("style").and_then(Value::as_i64) == Some(1) + && let Some(sm) = c.get("smoothing").and_then(Value::as_f64).filter(|v| v.is_finite()) + { + node.insert("cornerSmoothing".into(), json!(sm.clamp(0.0, 1.0))); + } +} + /// Layer `l`'s style into `node`, and its links to the styles made from its shared style. pub(crate) fn styles(cx: &mut Cx, l: &Value, w: f64, h: f64, node: &mut JsonMap) { // An artboard shows its background colour only; Sketch doesn't draw a style it carries. @@ -291,7 +369,7 @@ pub(crate) fn base(l: &Value, ty: &str, parent: Affine, world: Affine, w: f64, h let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); node.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()})); } - node.insert("constraints".into(), constraints(l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63))); + node.insert("constraints".into(), constraints(pin_mask(l))); node } @@ -317,6 +395,33 @@ pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine { * Affine::translate((-w / 2.0, -h / 2.0)) } +/// A layer's pins as a `resizingConstraint` mask. Sketch 2025 and newer keep them in +/// `horizontalPins`/`verticalPins` (bits: 1 the start, 4 the end) and `horizontalSizing`/ +/// `verticalSizing` (fixed and fit keep the size; fill and relative don't), with +/// `resizingConstraint` left at 63. +pub(crate) fn pin_mask(l: &Value) -> i64 { + if l.get("horizontalPins").is_none() && l.get("horizontalSizing").is_none() { + return l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63); + } + let int = |k: &str| l.get(k).and_then(Value::as_i64).unwrap_or(0); + let fixed = |k: &str| matches!(int(k), 0 | 1); + let mut mask = 63; + // A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32. + for (on, bit) in [ + (int("horizontalPins") & 1 != 0, 4), + (int("horizontalPins") & 4 != 0, 1), + (fixed("horizontalSizing"), 2), + (int("verticalPins") & 1 != 0, 32), + (int("verticalPins") & 4 != 0, 8), + (fixed("verticalSizing"), 16), + ] { + if on { + mask &= !bit; + } + } + mask +} + /// REST constraints from Sketch's `resizingConstraint`: six bits, a cleared bit pins that side or /// fixes that size (right 1, width 2, left 4, bottom 8, height 16, top 32). A layer pinned on /// neither side keeps its place in proportion (scale) unless its size is fixed (centre). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8759a359..4d7b92ed 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards (and Sketch 2025 frames) as frames with their background, groups, rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Warnings: noise fills, motion and zoom blurs, embedded fonts (not used), Smart Layout and stacks (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%; Sketch 2025 files on fixtures only | 5–10 (Smart Layout as auto layout, real Sketch 2025 files) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), embedded fonts (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 1.2% on average (at most 3.2%) | 5–10 (Smart Layout as auto layout, progressive layer opacity, motion and zoom blurs) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -166,7 +166,7 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Checked on fixtures and locally on one real Sketch 50 file and the IBM Carbon Design Kit (Sketch 47, 68, 83). Numbers unchanged | +| 2026-10-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Sketch 2025 and newer: frames, graphics, stacks as auto layout, corners, pins and sizing, tints, per-side borders, blur lists. Checked on fixtures and locally on real Sketch 50, 64 and 2026 files and the IBM Carbon Design Kit (Sketch 47, 68, 83). Numbers unchanged | | 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | From 41ff790d779d4755df693aa8c9a14d4054b25940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 22:58:19 +0200 Subject: [PATCH 22/87] M6: Sketch import: variable font axes; no false embedded-font note Sketch keys a font's variation axes by the tag read as a number; they map onto the text style's variations (a fontVariations key in the REST mapping). Font references without font data only name the fonts a document uses and no longer raise the embedded-font note. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/rest/text.rs | 9 +++++++++ crates/format/src/sketch/mod.rs | 15 +++++++++++---- crates/format/src/sketch/text.rs | 20 ++++++++++++++++++++ crates/format/src/sketch/text_tests.rs | 24 ++++++++++++++++++++++++ docs/file-format-parity.md | 2 +- 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 367677a6..99ceb500 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -102,6 +102,15 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle { let weight = num(s, "fontWeight").map_or_else(|| t.weight(), |w| w.clamp(1.0, 1000.0) as u16); t.font_style = style_name(weight, flag(s, "italic").unwrap_or_else(|| t.italic())); } + // Variable font axes, `{tag: value}` (FigCraft's own key; the Sketch importer writes it). + if let Some(axes) = s.get("fontVariations").and_then(Value::as_object) { + t.variations = axes + .iter() + .take(64) + .filter(|(k, _)| k.len() == 4 && k.is_ascii()) + .filter_map(|(k, v)| Some((k.clone(), v.as_f64().filter(|x| x.is_finite())?.clamp(-1e5, 1e5)))) + .collect(); + } if let Some(size) = num(s, "fontSize").filter(|x| *x > 0.0) { t.font_size = size.clamp(0.5, 10_000.0); } diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 8d8a465e..15df9d22 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -127,12 +127,19 @@ fn import_parts(bytes: &[u8], name: &str, max_member: u64, max_nodes: usize) -> Ok((doc, warnings)) } -/// The note about fonts the file carries (Sketch 7x and newer can embed them): FigCraft doesn't -/// use fonts from inside documents, so their text shows in installed fonts. +/// The note about fonts the file carries (Sketch 7x and newer can embed them, as `fontReferences` +/// with `fontData`): FigCraft doesn't use fonts from inside documents, so their text shows in +/// installed fonts. fn embedded_fonts(document: &Value) -> Option { let refs = document.get("fontReferences").and_then(Value::as_array)?; - let families: BTreeSet = - refs.iter().filter_map(|r| r.get("fontFamilyName").and_then(Value::as_str)).map(|f| f.chars().take(64).collect()).take(20).collect(); + // A reference without font data only names a font the document uses. + let families: BTreeSet = refs + .iter() + .filter(|r| r.get("fontData").is_some()) + .filter_map(|r| r.get("fontFamilyName").and_then(Value::as_str)) + .map(|f| f.chars().take(64).collect()) + .take(20) + .collect(); if families.is_empty() { return None; } diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index 0dd8d596..76692790 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -129,6 +129,9 @@ pub(crate) fn type_style(cx: &mut Cx, a: &Value) -> Value { cx.warn("text without a font uses Helvetica"); } let mut s = json!({"fontFamily": family, "fontStyle": style, "fontSize": size.min(10_000.0)}); + if let Some(v) = font.and_then(|f| f.get("variation")).and_then(variations) { + s["fontVariations"] = v; + } let p = a.get("paragraphStyle"); let line = |k: &str| p.and_then(|p| p.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); if let Some(lh) = line("maximumLineHeight").or_else(|| line("minimumLineHeight")) { @@ -160,6 +163,23 @@ pub(crate) fn type_style(cx: &mut Cx, a: &Value) -> Value { s } +/// A font's `variation` (axis values keyed by the axis tag read as a big-endian number, as Apple's +/// text engine keys them: 2003265652 is `wght`) as `{tag: value}`; `None` when it has none. +fn variations(v: &Value) -> Option { + let axes: JsonMap = v + .as_object()? + .iter() + .take(64) + .filter_map(|(k, val)| { + let tag = u32::try_from(k.parse::().ok()?).ok()?.to_be_bytes(); + let tag = std::str::from_utf8(&tag).ok().filter(|t| t.chars().all(|c| c.is_ascii_graphic()))?.to_string(); + let val = val.as_f64().filter(|x| x.is_finite())?; + Some((tag, json!(val.clamp(-1e5, 1e5)))) + }) + .collect(); + (!axes.is_empty()).then_some(Value::Object(axes)) +} + /// Family and style of a font from its PostScript name: "HelveticaNeue-Bold" → ("Helvetica Neue", /// "Bold"). Words of the family are split at case changes, which can't always match the real name /// (a CJK family would split as "Ping Fang SC"); the engine matches families against the installed diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs index 41b86f68..53ca5235 100644 --- a/crates/format/src/sketch/text_tests.rs +++ b/crates/format/src/sketch/text_tests.rs @@ -211,3 +211,27 @@ fn line_spacing_behaviour_moves_text() { assert!((y("B0") - 106.6).abs() < 1e-6, "{}", y("B0")); assert!((y("B1") - 93.4).abs() < 1e-6, "{}", y("B1")); } + +/// A variable font's axes (Sketch keys them by the tag as a number: 2003265652 is `wght`). +#[test] +fn font_variations() { + let mut t = text("Wide", 0.0, 0.0, 200.0, 50.0, "Orbit", &[(0, 5, "Skia", 40.0, BLACK)]); + t["attributedString"]["attributes"][0]["attributes"]["MSAttributedStringFontAttribute"]["attributes"]["variation"] = + json!({"2003265652": 3, "2003072104": 0.65, "junk": 1, "1": "x"}); + let (doc, _) = open(vec![t]); + let v = &data(&doc, "Wide").style.variations; + assert_eq!(v.get("wght"), Some(&3.0)); + assert_eq!(v.get("wdth"), Some(&0.65)); + assert_eq!(v.len(), 2, "{v:?}"); +} + +/// Font references without font data are only the fonts the document uses: no note. +#[test] +fn font_references_without_data_are_no_embedded_fonts() { + let mut d = document(&["P"]); + d["fontReferences"] = + json!([{"_class": "fontReference", "fontFamilyName": "Skia", "fontFileName": "Skia.ttf", "postscriptNames": ["Skia-Regular"]}]); + let z = sketch_zip("2026.3.1", 196, d, &[page("P", "Page", vec![])], &[]); + let (_, warnings) = import(&z, "f").unwrap(); + assert!(!warnings.iter().any(|w| w.contains("embedded")), "{warnings:?}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4d7b92ed..998b6091 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), embedded fonts (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 1.2% on average (at most 3.2%) | 5–10 (Smart Layout as auto layout, progressive layer opacity, motion and zoom blurs) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families, variable font axes; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 1.2% on average (at most 3.2%) | 5–10 (Smart Layout as auto layout, progressive layer opacity, motion and zoom blurs) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From f02557e8f9af96ebda90a44e94d428a1d8e823e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 23:26:27 +0200 Subject: [PATCH 23/87] M6: Sketch import: fonts by PostScript name, Sketch's natural line The font database knows each face's PostScript name; the Sketch import finds the installed face a file's font names (CourierNewPSMT is Courier New Regular) and reads the family from the name only when it isn't installed. Text without a fixed line height takes the line Sketch draws: ascent and descent rounded to whole pixels, the line gap left out, a fifth more for classic Mac fonts, the baseline where Sketch puts it (measured on sketchtool renders of 80 fonts). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 81 ++++++++++++++++++++++-- crates/engine/src/sketch_import_tests.rs | 40 ++++++++++++ crates/format/src/sketch/mod.rs | 32 ++++++++-- crates/format/src/sketch/text.rs | 19 +++++- crates/format/src/sketch/text_tests.rs | 15 +++++ crates/format/src/sketch/tree.rs | 7 +- crates/text/src/fontdb.rs | 38 +++++++++-- crates/text/src/fontdb_tests.rs | 31 ++++++++- docs/file-format-parity.md | 2 +- 9 files changed, 244 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 5de7e0fd..8db15cee 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -556,13 +556,18 @@ fn load_with( /// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try. fn load_sketch(bytes: &[u8], name: &str) -> Option> { - let r = figcraft_format::sketch::try_import(bytes, name)?; + if !figcraft_format::sketch::is_sketch(bytes) { + return None; + } + // The import and what follows read the installed fonts: the system's must be known first (the + // desktop app scans them in the background; this waits for that scan). + figcraft_text::fontdb::scan_system_fonts(); + let fonts: figcraft_format::sketch::FontLookup = Box::new(figcraft_text::fontdb::postscript_face); + let r = figcraft_format::sketch::try_import_with_fonts(bytes, name, Some(fonts))?; Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { - // Both read the installed fonts: the system's must be known first (the desktop app scans - // them in the background; this waits for that scan). - figcraft_text::fontdb::scan_system_fonts(); match_font_families(&mut doc); platform_tracking(&mut doc); + sketch_line_heights(&mut doc); link_instances(&mut doc); reflow_overridden_text(&mut doc); figcraft_format::Loaded { issues: warnings, ..doc.into() } @@ -665,6 +670,74 @@ fn platform_tracking(doc: &mut Document) { } } +/// Families Sketch (Apple's text system) gives a fifth more than their ascent and descent as their +/// natural line: classic Mac fonts, found on `sketchtool` renders. Other fonts show none of this. +const TALL_LINE_FAMILIES: [&str; 11] = + ["Academy Engraved LET", "Courier", "Helvetica", "Hoefler Text", "Palatino", "Sathu", "Silom", "Skia", "Times", "Webdings", "Zapf Dingbats"]; + +/// Sketch's natural line for a font of `ascent` and `descent` in `upem` units at `size` px: its +/// height in px and how far down the text moves so its baseline sits where Sketch puts it. Sketch +/// rounds the ascent and descent to whole pixels each and leaves the font's line gap out (a fifth +/// more for [`TALL_LINE_FAMILIES`]), with the baseline above the rounded descent; FigCraft centres +/// the font's own ascent and descent in the line. `(0, 0)` without metrics. +pub(crate) fn sketch_line(ascent: f64, descent: f64, upem: f64, family: &str, size: f64) -> (f64, f64) { + let k = size / upem; + let (a, d) = (ascent * k, descent * k); + if !(a.is_finite() && d.is_finite()) || a + d <= 0.0 { + return (0.0, 0.0); + } + let natural = a.round() + d.round(); + let line = if TALL_LINE_FAMILIES.iter().any(|f| f.eq_ignore_ascii_case(family)) { (natural * 1.2).round() } else { natural }; + let sketch_baseline = line - d.round(); + let figcraft_baseline = (line - (a + d)) / 2.0 + a; + (line, sketch_baseline - figcraft_baseline) +} + +/// Text without a fixed line height takes the line Sketch drew it with (FigCraft's natural line, +/// like Figma's, counts the font's line gap and centres the font in it; Sketch's doesn't), as a +/// share of its size so it still follows the size, and moves to Sketch's baseline. Shared text +/// styles take the line too. +fn sketch_line_heights(doc: &mut Document) { + // Per family and style: the face's ascent, descent and units per em. + type Metrics = Option<(f64, f64, f64)>; + let mut cache: std::collections::BTreeMap<(String, String), Metrics> = std::collections::BTreeMap::new(); + let mut set = |style: &mut TextStyle| -> f64 { + if style.line_height != LineHeight::Auto || !style.font_size.is_finite() || style.font_size <= 0.0 { + return 0.0; + } + let m = *cache.entry((style.font_family.clone(), style.font_style.clone())).or_insert_with(|| { + figcraft_text::fontdb::has_family(&style.font_family) + .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) + .flatten() + .map(|f| (f.ascent, f.descent, f.upem)) + }); + let Some((a, d, upem)) = m else { return 0.0 }; + let (line, down) = sketch_line(a, d, upem, &style.font_family, style.font_size); + if line > 0.0 { + style.line_height = LineHeight::Percent(line / style.font_size * 100.0); + } + down + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + let Some(n) = doc.node_mut(id) else { continue }; + let NodeKind::Text(td) = &mut n.kind else { continue }; + let down = set(&mut td.style); + for r in &mut td.runs { + set(&mut r.style); + } + // Down in the layer's own frame (a rotated text moves along its own axis). + if down.is_finite() && down != 0.0 { + n.transform *= figcraft_doc::geom::Affine::translate((0.0, down)); + } + } + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + set(style); + } + } +} + /// The installed family `family` means when it isn't installed under that exact name: the one /// whose name is the same without spaces, ignoring case. pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option { diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index 5dc4b31a..36b200a5 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -401,3 +401,43 @@ fn sketch_resized_stack_instances_reflow() { assert!((b.x0 - a.x1 - 8.0).abs() < 0.5, "{a:?} {b:?}"); assert!((doc.world_bounds(inst).unwrap().x1 - 342.0).abs() < 1e-6); } + +/// Sketch's natural line: the font's ascent and descent each rounded to whole pixels, without its +/// line gap, the baseline at the rounded ascent; the classic Mac fonts get a fifth more, all of it +/// above the text. +#[test] +fn sketch_natural_line_rule() { + use crate::cmd::file::sketch_line; + // Helvetica Neue Medium (975 + 217 of 1000, a gap of 29 left out) at 17 px: 16.575 → 17 and + // 3.689 → 4, a line of 21 with the baseline at 17. FigCraft puts the baseline at + // (21 − 20.264) / 2 + 16.575 = 16.943, so the text moves down 0.057. + let (line, down) = sketch_line(975.0, 217.0, 1000.0, "Helvetica Neue", 17.0); + assert!((line - 21.0).abs() < 1e-9 && (down - 0.057).abs() < 1e-9, "{line} {down}"); + // Helvetica at 100 px: 77 + 23 and a fifth more, 120, all of it above: the baseline at 97 + // where FigCraft centres it at 87. + let (line, down) = sketch_line(1577.0, 471.0, 2048.0, "helvetica", 100.0); + assert!((line - 120.0).abs() < 1e-9 && (down - 10.0).abs() < 0.05, "{line} {down}"); + assert_eq!(sketch_line(0.0, 0.0, 0.0, "X", 12.0), (0.0, 0.0), "no metrics, no change"); + assert_eq!(sketch_line(900.0, 200.0, 1000.0, "X", f64::NAN), (0.0, 0.0)); +} + +/// Text without a fixed line height gets Sketch's natural line as a share of its size, in the +/// layer and in shared text styles. +#[test] +fn sketch_text_gets_sketchs_natural_line() { + if !std::path::Path::new("/System/Library/Fonts/HelveticaNeue.ttc").exists() { + return; + } + figcraft_text::fontdb::scan_system_fonts(); + let font = json!({"name": "HelveticaNeue", "size": 100}); + let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}}); + let t = shape("text", "Label", [0.0, 0.0, 200.0, 120.0], json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0})); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[t])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") }; + // 95.2 → 95 and 21.3 → 21: 116 px, as a share of the size. + let figcraft_doc::LineHeight::Percent(p) = td.style.line_height else { panic!("{:?}", td.style.line_height) }; + assert!((p - 116.0).abs() < 1e-9, "{p}"); +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 15df9d22..18332afd 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -81,35 +81,55 @@ pub fn is_sketch(bytes: &[u8]) -> bool { })) } +/// Finds the installed face a PostScript name belongs to: its family and style. +pub type FontLookup = Box Option<(String, String)>>; + /// Import `bytes` when they are a `.sketch` file; `None` when they are something else. pub fn try_import(bytes: &[u8], name: &str) -> Option), FormatError>> { - is_sketch(bytes).then(|| import(bytes, name)) + try_import_with_fonts(bytes, name, None) +} + +/// [`try_import`] with `fonts` finding the installed faces the file's fonts (PostScript names) +/// belong to; families of fonts it doesn't find are read from their names. +pub fn try_import_with_fonts(bytes: &[u8], name: &str, fonts: Option) -> Option), FormatError>> { + is_sketch(bytes).then(|| import_with_fonts(bytes, name, fonts)) } /// Build a document from a `.sketch` file. Returns it with the warnings about what was skipped or /// approximated. `name`: the document name. pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), FormatError> { - import_parts(bytes, name, MAX_JSON_MEMBER, crate::fig::layer_budget(bytes.len())) + import_with_fonts(bytes, name, None) +} + +/// [`import`] with a font lookup (see [`try_import_with_fonts`]). +pub fn import_with_fonts(bytes: &[u8], name: &str, fonts: Option) -> Result<(Document, Vec), FormatError> { + import_parts(bytes, name, MAX_JSON_MEMBER, crate::fig::layer_budget(bytes.len()), fonts) } /// [`import`] reading pages of at most `max_member` bytes of JSON each. #[cfg(test)] pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result<(Document, Vec), FormatError> { - import_parts(bytes, name, max_member, crate::fig::layer_budget(bytes.len())) + import_parts(bytes, name, max_member, crate::fig::layer_budget(bytes.len()), None) } /// [`import`] with at most `max_nodes` layers. #[cfg(test)] pub(crate) fn import_within_nodes(bytes: &[u8], name: &str, max_nodes: usize) -> Result<(Document, Vec), FormatError> { - import_parts(bytes, name, MAX_JSON_MEMBER, max_nodes) + import_parts(bytes, name, MAX_JSON_MEMBER, max_nodes, None) } -fn import_parts(bytes: &[u8], name: &str, max_member: u64, max_nodes: usize) -> Result<(Document, Vec), FormatError> { +fn import_parts( + bytes: &[u8], + name: &str, + max_member: u64, + max_nodes: usize, + fonts: Option, +) -> Result<(Document, Vec), FormatError> { if bytes.starts_with(b"SQLite format 3\0") { return Err(FormatError::Io(TOO_OLD.into())); } let archive = Archive::open(bytes, max_member)?; - let map = tree::to_rest(&archive, name, max_nodes); + let map = tree::to_rest(&archive, name, max_nodes, fonts); if map.too_old { return Err(FormatError::Io(TOO_OLD.into())); } diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index 76692790..027de660 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -124,7 +124,7 @@ pub(crate) fn type_style(cx: &mut Cx, a: &Value) -> Value { let font = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes")); let ps = font.and_then(|f| f.get("name")).and_then(Value::as_str).unwrap_or(""); let size = font.and_then(|f| f.get("size")).and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(12.0); - let (family, style) = font_name(ps); + let (family, style) = installed(cx, ps).unwrap_or_else(|| font_name(ps)); if ps.is_empty() { cx.warn("text without a font uses Helvetica"); } @@ -180,6 +180,23 @@ fn variations(v: &Value) -> Option { (!axes.is_empty()).then_some(Value::Object(axes)) } +/// The installed family and style of PostScript name `ps`, when the import has a font lookup and +/// the font is installed. +fn installed(cx: &mut Cx, ps: &str) -> Option<(String, String)> { + if ps.is_empty() { + return None; + } + if let Some(found) = cx.font_cache.get(ps) { + return found.clone(); + } + let found = cx.fonts.as_ref().and_then(|f| f(ps)); + // A file names a few hundred fonts at most; junk ones don't grow the cache past that. + if cx.font_cache.len() < 4096 { + cx.font_cache.insert(ps.to_string(), found.clone()); + } + found +} + /// Family and style of a font from its PostScript name: "HelveticaNeue-Bold" → ("Helvetica Neue", /// "Bold"). Words of the family are split at case changes, which can't always match the real name /// (a CJK family would split as "Ping Fang SC"); the engine matches families against the installed diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs index 53ca5235..8adef1ea 100644 --- a/crates/format/src/sketch/text_tests.rs +++ b/crates/format/src/sketch/text_tests.rs @@ -235,3 +235,18 @@ fn font_references_without_data_are_no_embedded_fonts() { let (_, warnings) = import(&z, "f").unwrap(); assert!(!warnings.iter().any(|w| w.contains("embedded")), "{warnings:?}"); } + +/// With a font lookup, a PostScript name gives the installed family and style it belongs to (the +/// name alone can't always tell: "CourierNewPSMT" is Courier New Regular); without one, or for a +/// font that isn't installed, the family is read from the name. +#[test] +fn postscript_names_find_installed_fonts() { + let mk = |name: &str, ps: &str| text(name, 0.0, 0.0, 100.0, 20.0, "Hi", &[(0, 2, ps, 14.0, BLACK)]); + let z = sketch_zip("83.2", 139, document(&["P"]), &[page("P", "Page", vec![mk("A", "CourierNewPSMT"), mk("B", "BrandSans-Bold")])], &[]); + let lookup: crate::sketch::FontLookup = Box::new(|ps: &str| (ps == "CourierNewPSMT").then(|| ("Courier New".to_string(), "Regular".to_string()))); + let (doc, _) = crate::sketch::import_with_fonts(&z, "f", Some(lookup)).unwrap(); + assert_eq!((data(&doc, "A").style.font_family.as_str(), data(&doc, "A").style.font_style.as_str()), ("Courier New", "Regular")); + assert_eq!(data(&doc, "B").style.font_family, "Brand Sans"); + let (doc, _) = import(&z, "f").unwrap(); + assert_eq!(data(&doc, "A").style.font_family, "Courier New PSMT"); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 3d6a48d3..e651463d 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -58,6 +58,9 @@ pub(crate) struct Cx { /// Layers mapped so far, and the most the file may give. pub nodes: usize, pub max_nodes: usize, + /// Finds installed faces by PostScript name, and what it found (or didn't) so far. + pub fonts: Option, + pub font_cache: std::collections::BTreeMap>, } impl Cx { @@ -90,7 +93,7 @@ impl Cx { } /// Map the pages of `archive` to a REST file JSON named `name`. -pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { +pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize, fonts: Option) -> Map { let symbols = super::symbols::Symbols::read(&archive.document, &archive.pages); let shared = super::shared::Shared::read(&archive.document); let mut cx = Cx { @@ -109,6 +112,8 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { too_old: false, nodes: 0, max_nodes, + fonts, + font_cache: std::collections::BTreeMap::new(), }; let defs = super::shared::Shared::definitions(&mut cx); cx.style_defs = defs.iter().filter_map(|d| d.get("id").and_then(Value::as_str).map(str::to_string)).collect(); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 22ca4b84..34d76b12 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -86,6 +86,8 @@ pub struct Face { pub id: u32, pub family: String, pub style: String, + /// The face's PostScript name ("HelveticaNeue-Bold"). + pub postscript: Option, pub weight: u16, pub italic: bool, pub bytes: FontBytes, @@ -185,6 +187,7 @@ impl Face { id: NEXT_FACE.fetch_add(1, Ordering::Relaxed), italic: !matches!(attrs.style, skrifa::attribute::Style::Normal) || style.to_ascii_lowercase().contains("italic"), weight, + postscript: name_string(&f, StringId::POSTSCRIPT_NAME), family, style, index, @@ -493,6 +496,8 @@ struct Pending { path: std::path::PathBuf, index: u32, style: String, + /// The face's PostScript name. + postscript: Option, } #[derive(Default)] @@ -570,6 +575,16 @@ impl FontDb { (0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None)).map(|f| self.add(f)).collect() } + /// The family and style of the face whose PostScript name is `ps` (ignoring case), loaded or + /// only scanned. + pub fn postscript_face(&self, ps: &str) -> Option<(String, String)> { + let is = |p: &Option| p.as_deref().is_some_and(|p| p.eq_ignore_ascii_case(ps)); + if let Some(f) = self.faces.iter().find(|f| is(&f.postscript)) { + return Some((f.family.clone(), f.style.clone())); + } + self.pending.values().find_map(|(display, entries)| entries.iter().find(|e| is(&e.postscript)).map(|e| (display.clone(), e.style.clone()))) + } + /// Family display names, sorted (loaded and known system families). pub fn families(&self) -> Vec { let mut v: Vec = self.families.values().map(|(d, _)| d.clone()).collect(); @@ -897,8 +912,13 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap Option> { Some(buf) } -/// (index, family, style) of each face in a font file: outline fonts and colour fonts (Apple Color +/// (index, family, style, PostScript name) of each face in a font file: outline fonts and colour fonts (Apple Color /// Emoji draws most emoji from `sbix`, Noto Color Emoji has no outlines at all). -fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { +fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String, Option)> { let count = match FileRef::new(bytes) { Ok(FileRef::Collection(c)) => c.len(), Ok(FileRef::Font(_)) => 1, @@ -959,7 +979,7 @@ fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { .then_some(())?; let (family, style) = names(&f); let family = public_family(&family?)?; - Some((i, family, style.unwrap_or_else(|| "Regular".into()))) + Some((i, family, style.unwrap_or_else(|| "Regular".into()), name_string(&f, StringId::POSTSCRIPT_NAME))) }) .collect() } @@ -989,6 +1009,14 @@ fn apple_alias(key: &str) -> Option<&'static str> { } /// Convenience: find a face with the global db. +/// The family and style of the installed face whose PostScript name is `ps`. +pub fn postscript_face(ps: &str) -> Option<(String, String)> { + if ps.is_empty() { + return None; + } + db().read().unwrap_or_else(std::sync::PoisonError::into_inner).postscript_face(ps) +} + pub fn find_face(family: &str, style: &str) -> Option> { let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); db.find(family, style).or_else(|| db.default_face()) diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 0b83e1d3..5ff1e2e6 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -2,7 +2,8 @@ use super::*; fn pending(styles: &[&str]) -> FontDb { let mut db = FontDb::default(); - let entries = styles.iter().map(|s| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: (*s).into() }).collect(); + let entries = + styles.iter().map(|s| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: (*s).into(), postscript: None }).collect(); db.pending.insert("cjk ui".into(), ("CJK UI".into(), entries)); db } @@ -221,7 +222,7 @@ fn user_font_dirs_preserve_non_utf8_paths() { /// A rescan offers new families and the styles installed since, and never a file twice. #[test] fn a_rescan_offers_fonts_installed_since() { - let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into() }; + let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into(), postscript: None }; let mut db = pending(&["Regular"]); let found = |fams: &[(&str, &[&str])]| -> BTreeMap)> { fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect() @@ -271,7 +272,7 @@ fn apple_system_font_names_find_the_system_font() { let mut db = FontDb::default(); db.pending.insert( "system font".into(), - ("System Font".into(), vec![Pending { path: "no-such-dir/SFNS.ttf".into(), index: 0, style: "Regular".into() }]), + ("System Font".into(), vec![Pending { path: "no-such-dir/SFNS.ttf".into(), index: 0, style: "Regular".into(), postscript: None }]), ); for name in ["SF Pro", "SF Pro Text", "SF Pro Display", "SF UI Text", "SF UI Display", ".SF NS Text", "San Francisco"] { assert!(db.has_family(name), "{name}"); @@ -324,3 +325,27 @@ fn trak_tracking_by_size() { let _ = f.tracking(12.0); } } + +/// A face is found by its PostScript name (ignoring case), loaded or only scanned: the family and +/// style it is offered under. +#[test] +fn faces_by_postscript_name() { + let mut db = FontDb::default(); + db.pending.insert( + "brand sans".into(), + ( + "Brand Sans".into(), + vec![Pending { path: "no-such-dir/b.ttf".into(), index: 0, style: "Bold".into(), postscript: Some("BrandSans-Bold".into()) }], + ), + ); + assert_eq!(db.postscript_face("brandsans-bold"), Some(("Brand Sans".to_string(), "Bold".to_string()))); + assert_eq!(db.postscript_face("BrandSans-Light"), None); + if let Some(f) = CRAFT_FONTS.first() + && let Some(face) = Face::from_bytes(FontBytes::Static(f.bytes), 0, Some(f.family), Some(f.style)) + && let Some(ps) = face.postscript.clone() + { + db.add(face); + assert_eq!(db.postscript_face(&ps), Some((f.family.to_string(), f.style.to_string()))); + } + assert!(postscript_face("").is_none()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 998b6091..d1038576 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families, variable font axes; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 1.2% on average (at most 3.2%) | 5–10 (Smart Layout as auto layout, progressive layer opacity, motion and zoom blurs) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 1.2% on average (at most 3.2%) | 5–10 (Smart Layout as auto layout, progressive layer opacity, motion and zoom blurs) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From a8bade1446063e2ffefe9194bb622af98642b49b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 23:37:28 +0200 Subject: [PATCH 24/87] M6: Sketch import: alpha masks, Fade, linear gradient geometry Alpha masks only mask (no drawn copy of their fill); a progressive opacity (Fade) becomes an alpha mask of its gradient over the layer; linear gradients run square in pixels as Sketch draws them. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/style.rs | 7 +++- crates/format/src/sketch/style_tests.rs | 46 +++++++++++++++++++++++++ crates/format/src/sketch/tree.rs | 45 ++++++++++++++++++++++-- docs/file-format-parity.md | 2 +- 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs index 13320673..45c739e6 100644 --- a/crates/format/src/sketch/style.rs +++ b/crates/format/src/sketch/style.rs @@ -137,7 +137,12 @@ fn gradient(g: &Value, w: f64, h: f64) -> Option { } // Sketch starts on the right and turns clockwise; from/to don't change it. 2 => ("GRADIENT_ANGULAR", json!([pt((0.5, 0.5)), pt((1.0, 0.5)), pt((0.5, 1.0))])), - _ => ("GRADIENT_LINEAR", json!([pt(from), pt(to)])), + // Square to the line in pixels, as Sketch draws it (FigCraft's default is square in 0–1). + _ => { + let (dx, dy) = ((to.0 - from.0) * w, (to.1 - from.1) * h); + let across = if w > 0.0 && h > 0.0 { (from.0 - dy / w, from.1 + dx / h) } else { (from.0 - (to.1 - from.1), from.1 + (to.0 - from.0)) }; + ("GRADIENT_LINEAR", json!([pt(from), pt(to), pt(across)])) + } }; Some(json!({"type": ty, "gradientHandlePositions": handles, "gradientStops": stops})) } diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index b5d24bdf..e553ab6b 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -318,3 +318,49 @@ fn bitmap_color_controls_become_filters() { assert_eq!(img.filters.contrast, 0.0); assert!(warnings.iter().any(|w| w.contains("hue")), "{warnings:?}"); } + +/// An alpha mask (`clippingMaskMode` 1) only masks: Sketch doesn't draw its fill under the content. +#[test] +fn alpha_masks_draw_no_copy() { + let fill = json!([solid_fill(0.0, 0.0, 0.0, 1.0)]); + let m = styled(with(with(r("Fade"), "hasClippingMask", json!(true)), "clippingMaskMode", json!(1)), "fills", fill); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 100.0, 50.0, vec![m, r("Photo")])]); + assert!(!doc.nodes().any(|n| n.name == "Fade (drawn)")); + assert_eq!(node(&doc, "Fade").mask_type, figcraft_doc::MaskType::Alpha); +} + +/// A progressive opacity (Fade: `contextSettings.isProgressive` with a gradient) becomes an alpha +/// mask of that gradient over the layer, in a group in the layer's place. +#[test] +fn fades_become_alpha_masks() { + let fade = json!({"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 1, "isProgressive": true, + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 0.0, 0.0)}]}}); + let l = styled(rect("Photo", 30.0, 40.0, 100.0, 50.0), "contextSettings", fade); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 200.0, 200.0, vec![l])]); + // The group takes the layer's name too. + let photo = doc.nodes().find(|n| n.name == "Photo" && matches!(n.kind, NodeKind::Rectangle)).unwrap(); + let wrap = doc.node(photo.parent.unwrap()).unwrap(); + assert_eq!(wrap.name, "Photo"); + assert!(matches!(wrap.kind, NodeKind::Group), "{:?}", wrap.kind); + let mask = doc.node(wrap.children[0]).unwrap(); + assert!(mask.is_mask && mask.mask_type == figcraft_doc::MaskType::Alpha); + assert!(matches!(mask.fills[0].kind, PaintKind::Gradient(_))); + assert_eq!(doc.world_bounds(mask.id), doc.world_bounds(photo.id)); + let b = doc.world_bounds(photo.id).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (30.0, 40.0, 100.0, 50.0)); +} + +/// Sketch's linear gradients run square in pixels: on a 200×100 layer, from the top left to the +/// bottom right, the stripes are at right angles to that line in pixels, not in the layer's 0–1. +#[test] +fn linear_gradients_are_square_in_pixels() { + let g = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0), + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0, 0}", "to": "{1, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(1.0, 1.0, 1.0, 1.0)}]}}); + let (doc, _) = open(vec![styled(rect("Wide", 0.0, 0.0, 200.0, 100.0), "fills", json!([g]))]); + let PaintKind::Gradient(g) = &node(&doc, "Wide").fills[0].kind else { panic!("not a gradient") }; + // The line runs (200, 100) in pixels; square to it, (−100, 200), is (−0.5, 2) in 0–1. + let (wx, wy) = ((g.width.0 - g.from.0) * 200.0, (g.width.1 - g.from.1) * 100.0); + assert!((wx * 200.0 + wy * 100.0).abs() < 1e-6, "{g:?}"); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index e651463d..841b4daf 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -267,7 +267,45 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: node.insert("children".into(), Value::Array(kids)); super::stack::apply(l, &mut node); } - Some(Value::Object(node)) + Some(fade(cx, l, node, world, w, h)) +} + +/// Layer `node` with a progressive opacity (Fade: the style's `contextSettings` with +/// `isProgressive` and a gradient whose stops' opacity is the layer's) as a group in its place +/// holding an alpha mask of that gradient over it; FigCraft has no progressive opacity. Other +/// layers as they are. +fn fade(cx: &mut Cx, l: &Value, node: JsonMap, world: Affine, w: f64, h: f64) -> Value { + let ctx = l.get("style").and_then(|s| s.get("contextSettings")); + let gradient = ctx.filter(|c| c.get("isProgressive").and_then(Value::as_bool) == Some(true)).and_then(|c| c.get("gradient")); + let Some(gradient) = gradient else { return Value::Object(node) }; + let fill = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "gradient": gradient}); + let fills = super::style::fills(cx, &json!({"fills": [fill]}), w, h); + if fills.is_empty() { + return Value::Object(node); + } + let id = str_field(l, "do_objectID").unwrap_or("layer"); + let identity = json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]); + let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); + let abs = json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()}); + let mut inner = node.clone(); + let mut group = JsonMap::new(); + for k in ["relativeTransform", "absoluteBoundingBox", "constraints", "visible", "locked"] { + if let Some(v) = node.get(k) { + group.insert(k.into(), v.clone()); + } + } + group.insert("type".into(), json!("GROUP")); + group.insert("id".into(), json!(format!("{id}-fade"))); + group.insert("name".into(), node.get("name").cloned().unwrap_or_else(|| json!("Fade"))); + group.insert("size".into(), json!({"x": w, "y": h})); + group.insert("absoluteBoundingBox".into(), abs.clone()); + inner.insert("relativeTransform".into(), identity.clone()); + inner.insert("absoluteBoundingBox".into(), abs.clone()); + inner.insert("visible".into(), json!(true)); + let mask = json!({"type": "RECTANGLE", "id": format!("{id}-fade-mask"), "name": "Fade", "visible": true, "size": {"x": w, "y": h}, + "relativeTransform": identity, "absoluteBoundingBox": abs, "fills": fills, "isMask": true, "maskType": "ALPHA"}); + group.insert("children".into(), json!([mask, Value::Object(inner)])); + Value::Object(group) } /// A Sketch 2025+ group that is a frame: it behaves as a frame or a graphic, or clips. @@ -524,9 +562,10 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), out } -/// A plain copy of mask `n` when its style shows something (fills, borders or effects). +/// A plain copy of outline mask `n` when its style shows something (fills, borders or effects). +/// An alpha mask only masks. fn drawn_mask(n: &Value) -> Option { - if n.get("isMask").and_then(Value::as_bool) != Some(true) { + if n.get("isMask").and_then(Value::as_bool) != Some(true) || n.get("maskType").and_then(Value::as_str) == Some("ALPHA") { return None; } let shows = diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d1038576..38d94c51 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 1.2% on average (at most 3.2%) | 5–10 (Smart Layout as auto layout, progressive layer opacity, motion and zoom blurs) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From de0650e3f7d0a44b79d40bc89bc074c412b047ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 23:48:19 +0200 Subject: [PATCH 25/87] M6: Sketch import: layout grids come in hidden Sketch draws an artboard's layout and square grids only while its layout view is on; they now import hidden (still there, shown with the eye in the Layout grid section). Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/proto.rs | 9 +++++---- crates/format/src/sketch/proto_tests.rs | 2 ++ docs/file-format-parity.md | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/format/src/sketch/proto.rs b/crates/format/src/sketch/proto.rs index 1a08d268..8574ab34 100644 --- a/crates/format/src/sketch/proto.rs +++ b/crates/format/src/sketch/proto.rs @@ -74,7 +74,8 @@ pub(crate) fn export_settings(cx: &mut Cx, l: &Value, node: &mut JsonMap) { let mut out = Vec::new(); if let Some(g) = l.get("layout").filter(|g| bool_field(g, "isEnabled") != Some(false)) { @@ -83,21 +84,21 @@ pub(crate) fn layout_grids(l: &Value, h: f64, node: &mut JsonMap) && let (Some(count), Some(width)) = (num("numberOfColumns"), num("columnWidth")) { out.push(json!({"pattern": "COLUMNS", "alignment": "MIN", "count": count.min(1000.0), "sectionSize": width.min(1e5), - "gutterSize": num("gutterWidth").unwrap_or(0.0).min(1e5), "offset": num("horizontalOffset").unwrap_or(0.0).min(1e5), "visible": true})); + "gutterSize": num("gutterWidth").unwrap_or(0.0).min(1e5), "offset": num("horizontalOffset").unwrap_or(0.0).min(1e5), "visible": false})); } if bool_field(g, "drawHorizontal") == Some(true) { let gutter = num("gutterHeight").unwrap_or(0.0).min(1e5); let section = (gutter * num("rowHeightMultiplication").unwrap_or(1.0)).min(1e5); if section > 0.0 { let count = (h / (section + gutter)).floor().clamp(1.0, 1000.0); - out.push(json!({"pattern": "ROWS", "alignment": "MIN", "count": count, "sectionSize": section, "gutterSize": gutter, "offset": 0, "visible": true})); + out.push(json!({"pattern": "ROWS", "alignment": "MIN", "count": count, "sectionSize": section, "gutterSize": gutter, "offset": 0, "visible": false})); } } } if let Some(g) = l.get("grid").filter(|g| bool_field(g, "isEnabled") != Some(false)) && let Some(size) = g.get("gridSize").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0) { - out.push(json!({"pattern": "GRID", "sectionSize": size.min(1e5), "visible": true})); + out.push(json!({"pattern": "GRID", "sectionSize": size.min(1e5), "visible": false})); } if !out.is_empty() { node.insert("layoutGrids".into(), Value::Array(out)); diff --git a/crates/format/src/sketch/proto_tests.rs b/crates/format/src/sketch/proto_tests.rs index be9b835d..facef17a 100644 --- a/crates/format/src/sketch/proto_tests.rs +++ b/crates/format/src/sketch/proto_tests.rs @@ -101,6 +101,8 @@ fn layout_and_square_grids() { let grids = format!("{:?}", node(&doc, "Board").grids); assert_eq!(node(&doc, "Board").grids.len(), 2, "{grids}"); assert!(grids.contains("12") && grids.contains("16"), "{grids}"); + // Hidden, as Sketch shows them only when its layout view is on. + assert!(node(&doc, "Board").grids.iter().all(|g| !g.visible), "{grids}"); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 38d94c51..76d7437a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From 5f5b8021270abdbf2652affb1d0965f0c971f1c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 23:49:57 +0200 Subject: [PATCH 26/87] M6: Sketch import: roadmap and parity rows Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- docs/target-app-parity.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3f57ed57..0a7b5042 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,7 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | +| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | | 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged | | 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a1a5bf12..6ab02ade 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-17 · **Change:** minor (rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (`.sketch` import, best effort, numbers unchanged; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .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 @@ -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 (modifiers while dragging, hover states, popovers) not yet compared (a cursor for every tool and modifier, number fields and panel widths built from the documentation, not 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 and (2026-10-17) more fidelity, budgets for large files and an import report (tested on generated fixtures, not real files); layers copied from another design app paste the same way (tested only with clipboard HTML made from our own export), `.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) | +| File formats | 12 | 60% | 5–10 | `.sketch` read (Sketch 48 and newer, best effort; 2026-10-21): live symbols, shared styles, Sketch 2025 frames and stacks, checked on three real files against Sketch's own renders (0.8–2% of pixels differ on average); `.fig` read, best effort, with editable vector networks and (2026-10-17) more fidelity, budgets for large files and an import report (tested on generated fixtures, not real files); layers copied from another design app paste the same way (tested only with clipboard HTML made from our own export), `.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) | @@ -639,6 +639,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| | 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | +| 2026-10-21 | minor | `.sketch` import (best effort, Sketch 48 and newer) in the file formats dimension: pages, layers, styles, live symbols with overrides, shared styles and Color Variables, prototype links, Sketch 2025 frames, stacks as auto layout and tints, text placed as Sketch places it; checked on three real files (Sketch 50, 64 and 2026) against Sketch's own renders. Numbers unchanged: Figma has no `.sketch` reader in its desktop app, so the file-exchange discounts (Figma users' files) don't move | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | From 79894cfad22723b40a7520f619a2e8419d67f20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 23:54:25 +0200 Subject: [PATCH 27/87] M6: Sketch import: junk tints and colour overrides can't panic A tint or fill that isn't a JSON object is left alone instead of being indexed. A structural fuzz replaces each value of a rich file with values of other types and imports every variant. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/frames_tests.rs | 14 +++++ crates/format/src/sketch/style.rs | 6 +- crates/format/src/sketch/symbols.rs | 11 +++- crates/format/src/sketch/tests.rs | 75 ++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/crates/format/src/sketch/frames_tests.rs b/crates/format/src/sketch/frames_tests.rs index aadc4b5f..2ae2b62d 100644 --- a/crates/format/src/sketch/frames_tests.rs +++ b/crates/format/src/sketch/frames_tests.rs @@ -383,3 +383,17 @@ fn cut_corners_become_paths() { assert_eq!(arc.vertices.len(), 8); assert!(arc.segments.iter().any(|g| g.ta != (0.0, 0.0)), "the corners are arcs"); } + +/// Tints and colour overrides that aren't colours, and fills that aren't objects, are junk: left +/// alone, never a panic. +#[test] +fn junk_tints_and_colour_overrides() { + let child = styled(rect("Child", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let mut g = group("G", 0.0, 0.0, 10.0, 10.0, vec![child.clone()]); + g["style"] = json!({"_class": "style", "fills": [{"_class": "fill", "isEnabled": true, "fillType": 0, "layeringType": 1, "color": 5}]}); + let (_, _) = open(vec![g]); + let mut odd = rect("L", 0.0, 0.0, 10.0, 10.0); + odd["style"]["fills"] = json!([true]); + let m = master("M", "S", "Odd", 10.0, 10.0, vec![odd]); + let _ = open_symbols(vec![instance("I", "S", 0.0, 0.0, 10.0, 10.0, &[("L_fillColor", color(1.0, 0.0, 0.0, 1.0))])], vec![m]); +} diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs index 45c739e6..5e476803 100644 --- a/crates/format/src/sketch/style.rs +++ b/crates/format/src/sketch/style.rs @@ -335,7 +335,7 @@ pub(crate) fn tint_of(style: Option<&Value>, legacy: bool) -> Option { None => legacy, } })?; - (f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0).then(|| f.get("color").cloned()).flatten() + (f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0).then(|| f.get("color").filter(|c| c.is_object()).cloned()).flatten() } /// Recolour `layers` (Sketch JSON) with tint `color`: every enabled fill and border takes it, @@ -382,7 +382,9 @@ pub(crate) fn tint_layers(layers: &mut [Value], color: &Value, depth: usize) { fn tinted(old: Option<&Value>, tint: &Value) -> Value { let alpha = |c: Option<&Value>| c.and_then(|c| num(c, "alpha")).unwrap_or(1.0).clamp(0.0, 1.0); let mut c = tint.clone(); - c["alpha"] = json!(alpha(Some(tint)) * alpha(old)); + if let Some(m) = c.as_object_mut() { + m.insert("alpha".into(), json!(alpha(Some(tint)) * alpha(old))); + } c } diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index 06a39f68..f3cd26c4 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -409,10 +409,15 @@ fn apply( } // A colour override on a shape or group: its first fill's colour. if let Some(c) = get("fillColor").filter(|c| c.is_object()) - && let Some(f) = l.get_mut("style").and_then(|s| s.get_mut("fills")).and_then(Value::as_array_mut).and_then(|f| f.first_mut()) + && let Some(f) = l + .get_mut("style") + .and_then(|s| s.get_mut("fills")) + .and_then(Value::as_array_mut) + .and_then(|f| f.first_mut()) + .and_then(Value::as_object_mut) { - f["fillType"] = json!(0); - f["color"] = c.clone(); + f.insert("fillType".into(), json!(0)); + f.insert("color".into(), c.clone()); } } } diff --git a/crates/format/src/sketch/tests.rs b/crates/format/src/sketch/tests.rs index 4fb24400..8ed30231 100644 --- a/crates/format/src/sketch/tests.rs +++ b/crates/format/src/sketch/tests.rs @@ -89,6 +89,81 @@ fn junk_never_panics() { let _ = import(&raw_zip(&document(&["P"]), &json!("x"), &[], &[]), "f"); } +/// A file using most of what the importer reads (Sketch 2025 frames and stacks, tints, symbols +/// with overrides, text, paths, gradients, masks, fades, grids) with each of its values in turn +/// replaced by a value of another type: every import ends without a panic. +#[test] +fn junk_values_never_panic() { + let text_layer = text("T", 0.0, 0.0, 80.0, 20.0, "Go", &[(0, 2, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]); + let dot = styled(rect("Dot", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let mut tint = solid_fill(1.0, 0.0, 0.0, 1.0); + tint["layeringType"] = json!(1); + let mut icon = master("MI", "SI", "Icon", 24.0, 24.0, vec![dot.clone()]); + icon["groupBehavior"] = json!(2); + icon["style"]["fills"] = json!([tint]); + let mut chip = stack(master("MC", "SC", "Chip", 60.0, 24.0, vec![text_layer.clone(), instance("N", "SI", 0.0, 0.0, 24.0, 24.0, &[])]), 0, 1, 1, 6.0, [8.0, 4.0, 10.0, 4.0]); + chip["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [12], "style": 2, "smoothing": 0.6}); + let over = [("T_stringValue", json!("Longer")), ("N_fillColor", color(0.0, 1.0, 0.0, 1.0)), ("T_textColor", color(0.0, 0.0, 1.0, 1.0)), ("N_isVisible", json!("0"))]; + let inst = instance("I", "SC", 0.0, 100.0, 200.0, 24.0, &over); + let grad = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0), + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0, 0}", "to": "{1, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(1.0, 1.0, 1.0, 0.0)}]}}); + let mut faded = styled(rect("Faded", 0.0, 0.0, 30.0, 30.0), "fills", json!([grad.clone()])); + faded["style"]["contextSettings"] = json!({"_class": "graphicsContextSettings", "opacity": 1, "isProgressive": true, "gradient": grad["gradient"].clone()}); + let mask = with(with(styled(rect("Mask", 0.0, 0.0, 30.0, 30.0), "fills", json!([grad])), "hasClippingMask", json!(true)), "clippingMaskMode", json!(1)); + let path = shape_path("P", 0.0, 0.0, 20.0, 20.0, &[(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)], true); + let mut g = group("G", 0.0, 0.0, 100.0, 100.0, vec![mask, faded, path, dot, text_layer]); + g["style"] = json!({"_class": "style", "fills": [tint_fill()]}); + let mut frame = stack(frame_group("F", 0.0, 0.0, 300.0, 300.0, vec![g, inst]), 1, 0, 3, 8.0, [0.0; 4]); + frame["layout"] = json!({"_class": "layoutGrid", "isEnabled": true, "columnWidth": 20, "gutterWidth": 10, "numberOfColumns": 4, "drawVertical": true}); + let pages = [page("P", "Page", vec![frame]), page("S", "Symbols", vec![icon, chip])]; + // Every value's path, with each junk value in turn (a deterministic sample keeps it quick). + fn paths(v: &serde_json::Value, at: &mut Vec, out: &mut Vec>) { + out.push(at.clone()); + match v { + serde_json::Value::Object(m) => { + for (k, c) in m { + at.push(k.clone()); + paths(c, at, out); + at.pop(); + } + } + serde_json::Value::Array(a) => { + for (i, c) in a.iter().enumerate() { + at.push(i.to_string()); + paths(c, at, out); + at.pop(); + } + } + _ => {} + } + } + let junk = [json!(null), json!(5), json!(-1e300), json!("x"), json!(true), json!([]), json!({}), json!([1, "a"])]; + for (pi, p) in pages.iter().enumerate() { + let mut all = Vec::new(); + paths(p, &mut Vec::new(), &mut all); + for (n, at) in all.iter().enumerate().skip(1) { + let mut changed = pages.clone(); + let mut v = &mut changed[pi]; + for k in at { + v = match v { + serde_json::Value::Object(m) => m.get_mut(k).unwrap(), + serde_json::Value::Array(a) => &mut a[k.parse::().unwrap()], + _ => unreachable!(), + }; + } + *v = junk[n % junk.len()].clone(); + let _ = import(&sketch_zip("2026.3.1", 196, document(&["P", "S"]), &changed, &[]), "f"); + } + } +} + +fn tint_fill() -> serde_json::Value { + let mut f = solid_fill(0.0, 0.0, 1.0, 1.0); + f["layeringType"] = json!(1); + f +} + #[test] fn deep_nesting_does_not_fail_the_file() { let mut deep = rect("R", 0.0, 0.0, 1.0, 1.0); From 40c7cb302917d10e61023369a092dd6863a96c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 23:57:20 +0200 Subject: [PATCH 28/87] M6: Sketch import: tint copies and handed overrides count as work Tinting copies a group's or master's layers; the copy is charged to the import's work budget (past it, tints are skipped with a note). An override handed to a nested instance costs its value's size, so deep chains of large overrides can't be copied without end. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/frames_tests.rs | 29 ++++++++++++++++++++++++ crates/format/src/sketch/symbols.rs | 20 +++++++++++----- crates/format/src/sketch/tree.rs | 21 +++++++++++++++-- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/crates/format/src/sketch/frames_tests.rs b/crates/format/src/sketch/frames_tests.rs index 2ae2b62d..429a884b 100644 --- a/crates/format/src/sketch/frames_tests.rs +++ b/crates/format/src/sketch/frames_tests.rs @@ -397,3 +397,32 @@ fn junk_tints_and_colour_overrides() { let m = master("M", "S", "Odd", 10.0, 10.0, vec![odd]); let _ = open_symbols(vec![instance("I", "S", 0.0, 0.0, 10.0, 10.0, &[("L_fillColor", color(1.0, 0.0, 0.0, 1.0))])], vec![m]); } + +/// Tinting copies a group's layers: the copies count against the import's work budget, so nested +/// tinted groups of large layers can't make the import copy without end; past the budget, tints +/// are skipped with a note. +#[test] +fn nested_tints_are_budgeted() { + // Text that doesn't compress, so the page stays within the archive's size checks. + let mut seed = 0x2545_f491_u32; + let big: String = (0..1_000_000) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + char::from(b'a' + (seed % 26) as u8) + }) + .collect(); + let mut l = styled(rect("Leaf", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + for i in 0..35 { + let mut pad = rect(&big, 0.0, 0.0, 1.0, 1.0); + pad["do_objectID"] = json!(format!("pad{i}")); + let mut g = group(&format!("G{i}"), 0.0, 0.0, 10.0, 10.0, vec![l, pad]); + let mut t = solid_fill(1.0, 0.0, 0.0, 1.0); + t["layeringType"] = json!(1); + g["style"] = json!({"_class": "style", "fills": [t]}); + l = g; + } + let (_, warnings) = open(vec![l]); + assert!(warnings.iter().any(|w| w.contains("tint")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index f3cd26c4..dd806282 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -80,7 +80,7 @@ impl Symbols { } /// What copying and mapping `v` costs, in work units: one per JSON value, one per 8 bytes of text. -fn weight(v: &Value, depth: usize) -> u64 { +pub(crate) fn weight(v: &Value, depth: usize) -> u64 { if depth > 256 { return 1; } @@ -141,7 +141,7 @@ pub(crate) fn component(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, ro let sid = str_field(l, "symbolID").unwrap_or("").to_string(); // A graphic's fill tints its layers. let tinted; - let l = match super::style::tint_of(l.get("style"), false) { + let l = match super::style::tint_of(l.get("style"), false).filter(|_| super::tree::spend_on_tint(cx, l)) { Some(t) => { let mut c = l.clone(); if let Some(kids) = c.get_mut("layers").and_then(Value::as_array_mut) { @@ -342,8 +342,8 @@ fn nested(ov: &BTreeMap) -> BTreeMap<&str, Vec<(&str, &Value)>> { /// Apply `ov` to the layers of a master's copy: to the layers it names, and passed on to nested /// instances for the layers inside them (an outer override wins over the nested instance's own). -/// Each override handed to a nested instance costs [`OVERRIDE_COST`] units of `budget`; once it is spent, the -/// rest aren't handed on. +/// Each override handed to a nested instance costs [`OVERRIDE_COST`] units of `budget` plus its +/// value's [`weight`]; once it is spent, the rest aren't handed on. fn apply( layers: &mut [Value], ov: &BTreeMap, @@ -368,8 +368,16 @@ fn apply( _ => {} } let inner = nested.get(id.as_str()).map(Vec::as_slice).unwrap_or_default(); - let take = usize::try_from(*budget / OVERRIDE_COST).unwrap_or(usize::MAX).min(inner.len()); - *budget -= take as u64 * OVERRIDE_COST; + // Each costs its copy's size: deep chains of large overrides use up the budget. + let mut take = 0; + for (_, v) in inner { + let cost = OVERRIDE_COST + weight(v, 0); + if cost > *budget { + break; + } + *budget -= cost; + take += 1; + } if take > 0 { let mut own = l.get("overrideValues").and_then(Value::as_array).cloned().unwrap_or_default(); own.extend(inner.iter().take(take).map(|(rest, v)| json!({"_class": "overrideValue", "overrideName": rest, "value": v}))); diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 841b4daf..cccf3934 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -87,11 +87,27 @@ impl Cx { true } + /// Spend `n` units of the work budget on tinting; false (with a note, once) when it would run + /// out. + pub(crate) fn spend_on_tint(&mut self, n: u64) -> bool { + if self.work.saturating_add(n) > self.max_work { + self.warn("tints past what the import handles were skipped; those layers keep their own colours"); + return false; + } + self.work += n; + true + } + pub(crate) fn work_left(&self) -> u64 { self.max_work.saturating_sub(self.work) } } +/// Charge the copy tinting layer `l` makes against the work budget: whether it may be tinted. +pub(crate) fn spend_on_tint(cx: &mut Cx, l: &Value) -> bool { + cx.spend_on_tint(super::symbols::weight(l, 0)) +} + /// Map the pages of `archive` to a REST file JSON named `name`. pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize, fonts: Option) -> Map { let symbols = super::symbols::Symbols::read(&archive.document, &archive.pages); @@ -247,8 +263,9 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: // A group's or graphic's fill tints its layers (a frame's is its background). let tinted; let gb = l.get("groupBehavior").and_then(Value::as_i64); - let l = match (class, super::style::tint_of(l.get("style"), gb != Some(1))) { - ("group", Some(t)) => { + let tint = if class == "group" { super::style::tint_of(l.get("style"), gb != Some(1)) } else { None }; + let l = match tint.filter(|_| spend_on_tint(cx, l)) { + Some(t) => { // A plain group's legacy fill is all tint; a frame keeps its background fills. if gb != Some(1) { node.remove("fills"); From 3e92904cba325f9a451f96aa54401a0f66d0400a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Thu, 22 Oct 2026 00:03:30 +0200 Subject: [PATCH 29/87] M6: Sketch import: review fixes Texts an auto layout places keep the layout's position (the next layout pass would take the baseline move back). A faded layer's group takes the layer's id, so stacks, links and overrides still find it; faded masks stay masks. figcraft-format no longer depends on figcraft-layout. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 - crates/engine/src/cmd/file.rs | 5 +++- crates/engine/src/sketch_import_tests.rs | 35 ++++++++++++++++++++++++ crates/format/Cargo.toml | 1 - crates/format/src/sketch/frames_tests.rs | 15 ++++++++++ crates/format/src/sketch/tests.rs | 27 ++++++++++++++---- crates/format/src/sketch/tree.rs | 13 +++++---- 7 files changed, 83 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d118fda..32e7be59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1587,7 +1587,6 @@ name = "figcraft-format" version = "0.9.0" dependencies = [ "figcraft-doc", - "figcraft-layout", "figcraft-pathops", "flate2", "image", diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8db15cee..8f7da824 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -720,6 +720,9 @@ fn sketch_line_heights(doc: &mut Document) { }; let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); for id in ids { + // An auto layout places its texts itself (its next pass would take a move back). + let laid_out = + doc.node(id).is_some_and(|n| !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); let Some(n) = doc.node_mut(id) else { continue }; let NodeKind::Text(td) = &mut n.kind else { continue }; let down = set(&mut td.style); @@ -727,7 +730,7 @@ fn sketch_line_heights(doc: &mut Document) { set(&mut r.style); } // Down in the layer's own frame (a rotated text moves along its own axis). - if down.is_finite() && down != 0.0 { + if !laid_out && down.is_finite() && down != 0.0 { n.transform *= figcraft_doc::geom::Affine::translate((0.0, down)); } } diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index 36b200a5..292cd019 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -441,3 +441,38 @@ fn sketch_text_gets_sketchs_natural_line() { let figcraft_doc::LineHeight::Percent(p) = td.style.line_height else { panic!("{:?}", td.style.line_height) }; assert!((p - 116.0).abs() < 1e-9, "{p}"); } + +/// A text an auto layout places isn't moved to Sketch's baseline (the next layout pass would take +/// the move back): laying the stack out again leaves it where the import put it. +#[test] +fn sketch_text_in_a_stack_stays_put() { + if !std::path::Path::new("/System/Library/Fonts/Helvetica.ttc").exists() { + return; + } + figcraft_text::fontdb::scan_system_fonts(); + let font = json!({"name": "Helvetica", "size": 40}); + let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}}); + let label = shape( + "text", + "Label", + [10.0, 10.0, 50.0, 48.0], + json!({"attributedString": {"string": "Hi", "attributes": [run]}, "horizontalSizing": 1, "verticalSizing": 1}), + ); + let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 1, "justifyContent": 0, "alignItems": 0, "allGuttersGap": 0, "stackingOrder": 0}); + let stack = shape( + "group", + "Stack", + [0.0, 0.0, 200.0, 68.0], + json!({"groupBehavior": 1, "groupLayout": layout, "leftPadding": 10, "topPadding": 10, "rightPadding": 10, "bottomPadding": 10, "layers": [label]}), + ); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[stack])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let mut doc = s.doc().unwrap().clone(); + let stack = doc.nodes().find(|n| n.name == "Stack").unwrap().id; + let label = doc.nodes().find(|n| n.name == "Label").unwrap().id; + let before = doc.world_bounds(label).unwrap(); + figcraft_layout::layout_frame(&mut doc, stack, &crate::measure::TextMeasure); + let after = doc.world_bounds(label).unwrap(); + assert!((before.y0 - after.y0).abs() < 1e-6, "{before:?} → {after:?}"); +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 80a943e2..3dca3912 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -11,7 +11,6 @@ workspace = true [dependencies] figcraft-doc = { workspace = true } figcraft-pathops = { workspace = true } -figcraft-layout = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } zip = { workspace = true } diff --git a/crates/format/src/sketch/frames_tests.rs b/crates/format/src/sketch/frames_tests.rs index 429a884b..5a5cefca 100644 --- a/crates/format/src/sketch/frames_tests.rs +++ b/crates/format/src/sketch/frames_tests.rs @@ -426,3 +426,18 @@ fn nested_tints_are_budgeted() { let (_, warnings) = open(vec![l]); assert!(warnings.iter().any(|w| w.contains("tint")), "{warnings:?}"); } + +/// A faded layer in a stack keeps its place in the stack: the group the fade makes takes the +/// layer's id, and with it its sizing. +#[test] +fn faded_stack_items_keep_their_sizing() { + let fade = json!({"_class": "graphicsContextSettings", "opacity": 1, "isProgressive": true, + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 0.0, 0.0)}]}}); + let mut item = styled(rect("Item", 0.0, 0.0, 50.0, 20.0), "contextSettings", fade); + item["horizontalSizing"] = json!(2); + let (doc, _) = open(vec![stack(frame_group("Row", 0.0, 0.0, 200.0, 20.0, vec![item]), 0, 0, 0, 0.0, [0.0; 4])]); + let row = node(&doc, "Row"); + let wrap = doc.node(row.children[0]).unwrap(); + assert_eq!(format!("{:?}", wrap.child_layout.sizing_h), "Fill", "{:?}", wrap.child_layout); +} diff --git a/crates/format/src/sketch/tests.rs b/crates/format/src/sketch/tests.rs index 8ed30231..b4368de1 100644 --- a/crates/format/src/sketch/tests.rs +++ b/crates/format/src/sketch/tests.rs @@ -101,21 +101,36 @@ fn junk_values_never_panic() { let mut icon = master("MI", "SI", "Icon", 24.0, 24.0, vec![dot.clone()]); icon["groupBehavior"] = json!(2); icon["style"]["fills"] = json!([tint]); - let mut chip = stack(master("MC", "SC", "Chip", 60.0, 24.0, vec![text_layer.clone(), instance("N", "SI", 0.0, 0.0, 24.0, 24.0, &[])]), 0, 1, 1, 6.0, [8.0, 4.0, 10.0, 4.0]); + let mut chip = stack( + master("MC", "SC", "Chip", 60.0, 24.0, vec![text_layer.clone(), instance("N", "SI", 0.0, 0.0, 24.0, 24.0, &[])]), + 0, + 1, + 1, + 6.0, + [8.0, 4.0, 10.0, 4.0], + ); chip["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [12], "style": 2, "smoothing": 0.6}); - let over = [("T_stringValue", json!("Longer")), ("N_fillColor", color(0.0, 1.0, 0.0, 1.0)), ("T_textColor", color(0.0, 0.0, 1.0, 1.0)), ("N_isVisible", json!("0"))]; + let over = [ + ("T_stringValue", json!("Longer")), + ("N_fillColor", color(0.0, 1.0, 0.0, 1.0)), + ("T_textColor", color(0.0, 0.0, 1.0, 1.0)), + ("N_isVisible", json!("0")), + ]; let inst = instance("I", "SC", 0.0, 100.0, 200.0, 24.0, &over); let grad = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0), "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0, 0}", "to": "{1, 1}", "stops": [ {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(1.0, 1.0, 1.0, 0.0)}]}}); let mut faded = styled(rect("Faded", 0.0, 0.0, 30.0, 30.0), "fills", json!([grad.clone()])); - faded["style"]["contextSettings"] = json!({"_class": "graphicsContextSettings", "opacity": 1, "isProgressive": true, "gradient": grad["gradient"].clone()}); - let mask = with(with(styled(rect("Mask", 0.0, 0.0, 30.0, 30.0), "fills", json!([grad])), "hasClippingMask", json!(true)), "clippingMaskMode", json!(1)); + faded["style"]["contextSettings"] = + json!({"_class": "graphicsContextSettings", "opacity": 1, "isProgressive": true, "gradient": grad["gradient"].clone()}); + let mask = + with(with(styled(rect("Mask", 0.0, 0.0, 30.0, 30.0), "fills", json!([grad])), "hasClippingMask", json!(true)), "clippingMaskMode", json!(1)); let path = shape_path("P", 0.0, 0.0, 20.0, 20.0, &[(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)], true); let mut g = group("G", 0.0, 0.0, 100.0, 100.0, vec![mask, faded, path, dot, text_layer]); g["style"] = json!({"_class": "style", "fills": [tint_fill()]}); let mut frame = stack(frame_group("F", 0.0, 0.0, 300.0, 300.0, vec![g, inst]), 1, 0, 3, 8.0, [0.0; 4]); - frame["layout"] = json!({"_class": "layoutGrid", "isEnabled": true, "columnWidth": 20, "gutterWidth": 10, "numberOfColumns": 4, "drawVertical": true}); + frame["layout"] = + json!({"_class": "layoutGrid", "isEnabled": true, "columnWidth": 20, "gutterWidth": 10, "numberOfColumns": 4, "drawVertical": true}); let pages = [page("P", "Page", vec![frame]), page("S", "Symbols", vec![icon, chip])]; // Every value's path, with each junk value in turn (a deterministic sample keeps it quick). fn paths(v: &serde_json::Value, at: &mut Vec, out: &mut Vec>) { @@ -149,7 +164,7 @@ fn junk_values_never_panic() { v = match v { serde_json::Value::Object(m) => m.get_mut(k).unwrap(), serde_json::Value::Array(a) => &mut a[k.parse::().unwrap()], - _ => unreachable!(), + _ => panic!("a path only runs through objects and arrays"), }; } *v = junk[n % junk.len()].clone(); diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index cccf3934..8bad4c6c 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -294,7 +294,8 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: fn fade(cx: &mut Cx, l: &Value, node: JsonMap, world: Affine, w: f64, h: f64) -> Value { let ctx = l.get("style").and_then(|s| s.get("contextSettings")); let gradient = ctx.filter(|c| c.get("isProgressive").and_then(Value::as_bool) == Some(true)).and_then(|c| c.get("gradient")); - let Some(gradient) = gradient else { return Value::Object(node) }; + // A mask stays as it is: its group would stop masking the layers above it. + let Some(gradient) = gradient.filter(|_| node.get("isMask").and_then(Value::as_bool) != Some(true)) else { return Value::Object(node) }; let fill = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "gradient": gradient}); let fills = super::style::fills(cx, &json!({"fills": [fill]}), w, h); if fills.is_empty() { @@ -304,18 +305,20 @@ fn fade(cx: &mut Cx, l: &Value, node: JsonMap, world: Affine, w: let identity = json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]); let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); let abs = json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()}); - let mut inner = node.clone(); + // The group takes the layer's id (stacks, links and overrides find it), the layer a new one. + let mut inner = node; let mut group = JsonMap::new(); for k in ["relativeTransform", "absoluteBoundingBox", "constraints", "visible", "locked"] { - if let Some(v) = node.get(k) { + if let Some(v) = inner.get(k) { group.insert(k.into(), v.clone()); } } group.insert("type".into(), json!("GROUP")); - group.insert("id".into(), json!(format!("{id}-fade"))); - group.insert("name".into(), node.get("name").cloned().unwrap_or_else(|| json!("Fade"))); + group.insert("id".into(), json!(id)); + group.insert("name".into(), inner.get("name").cloned().unwrap_or_else(|| json!("Fade"))); group.insert("size".into(), json!({"x": w, "y": h})); group.insert("absoluteBoundingBox".into(), abs.clone()); + inner.insert("id".into(), json!(format!("{id}-faded"))); inner.insert("relativeTransform".into(), identity.clone()); inner.insert("absoluteBoundingBox".into(), abs.clone()); inner.insert("visible".into(), json!(true)); From 054d53900f9bc7a89a5e4c2c0f5ddf6de6ff9a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Thu, 22 Oct 2026 00:21:16 +0200 Subject: [PATCH 30/87] M6: Sketch import: note embedded fonts as waiting for .figcraft font support Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/mod.rs | 3 ++- docs/file-format-parity.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 18332afd..985541b1 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -149,7 +149,8 @@ fn import_parts( /// The note about fonts the file carries (Sketch 7x and newer can embed them, as `fontReferences` /// with `fontData`): FigCraft doesn't use fonts from inside documents, so their text shows in -/// installed fonts. +/// installed fonts. To do once `.figcraft` documents can carry fonts (loading them only for the +/// session would lose them on the first save and reopen). fn embedded_fonts(document: &Value) -> Option { let refs = document.get("fontReferences").and_then(Value::as_array)?; // A reference without font data only names a font the document uses. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 76d7437a..203a015e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From 03c53243051723466b1eb34b8e6a72330a52f5c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Thu, 22 Oct 2026 00:22:34 +0200 Subject: [PATCH 31/87] M6: Sketch import: count it in the parity assessment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Figma imports Sketch files from its file browser (desktop app too); the area table hadn't listed it. Import/export breadth 86% → 87%, file formats 60% → 61%; headline numbers unchanged after rounding. Co-Authored-By: Claude Opus 5.5 --- docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 203a015e..11867d88 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) | +| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6ab02ade..8bc953f0 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-21 · **Change:** minor (`.sketch` import, best effort, numbers unchanged; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (`.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .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 (a component playground for variant, boolean and text properties, a 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% | Real-library QA (variables inside styles, 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, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, 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, then (2026-10-17) grid tracks, slots, labels of connectors saved with their path, per-point joins, text on a path, image adjustments, budgets for large files, an import report and paste from another design app; all on synthetic files, 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 | +| 14 | Import/export | 6 | 87% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed, then (2026-10-17) grid tracks, slots, labels of connectors saved with their path, per-point joins, text on a path, image adjustments, budgets for large files, an import report and paste from another design app; all on synthetic files, 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 (modifiers while dragging, hover states, popovers) not yet compared (a cursor for every tool and modifier, number fields and panel widths built from the documentation, not compared); accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | -| File formats | 12 | 60% | 5–10 | `.sketch` read (Sketch 48 and newer, best effort; 2026-10-21): live symbols, shared styles, Sketch 2025 frames and stacks, checked on three real files against Sketch's own renders (0.8–2% of pixels differ on average); `.fig` read, best effort, with editable vector networks and (2026-10-17) more fidelity, budgets for large files and an import report (tested on generated fixtures, not real files); layers copied from another design app paste the same way (tested only with clipboard HTML made from our own export), `.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) | +| File formats | 12 | 61% | 5–10 | `.sketch` read (Sketch 48 and newer, best effort; 2026-10-21): live symbols, shared styles, Sketch 2025 frames and stacks, checked on three real files against Sketch's own renders (0.8–2% of pixels differ on average); `.fig` read, best effort, with editable vector networks and (2026-10-17) more fidelity, budgets for large files and an import report (tested on generated fixtures, not real files); layers copied from another design app paste the same way (tested only with clipboard HTML made from our own export), `.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) | @@ -639,7 +639,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| | 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | -| 2026-10-21 | minor | `.sketch` import (best effort, Sketch 48 and newer) in the file formats dimension: pages, layers, styles, live symbols with overrides, shared styles and Color Variables, prototype links, Sketch 2025 frames, stacks as auto layout and tints, text placed as Sketch places it; checked on three real files (Sketch 50, 64 and 2026) against Sketch's own renders. Numbers unchanged: Figma has no `.sketch` reader in its desktop app, so the file-exchange discounts (Figma users' files) don't move | +| 2026-10-21 | minor | `.sketch` import (best effort, Sketch 48 and newer), a feature Figma has (its file browser imports Sketch files, desktop app included, per the help center) that the area table hadn't listed: pages, layers, styles, live symbols with overrides, shared styles and Color Variables, prototype links, Sketch 2025 frames, stacks as auto layout and tints, text placed as Sketch places it; checked on three real files (Sketch 50, 64 and 2026) against Sketch's own renders. Import/export breadth 86% → 87% (ready stays 58%: best effort, three files), file formats 60% → 61%; feature breadth (~85%), ready for real work (~53%) and the audience numbers unchanged after rounding | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | From fa53463dddf01cf63654086cf4c6dd8aee47db7d Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 11:01:42 +0800 Subject: [PATCH 32/87] =?UTF-8?q?feat:=20=E4=B8=BA=E7=9F=A2=E9=87=8F?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E6=B7=BB=E5=8A=A0=E7=BA=BF=E6=AE=B5=E4=B8=AD?= =?UTF-8?q?=E7=82=B9=E6=B7=BB=E5=8A=A0=E7=82=B9=E7=9A=84=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E5=92=8C=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/cursor_tests.rs | 3 +- crates/engine/src/tools.rs | 13 ++++ crates/engine/src/vector_behaviour_tests.rs | 4 +- crates/engine/src/vectoredit/pointer.rs | 36 ++++++++++- crates/engine/src/vectoredit/tests.rs | 66 ++++++++++++++++++++- crates/ui-egui/src/canvas.rs | 26 ++++++-- 6 files changed, 139 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 7a9e0621..8d8f245e 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -270,13 +270,14 @@ fn vector_edit_mode_shows_each_tool_and_modifier() { let mut s = Session::new(); let path = id(&ex(&mut s, "vector.create", json!({"points": [[100, 100], [300, 100], [300, 300]], "closed": false}))); ex(&mut s, "vector.edit.begin", json!({"id": path})); - let (point, segment, empty) = ((300.0, 100.0), (200.0, 100.0), (200.0, 250.0)); + let (point, segment, midpoint, empty) = ((300.0, 100.0), (150.0, 100.0), (200.0, 100.0), (200.0, 250.0)); use Cursor::*; // (vector tool, modifiers, where, cursor, why) type Row = (&'static str, Mods, (f64, f64), Cursor, &'static str); let rows: Vec = vec![ ("move", NONE, point, Default, "the arrow over a point"), ("move", NONE, segment, Default, "the arrow over a segment"), + ("move", NONE, midpoint, Pen(PenCursor::Add), "the arrow over a segment's midpoint: adds a point there"), ("move", NONE, empty, Default, "the arrow elsewhere"), ("move", ALT, segment, Pen(PenCursor::Add), "Alt over a segment adds a point there"), ("move", ALT, point, Default, "Alt over a point adds nothing"), diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 187e28d4..e35b054e 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -285,6 +285,9 @@ pub struct Overlay { pub vector_hover: Option, /// The edited layer `vector_hover` is on (several layers can be in edit mode). pub vector_hover_on: Option, + /// Vector edit mode: the point a press on the outline would add (the pen's ghost anchor where + /// it is over a segment, or the midpoint of the segment under the Move tool). + pub vector_add: Option, /// Vector edit mode, Variable width tool: the width point, handle or path under the pointer. pub width_hover: Option, pub lasso: Vec<[f64; 2]>, @@ -309,6 +312,16 @@ pub struct Overlay { pub shape: Option, } +/// A point a press would add to the edited vector's outline: page position, whether it is a +/// segment's midpoint (the Move tool) rather than the pen's place on its segment, and whether the +/// pointer is on it (a press adds it; otherwise it just shows where one would go). +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct VectorAdd { + pub at: [f64; 2], + pub midpoint: bool, + pub on: bool, +} + /// The Scale tool's live readout while dragging a handle. #[derive(Clone, Copy, Debug, PartialEq, Serialize)] pub struct ScaleHint { diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index fb734085..230d2998 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -236,7 +236,7 @@ fn selecting_points_and_segments() { match what { "click tl" => click(&mut s, (0.0, 0.0), m), "click tr" => click(&mut s, (100.0, 0.0), m), - "click top" => click(&mut s, (50.0, 0.0), m), + "click top" => click(&mut s, (30.0, 0.0), m), "marquee top" => drag(&mut s, (-20.0, -20.0), (110.0, 10.0), m), _ => { ex(&mut s, "vector.edit.tool", json!({"tool": "lasso"})); @@ -310,7 +310,7 @@ fn deleting_points_vs_delete_and_heal() { // Deleting a selected segment keeps its points when others use them. let mut s = Session::new(); let id = square(&mut s, true); - click(&mut s, (50.0, 0.0), NONE); + click(&mut s, (30.0, 0.0), NONE); key(&mut s, "Delete", false); let n = net(&s, id); assert_eq!((n.vertices.len(), n.segments.len()), (4, 3), "deleting a segment leaves its points"); diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 6667a209..1684bf26 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -114,6 +114,16 @@ pub(crate) struct PointSnap { /// Eraser brush radius in screen pixels. const ERASER_PX: f64 = 8.0; +/// A segment's midpoint is a point a press adds when the pointer is this close (screen pixels). +const MIDPOINT_PX: f64 = 6.0; + +/// The page point at `t` (0..1) along segment `seg` of vector `id`. +fn segment_point(doc: &Document, id: NodeId, seg: usize, t: f64) -> Option { + use figcraft_geom::kurbo::ParamCurve; + let c = network(doc, id)?.cubic(seg)?; + Some(doc.world_transform(id) * c.eval(t.clamp(0.0, 1.0))) +} + fn erase_all(net: &mut VectorNetwork, stamps: &[Point], radius: f64) -> bool { stamps.iter().fold(false, |acc, c| ops::erase(net, *c, radius) | acc) } @@ -337,9 +347,22 @@ impl Session { let shown = if width_tool { None } else { h.map(|h| (ve.id, h)).or_else(|| hit_any(&doc, ve, ev.p(), tol)) }; // A pen click on empty canvas lands where it snaps. let pen_at = if ve.tool == VTool::Pen && h.is_none() && !ev.shift { Some(self.snap_pen(ev, Some(ve.id), &[])?) } else { None }; + // A point a press on the outline would add: the pen's place under its pointer on a segment + // (its ghost anchor), or the midpoint of the segment under the Move tool. + let add = match (ve.tool, h) { + (VTool::Pen, Some(VHit::Segment { seg, t })) => { + segment_point(&doc, ve.id, seg, t).map(|at| crate::tools::VectorAdd { at: [at.x, at.y], midpoint: false, on: true }) + } + (VTool::Move, Some(VHit::Segment { seg, .. })) if !ev.cmd && !ev.alt => segment_point(&doc, ve.id, seg, 0.5).map(|at| { + let on = (at - ev.p()).hypot() * ev.zoom.max(1e-6) <= MIDPOINT_PX; + crate::tools::VectorAdd { at: [at.x, at.y], midpoint: true, on } + }), + _ => None, + }; let ov = &mut self.tool.overlay; ov.vector_hover = shown.map(|x| x.1); ov.vector_hover_on = shown.map(|x| x.0); + ov.vector_add = add; ov.width_hover = None; ov.face.clear(); ov.rubber = None; @@ -351,6 +374,8 @@ impl Session { (VTool::Move, Some(_)) if ev.cmd => Cursor::Pen(PenCursor::Bend), (VTool::Move, _) if ev.cmd => Cursor::Crosshair, (VTool::Move, Some(VHit::Segment { .. })) if ev.alt => Cursor::Pen(PenCursor::Add), + // On a segment's midpoint the arrow turns into the pen: a press adds a point there. + (VTool::Move, Some(VHit::Segment { .. })) if add.is_some_and(|a| a.on) => Cursor::Pen(PenCursor::Add), (VTool::Move, _) => Cursor::Default, (VTool::Pen, Some(VHit::Vertex { .. })) => Cursor::Pen(PenCursor::Close), (VTool::Pen, Some(VHit::Segment { .. })) => Cursor::Pen(PenCursor::Add), @@ -370,11 +395,13 @@ impl Session { { self.tool.overlay.cursor = crate::tools::handle_cursor(&b.corners(), handle); self.tool.overlay.vector_hover = None; + self.tool.overlay.vector_add = None; } else if shown.is_none_or(|(on, h)| on == ve.id && !matches!(h, VHit::Handle { .. })) { region = Self::region_under(&doc, ve, h, ev); if region.is_some() { self.tool.overlay.vector_hover = None; self.tool.overlay.vector_hover_on = None; + self.tool.overlay.vector_add = None; } } } @@ -510,6 +537,7 @@ impl Session { }; let h = hit(&doc, ve, ev.p(), tol); self.tool.overlay.vector_hover = None; + self.tool.overlay.vector_add = None; self.tool.overlay.face.clear(); self.tool.overlay.guides.clear(); let tool = if ev.cmd && ve.tool == VTool::Move { VTool::Bend } else { ve.tool }; @@ -626,8 +654,14 @@ impl Session { self.vgesture(g); } Some(VHit::Segment { seg, t }) => { - if ev.alt { + // A press on the midpoint the hover showed adds a point exactly there; Alt adds + // one anywhere else on the segment. + let midpoint = !ev.alt + && !ev.shift + && segment_point(&doc, id, seg, 0.5).is_some_and(|m| (m - ev.p()).hypot() * ev.zoom.max(1e-6) <= MIDPOINT_PX); + if ev.alt || midpoint { // Add a point on the segment and drag it. + let t = if midpoint { 0.5 } else { t }; self.begin_interaction("Add point")?; let mut v = None; self.preview(|doc, _| { diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/vectoredit/tests.rs index 0eba28e3..ff3c4cfa 100644 --- a/crates/engine/src/vectoredit/tests.rs +++ b/crates/engine/src/vectoredit/tests.rs @@ -171,7 +171,7 @@ fn lasso_marquee_and_segment_selection() { assert!(s.tool.overlay.lasso.is_empty()); // Move tool: clicking a segment selects it; shift adds another. ex(&mut s, "vector.edit.tool", json!({"tool": "move"})); - click(&mut s, 50.0, 100.0); + click(&mut s, 30.0, 100.0); assert_eq!(ve(&s).segments.len(), 1); assert!(ve(&s).vertices.is_empty()); let mut sh = ev(PointerKind::Down, 0.0, 50.0); @@ -371,3 +371,67 @@ fn eraser_strokes_cut_paths_in_one_step() { assert!(key(&mut s, "E", true, false, false).unwrap()); assert_eq!(ve(&s).tool, VTool::Eraser); } + +#[test] +fn a_segment_shows_its_midpoint_and_a_press_adds_a_point_there() { + let mut s = Session::new(); + let id = rect(&mut s); + // Near the top edge but off its midpoint: the hint shows where the point would go, but the + // pointer is not on it, so the arrow stays. + s.pointer(&ev(PointerKind::Hover, 30.0, 0.0)).unwrap(); + let add = s.tool.overlay.vector_add.expect("the segment's midpoint"); + assert!(add.midpoint && !add.on, "{add:?}"); + assert!((add.at[0] - 50.0).abs() < 1e-9 && add.at[1].abs() < 1e-9, "{add:?}"); + assert_eq!(s.tool.overlay.cursor, crate::tools::Cursor::Default); + // On the midpoint: the pen cursor, and a press adds a point there and selects it. + s.pointer(&ev(PointerKind::Hover, 50.0, 0.0)).unwrap(); + assert!(s.tool.overlay.vector_add.is_some_and(|a| a.on), "the pointer is on the midpoint"); + assert_eq!(s.tool.overlay.cursor, crate::tools::Cursor::Pen(crate::tools::PenCursor::Add)); + click(&mut s, 50.0, 0.0); + let n = net(&s, id); + assert_eq!(n.vertices.len(), 5, "a point was added: {n:?}"); + assert_eq!(ve(&s).vertices, vec![vertex_at(&n, 50.0, 0.0)], "the new point is selected"); +} + +#[test] +fn adding_a_midpoint_keeps_new_midpoints_coming_on_each_half() { + let mut s = Session::new(); + let id = rect(&mut s); + click(&mut s, 50.0, 0.0); + assert_eq!(net(&s, id).vertices.len(), 5); + // The left half has its own midpoint, shown and added the same way. + s.pointer(&ev(PointerKind::Hover, 25.0, 0.0)).unwrap(); + let add = s.tool.overlay.vector_add.expect("the new half's midpoint"); + assert!(add.midpoint && add.on && (add.at[0] - 25.0).abs() < 1e-9, "{add:?}"); + click(&mut s, 25.0, 0.0); + let n = net(&s, id); + assert_eq!(n.vertices.len(), 6, "{n:?}"); + assert_eq!(ve(&s).vertices, vec![vertex_at(&n, 25.0, 0.0)]); + // And on it goes: a quarter of the way along. + s.pointer(&ev(PointerKind::Hover, 12.5, 0.0)).unwrap(); + let add = s.tool.overlay.vector_add.expect("another midpoint"); + assert!((add.at[0] - 12.5).abs() < 1e-9, "{add:?}"); +} + +#[test] +fn a_press_away_from_the_midpoint_selects_the_segment() { + let mut s = Session::new(); + let id = rect(&mut s); + click(&mut s, 30.0, 0.0); + assert_eq!(net(&s, id).vertices.len(), 4, "no point is added off the midpoint"); + assert_eq!(ve(&s).segments.len(), 1, "the segment is selected instead"); +} + +#[test] +fn the_pen_shows_its_ghost_anchor_on_the_segment_under_it() { + let mut s = Session::new(); + let id = rect(&mut s); + ex(&mut s, "vector.edit.tool", json!({"tool": "pen"})); + s.pointer(&ev(PointerKind::Hover, 80.0, 0.0)).unwrap(); + let add = s.tool.overlay.vector_add.expect("the ghost anchor"); + assert!(!add.midpoint && add.on, "{add:?}"); + assert!((add.at[0] - 80.0).abs() < 1e-3 && add.at[1].abs() < 1e-3, "{add:?}"); + click(&mut s, 80.0, 0.0); + assert_eq!(net(&s, id).vertices.len(), 5, "the press added the point"); + assert_eq!(ve(&s).anchor, None, "the click ended the pen's path"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8126e6f3..841c95e8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1021,10 +1021,10 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ let hover_on = |id| ov.vector_hover.filter(|_| ov.vector_hover_on.unwrap_or(ve.id) == id); for part in &ve.others { if let Some(po) = figcraft_engine::vectoredit::overlay(&doc, &ve.view(part)) { - vector_overlay(app, painter, &po, !width_tool, hover_on(part.id), false); + vector_overlay(app, painter, &po, !width_tool, hover_on(part.id), false, None); } } - vector_overlay(app, painter, &o, !width_tool, hover_on(ve.id), true); + vector_overlay(app, painter, &o, !width_tool, hover_on(ve.id), true, ov.vector_add); if width_tool { width_overlay(app, painter, &doc, &ve, zoom); } else if let Some(c) = app.session.vector_edit_box() { @@ -1327,8 +1327,9 @@ fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color3 painter.circle_stroke(e, r, stroke(accent(painter), 1.0)); } -/// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, and -/// for the `active` layer the closed area under the paint bucket and the pen's rubber band. +/// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, the +/// `add` point a press would put on the outline, and for the `active` layer the closed area under +/// the paint bucket and the pen's rubber band. fn vector_overlay( app: &FigApp, painter: &egui::Painter, @@ -1336,6 +1337,7 @@ fn vector_overlay( points: bool, hover: Option, active: bool, + add: Option, ) { use figcraft_engine::vectoredit::VHit; let ov = &app.session.tool.overlay; @@ -1383,6 +1385,22 @@ fn vector_overlay( painter.circle_stroke(p, 7.5, stroke(accent(painter), 1.0)); } } + // The point a press would add to the outline: the pen's ghost anchor where it is over a + // segment, or a segment's midpoint under the Move tool (which grows and fills once the pointer + // is on it, when a press adds it). + if let Some(a) = add { + let p = app.to_screen(a.at[0], a.at[1]); + let col = accent(painter); + if a.midpoint { + let (r, fill) = if a.on { (5.5, col) } else { (3.5, Color32::from_white_alpha(190)) }; + painter.circle_filled(p, r, fill); + painter.circle_stroke(p, r, stroke(if a.on { Color32::WHITE } else { col.gamma_multiply(0.7) }, 1.2)); + } else { + let r = Rect::from_center_size(p, vec2(7.0, 7.0)); + painter.rect_filled(r, CornerRadius::same(5), Color32::from_white_alpha(160)); + painter.rect_stroke(r, CornerRadius::same(5), stroke(col.gamma_multiply(0.8), 1.0), StrokeKind::Inside); + } + } if let Some([x0, y0, x1, y1]) = ov.rubber.filter(|_| active) { painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.0), 4.0, 3.0)); } From 6bb1f3a456e8c22b9d7414631ccdfdd23b8680aa Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 12:03:19 +0800 Subject: [PATCH 33/87] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=9F=A2?= =?UTF-8?q?=E9=87=8F=E7=BC=96=E8=BE=91=E6=A8=A1=E5=BC=8F=E4=B8=8B=E7=9A=84?= =?UTF-8?q?=E5=89=AA=E5=88=87=E5=91=BD=E4=BB=A4=E9=80=BB=E8=BE=91=E5=B9=B6?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在矢量编辑模式中,剪切命令会处理选中的顶点并自动修复路径,而非直接删除整个图层。新增了专门的cut函数处理该逻辑,同时添加了vector_keys_tests测试文件,覆盖了不同场景下的剪切行为,包括Windows系统的Shift+Delete映射、选中顶点时的剪切、无选中项时的剪切以及非编辑模式下的剪切。 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/shortcuts.rs | 17 +++++- crates/ui-egui/src/vector_keys_tests.rs | 70 +++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/vector_keys_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e24bbf24..846bfa92 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -219,6 +219,8 @@ mod variables_rename_tests; #[cfg(test)] mod variants_discovery_tests; #[cfg(test)] +mod vector_keys_tests; +#[cfg(test)] mod vector_panel_tests; #[cfg(test)] mod vector_tail_ui_tests; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index c86cfdf1..30267cc7 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -125,7 +125,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowe for ev in &events { match ev { Event::Copy => crate::menus::invoke(app, "edit.copy"), - Event::Cut => crate::menus::invoke(app, "edit.cut"), + Event::Cut => cut(app), Event::Paste(text) if !swallowed.drop_text => crate::menus::paste(app, Some(text), None), Event::Key { key, physical_key, pressed: true, modifiers, repeat } if key_event(app, *key, *physical_key, *modifiers, *repeat, now, ctx) => @@ -137,6 +137,21 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowe } } +/// Cut: usually the layers. Windows sends Shift+Delete (remove the points and heal the path) +/// as a cut command and never as a key, so in vector edit mode the selection is points: they are +/// removed, a heal joining the path through them. +fn cut(app: &mut FigApp) { + let Some(ve) = app.session.ds().ok().and_then(|d| d.vector_edit.clone()) else { + crate::menus::invoke(app, "edit.cut"); + return; + }; + if !ve.vertices.is_empty() { + app.run("vector.deleteHeal", json!({})); + } else if !ve.segments.is_empty() { + app.run("vector.edit.delete", json!({})); + } +} + /// The command Cmd/Ctrl+V runs with `m` held too: `edit.paste`, `edit.pasteOver` (Shift), /// `edit.pasteProperties` (Alt), or `None` for a chord nothing is bound to. The keymap decides, /// whether the key press arrived (browser) or only a paste event and the release (desktop). diff --git a/crates/ui-egui/src/vector_keys_tests.rs b/crates/ui-egui/src/vector_keys_tests.rs new file mode 100644 index 00000000..eb99742a --- /dev/null +++ b/crates/ui-egui/src/vector_keys_tests.rs @@ -0,0 +1,70 @@ +//! Keys in vector edit mode that the window's key handling twists: Windows sends Shift+Delete +//! (delete the points and heal the path) as a cut command and no key at all, so the canvas must +//! heal the selected points rather than cut the layer. + +use egui::{Event, Modifiers}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn net(u: &Ui, path: NodeId) -> figcraft_doc::VectorNetwork { + let doc = u.app.session.doc().unwrap(); + figcraft_engine::vectoredit::network(doc, path).unwrap().clone() +} + +/// A three-point open path A-B-C in edit mode, B selected. +fn abc(u: &mut Ui) -> NodeId { + let path = id(u.app.session.execute("vector.create", &json!({"points": [[100, 100], [200, 100], [300, 100]], "closed": false})).unwrap()); + u.app.session.execute("vector.edit.begin", &json!({"id": path})).unwrap(); + u.app.session.execute("vector.edit.select", &json!({"vertices": [1]})).unwrap(); + path +} + +#[test] +fn shift_delete_in_vector_edit_mode_heals_the_point_instead_of_cutting_the_layer() { + let mut u = Ui::new(Session::new()); + let path = abc(&mut u); + // Windows: egui-winit turns Shift+Delete into a cut command, with no key event. + u.frame_with(vec![Event::Cut], Modifiers::SHIFT); + assert!(u.app.session.doc().unwrap().node(path).is_some(), "the layer stays"); + let n = net(&u, path); + assert_eq!(n.vertices.len(), 2, "the point is gone: {n:?}"); + assert_eq!(n.segments.len(), 1, "A and C are joined"); + let (a, b) = (n.vertices.first().map(|v| v.x).unwrap_or_default(), n.vertices.get(1).map(|v| v.x).unwrap_or_default()); + assert!(b - a == 200.0 && n.vertices.iter().all(|v| v.y == 0.0), "it runs straight from A to C: {n:?}"); +} + +#[test] +fn a_cut_command_in_edit_mode_takes_the_selected_points_not_the_layer() { + let mut u = Ui::new(Session::new()); + let path = abc(&mut u); + u.frame_with(vec![Event::Cut], Modifiers::COMMAND); + assert!(u.app.session.doc().unwrap().node(path).is_some(), "Ctrl+X on points keeps the layer"); + assert_eq!(net(&u, path).vertices.len(), 2, "it takes the selected point"); +} + +#[test] +fn a_cut_command_in_edit_mode_with_nothing_selected_keeps_the_layer() { + let mut u = Ui::new(Session::new()); + let path = abc(&mut u); + u.app.session.execute("vector.edit.select", &json!({"vertices": []})).unwrap(); + u.frame_with(vec![Event::Cut], Modifiers::SHIFT); + assert!(u.app.session.doc().unwrap().node(path).is_some(), "nothing selected: nothing goes"); + assert_eq!(net(&u, path).vertices.len(), 3); +} + +#[test] +fn a_cut_command_outside_edit_mode_cuts_the_layer() { + let mut s = Session::new(); + let path = id(s.execute("vector.create", &json!({"points": [[100, 100], [200, 100], [300, 100]], "closed": false})).unwrap()); + s.execute("select.set", &json!({"ids": [path]})).unwrap(); + let mut u = Ui::new(s); + u.frame_with(vec![Event::Cut], Modifiers::SHIFT); + assert!(u.app.session.doc().unwrap().node(path).is_none(), "outside edit mode the selected layer is cut"); +} From e3bf9b7270336831f0193e260a8bb0fcabdcfbd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=E1=BB=91ng=20Huy=20S=C6=A1n?= Date: Thu, 22 Oct 2026 11:33:34 +0700 Subject: [PATCH 34/87] i18n: add Vietnamese (vi) interface localization --- README.md | 2 +- ROADMAP.md | 4 +- crates/engine/src/cmd/view.rs | 2 +- crates/ui-egui/locales/vi.toml | 2367 ++++++++++++++++++++++++++++++ crates/ui-egui/src/cjk.rs | 2 +- crates/ui-egui/src/i18n.rs | 4 +- crates/ui-egui/src/i18n_tests.rs | 8 +- docs/localization-parity.md | 12 +- docs/mcp.md | 2 +- docs/translating.md | 14 +- 10 files changed, 2395 insertions(+), 22 deletions(-) create mode 100644 crates/ui-egui/locales/vi.toml diff --git a/README.md b/README.md index 63bc739b..41bca26d 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust. - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. -- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). +- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian, Georgian and Vietnamese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). diff --git a/ROADMAP.md b/ROADMAP.md index f43cd015..b1ec9215 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,11 +109,11 @@ native speakers' reviews of the six language drafts need people. | Japanese | ja | 99% | draft (needs native review) | | German | de | 96% | draft (needs native review) | | Korean | ko | 95% | draft (needs native review) | -| Vietnamese | vi | 0% | none | +| Vietnamese | vi | 100% | draft (needs native review) | Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸ +French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 0592046e..b4ce92b9 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -427,7 +427,7 @@ pub fn specs() -> Vec { spec!( "pref.language", "Interface language", - "{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…; else English}", + "{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|vi|zh-Hans|…; else English}", false, |s, p| { let lang = req_string("pref.language", p, "language")?; diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml new file mode 100644 index 00000000..6493508b --- /dev/null +++ b/crates/ui-egui/locales/vi.toml @@ -0,0 +1,2367 @@ +# FigCraft interface strings: Vietnamese. +# +# Draft — needs native review: machine-drafted from the English catalog. Brand names, keyboard +# glyphs and {placeholders} stay as they are. Vietnamese nouns do not inflect for number, so plural +# entries give only `other`. See docs/translating.md. + +[meta] +name = "Tiếng Việt" +status = "draft — needs native review" + +[ui] +"% of all commits" = "% tổng số commit" +"(no longer published)" = "(không còn được xuất bản)" +"({n} warnings: some entries were skipped)" = { other = "({n} cảnh báo: một số mục đã bị bỏ qua)" } +"+ Create variable" = "+ Tạo biến" +".fig (experimental)" = ".fig (thử nghiệm)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} dòng (Δ {delta}), +{bin_added} / −{bin_deleted} tài nguyên nhị phân, {first} – {last}" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Đã tồn tại tệp .figcraft có tên này. Hãy chọn tên đầy đủ của tệp để xác nhận thay thế." +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Một bộ sưu tập của tệp này với các chế độ và biến sau: ghi đè giá trị theo từng chế độ và thêm các biến riêng" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Một lớp riêng lẻ sẽ căn chỉnh theo khung của nó: hãy chọn thêm lớp, hoặc chọn một lớp bên trong khung" +"A responsive product page: hero, features, figures, a quote and a footer" = "Trang sản phẩm đáp ứng: phần mở đầu, tính năng, số liệu, trích dẫn và chân trang" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Chuyển động lò xo chạy cho đến khi ổn định: khối lượng, độ cứng và độ giảm chấn quyết định thời lượng" +"A title, an agenda, a big figure, a chart and a closing slide" = "Tiêu đề, chương trình, số liệu lớn, biểu đồ và trang kết thúc" +"A to Z" = "A đến Z" +"About" = "Giới thiệu" +"About FigCraft" = "Giới thiệu về FigCraft" +"About this server" = "Giới thiệu về máy chủ này" +"Action" = "Hành động" +"Actions" = "Hành động" +"Actions for {name}" = "Hành động cho {name}" +"Actions…" = "Hành động…" +"Active" = "Đang hoạt động" +"Actual size (100%)" = "Kích thước thực (100%)" +"Add a category" = "Thêm danh mục" +"Add a comment" = "Thêm bình luận" +"Add a description" = "Thêm mô tả" +"Add a frame (F) or a shape (R) to get started" = "Thêm khung (F) hoặc hình dạng (R) để bắt đầu" +"Add a layer or component to tile first" = "Hãy thêm một lớp hoặc thành phần để lặp ô trước" +"Add a note for developers" = "Thêm ghi chú cho nhà phát triển" +"Add action" = "Thêm hành động" +"Add actions for when no condition holds" = "Thêm hành động khi không có điều kiện nào thỏa mãn" +"Add an annotation for developers" = "Thêm chú thích cho nhà phát triển" +"Add another condition" = "Thêm điều kiện khác" +"Add auto layout" = "Thêm bố cục tự động" +"Add background behind overlay" = "Thêm nền phía sau lớp phủ" +"Add content to this slot" = "Thêm nội dung vào ô chứa này" +"Add dev resource" = "Thêm tài nguyên dev" +"Add effect" = "Thêm hiệu ứng" +"Add export setting" = "Thêm thiết lập xuất" +"Add fill" = "Thêm màu tô" +"Add interaction" = "Thêm tương tác" +"Add layout grid" = "Thêm lưới bố cục" +"Add library" = "Thêm thư viện" +"Add library file…" = "Thêm tệp thư viện…" +"Add max height" = "Thêm chiều cao tối đa" +"Add max width" = "Thêm chiều rộng tối đa" +"Add min height" = "Thêm chiều cao tối thiểu" +"Add min width" = "Thêm chiều rộng tối thiểu" +"Add mode" = "Thêm chế độ" +"Add new page" = "Thêm trang mới" +"Add or update collections from a variables JSON" = "Thêm hoặc cập nhật bộ sưu tập từ JSON biến" +"Add properties to make instances configurable" = "Thêm thuộc tính để có thể cấu hình các bản thể" +"Add reaction" = "Thêm biểu cảm" +"Add starting point" = "Thêm điểm bắt đầu" +"Add stroke" = "Thêm nét" +"Add variant" = "Thêm biến thể" +"Add variant with a new value" = "Thêm biến thể với giá trị mới" +"Added" = "Đã thêm" +"Added the library \"{name}\"" = "Đã thêm thư viện \"{name}\"" +"Adding to {name}" = "Đang thêm vào {name}" +"Adjust" = "Điều chỉnh" +"Adjustment presets" = "Thiết lập sẵn điều chỉnh" +"Advanced" = "Nâng cao" +"Advanced stroke" = "Nét nâng cao" +"After delay" = "Sau khoảng trễ" +"After delay {ms}ms" = "Sau khoảng trễ {ms}ms" +"Align" = "Căn chỉnh" +"Align bottom" = "Căn dưới" +"Align center" = "Căn giữa" +"Align horizontal centers" = "Căn tâm ngang" +"Align left" = "Căn trái" +"Align middle" = "Căn giữa dọc" +"Align right" = "Căn phải" +"Align text baselines" = "Căn theo đường cơ sở văn bản" +"Align top" = "Căn trên" +"Align vertical centers" = "Căn tâm dọc" +"Alignment" = "Căn chỉnh" +"All" = "Tất cả" +"All categories" = "Tất cả danh mục" +"All comments" = "Tất cả bình luận" +"All components" = "Tất cả thành phần" +"All fonts" = "Tất cả phông chữ" +"All supported properties" = "Tất cả thuộc tính được hỗ trợ" +"All the fonts this file uses are installed" = "Tất cả phông chữ tệp này sử dụng đều đã được cài đặt" +"All variables" = "Tất cả biến" +"Alpha" = "Alpha" +"Alphabetical" = "Theo bảng chữ cái" +"Alt+drag" = "Alt+kéo" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Màn hình phân tích trên lưới: số liệu, biểu đồ vẽ bằng vector và bảng dữ liệu" +"An invite link that opens FigCraft and joins" = "Liên kết mời giúp mở FigCraft và tham gia ngay" +"Anchor" = "Điểm neo" +"Android" = "Android" +"Angular" = "Góc" +"Animation" = "Hoạt ảnh" +"Annotations" = "Chú thích" +"Annotations shown on the canvas" = "Chú thích hiển thị trên vùng vẽ" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Một máy chủ khác, hoặc máy chủ cần khóa truy cập. Máy chủ thường dùng được đặt trong Tùy chọn." +"Any status" = "Mọi trạng thái" +"Anyone on your network with this code can edit the file, while this app stays open." = "Bất kỳ ai trong mạng của bạn có mã này đều có thể chỉnh sửa tệp trong khi ứng dụng này đang mở." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Bất kỳ ai có liên kết chỉnh sửa đều có thể thay đổi tệp; liên kết xem chỉ cho phép xem. Các liên kết chứa mã bí mật: hãy chia sẻ cẩn thận như mật khẩu. `figcraft-server links` dùng để tạo thêm và thu hồi liên kết." +"Anyone with this code can edit the file." = "Bất kỳ ai có mã này đều có thể chỉnh sửa tệp." +"Anyone with this code can look at the file, not change it." = "Bất kỳ ai có mã này đều có thể xem tệp nhưng không thể chỉnh sửa." +"Appearance" = "Giao diện" +"Apply a component property" = "Áp dụng thuộc tính thành phần" +"Apply variable" = "Áp dụng biến" +"Apply variable to text" = "Áp dụng biến cho văn bản" +"Apply variable to visibility" = "Áp dụng biến cho hiển thị" +"Apps" = "Ứng dụng" +"Apr" = "Thg 4" +"Arc" = "Cung" +"Arc ratio" = "Tỷ lệ cung" +"Arc start" = "Điểm đầu cung" +"Arc sweep" = "Góc quét cung" +"Archive" = "Lưu trữ" +"Archived" = "Đã lưu trữ" +"Arrange" = "Sắp xếp" +"Arrow" = "Mũi tên" +"As typed" = "Như đã nhập" +"Asking the server…" = "Đang hỏi máy chủ…" +"Assets" = "Tài nguyên" +"Aug" = "Thg 8" +"Auto" = "Tự động" +"Auto (space around)" = "Tự động (cách đều xung quanh)" +"Auto (space between)" = "Tự động (cách đều giữa các mục)" +"Auto (space evenly)" = "Tự động (cách đều bằng nhau)" +"Auto ({mode})" = "Tự động ({mode})" +"Auto columns" = "Cột tự động" +"Auto gap" = "Khoảng hở tự động" +"Auto height" = "Chiều cao tự động" +"Auto layout" = "Bố cục tự động" +"Auto rows" = "Hàng tự động" +"Auto width" = "Chiều rộng tự động" +"Back" = "Quay lại" +"Back to Ready for dev" = "Quay lại Sẵn sàng cho dev" +"Back to Ready for dev: the next change compares with how it is now" = "Quay lại Sẵn sàng cho dev: thay đổi tiếp theo sẽ so sánh với trạng thái hiện tại" +"Back to assets" = "Quay lại tài nguyên" +"Back to current" = "Quay lại hiện tại" +"Back to styles" = "Quay lại các kiểu" +"Back to the FigCraft server" = "Quay lại máy chủ FigCraft" +"Back to the canvas from a focused region" = "Quay lại vùng vẽ từ vùng đang giữ tiêu điểm" +"Back to the current version" = "Quay lại phiên bản hiện tại" +"Back to the font's value" = "Quay lại giá trị của phông chữ" +"Back to the values the font style implies" = "Quay lại các giá trị mặc định của kiểu phông chữ" +"Background" = "Nền" +"Background blur" = "Làm mờ nền" +"Basics" = "Cơ bản" +"Before" = "Trước" +"Bend" = "Uốn cong" +"Bevel" = "Vát cạnh" +"Binary assets added" = "Tài nguyên nhị phân đã thêm" +"Binary assets removed" = "Tài nguyên nhị phân đã xóa" +"Black & white" = "Đen trắng" +"Bleed" = "Vùng tràn lề" +"Bleed marks" = "Dấu tràn lề" +"Bleed marks need a bleed" = "Dấu tràn lề cần có vùng tràn lề" +"Blend" = "Hòa trộn" +"Blend mode" = "Chế độ hòa trộn" +"Blue" = "Xanh lam" +"Blur" = "Làm mờ" +"Boolean" = "Boolean" +"Boolean groups" = "Nhóm boolean" +"Both directions" = "Cả hai hướng" +"Bottom" = "Dưới" +"Bottom left radius" = "Bán kính góc dưới trái" +"Bottom padding" = "Đệm dưới" +"Bottom right radius" = "Bán kính góc dưới phải" +"Bound to {name}. Click to detach the variable." = "Đã gắn với {name}. Nhấp để tách biến." +"Branch" = "Nhánh" +"Branch archived" = "Đã lưu trữ nhánh" +"Branch merged" = "Đã hợp nhất nhánh" +"Branch name" = "Tên nhánh" +"Branch of {file}" = "Nhánh của {file}" +"Branch of {file} · merged or archived" = "Nhánh của {file} · đã hợp nhất hoặc lưu trữ" +"Branch updated from main" = "Đã cập nhật nhánh từ nhánh chính" +"Branch {name}" = "Nhánh {name}" +"Branches" = "Các nhánh" +"Bring forward" = "Đưa tiến một lớp" +"Bring the main file's changes into the branch" = "Đưa các thay đổi của tệp chính vào nhánh" +"Bring to front" = "Đưa lên trên cùng" +"Bristle" = "Lông cọ" +"Browse" = "Duyệt" +"Brush" = "Cọ vẽ" +"Brush rotation jitter" = "Độ ngẫu nhiên góc xoay cọ" +"Brush scatter" = "Độ phân tán cọ" +"Brush size jitter" = "Độ ngẫu nhiên cỡ cọ" +"Brush spacing" = "Khoảng cách nét cọ" +"Bulleted list" = "Danh sách dấu đầu dòng" +"C or Shift+C" = "C hoặc Shift+C" +"C or ⇧C" = "C hoặc ⇧C" +"CMYK colour" = "Màu CMYK" +"CSS custom properties…" = "Thuộc tính tùy chỉnh CSS…" +"Can edit" = "Có thể chỉnh sửa" +"Can view" = "Có thể xem" +"Can't read this library: {e}" = "Không thể đọc thư viện này: {e}" +"Cancel" = "Hủy" +"Canvas" = "Vùng vẽ" +"Canvas stacking" = "Thứ tự xếp chồng trên vùng vẽ" +"Cap height to baseline" = "Chiều cao chữ hoa đến đường cơ sở" +"Case" = "Chữ hoa/thường" +"Case-sensitive forms" = "Dạng ký tự theo chữ hoa/thường" +"Categories" = "Danh mục" +"Center" = "Giữa" +"Change alias" = "Đổi bí danh" +"Change resolved" = "Đã giải quyết thay đổi" +"Change style" = "Đổi kiểu" +"Change to" = "Đổi thành" +"Change to needs a layer inside a variant" = "Đổi thành cần một lớp nằm trong biến thể" +"Changed" = "Đã thay đổi" +"Changed on both sides: keep one" = "Đã thay đổi ở cả hai phía: hãy giữ một bên" +"Changes on main" = "Thay đổi trên nhánh chính" +"Changes on the branch" = "Thay đổi trên nhánh" +"Changes unknown" = "Không rõ thay đổi" +"Checking the fonts installed on this computer…" = "Đang kiểm tra các phông chữ cài đặt trên máy tính này…" +"Checking the server…" = "Đang kiểm tra máy chủ…" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Hãy chọn React, Leptos, SwiftUI hoặc Compose ở trên để kết nối thành phần này với mã nguồn." +"Choose another profile to change it." = "Hãy chọn hồ sơ khác để thay đổi." +"Choose collection" = "Chọn bộ sưu tập" +"Choose image" = "Chọn hình ảnh" +"Choose mode" = "Chọn chế độ" +"Choose variable" = "Chọn biến" +"Circle" = "Hình tròn" +"Clear from this list" = "Xóa khỏi danh sách này" +"Clear recent" = "Xóa danh sách gần đây" +"Click + to replace mixed content" = "Nhấp + để thay thế nội dung hỗn hợp" +"Click anywhere on the canvas to leave one." = "Nhấp vào bất kỳ đâu trên vùng vẽ để lại bình luận." +"Click or drag to merge · {alt} removes" = "Nhấp hoặc kéo để hợp nhất · {alt} để xóa" +"Click or drag to remove · {alt} merges" = "Nhấp hoặc kéo để xóa · {alt} để hợp nhất" +"Click to record a key" = "Nhấp để ghi nhận phím" +"Clip content" = "Cắt nội dung tràn khung" +"Clip to shape" = "Cắt theo hình dạng" +"Close" = "Đóng" +"Close overlay" = "Đóng lớp phủ" +"Close preview" = "Đóng xem trước" +"Close tab" = "Đóng thẻ" +"Close version history" = "Đóng lịch sử phiên bản" +"Close when clicking outside" = "Đóng khi nhấp ra ngoài" +"Close without saving" = "Đóng không lưu" +"Code" = "Mã" +"Code or invite link" = "Mã hoặc liên kết mời" +"Code syntax" = "Cú pháp mã" +"Collapse" = "Thu gọn" +"Collapse panel" = "Thu gọn bảng" +"Collection" = "Bộ sưu tập" +"Collections" = "Bộ sưu tập" +"Color" = "Màu" +"Color burn" = "Đốt màu" +"Color dodge" = "Làm sáng màu" +"Color format" = "Định dạng màu" +"Color profile" = "Hồ sơ màu" +"Color profile: {name}" = "Hồ sơ màu: {name}" +"Color styles" = "Kiểu màu" +"Color values in this file are in this color space." = "Các giá trị màu trong tệp này thuộc không gian màu này." +"Color variables" = "Biến màu" +"Colour" = "Màu" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Kiểu màu và văn bản, biến Sáng và Tối, nút bấm, ô nhập liệu và thẻ" +"Column" = "Cột" +"Column gap" = "Khoảng hở cột" +"Column sizes" = "Kích thước cột" +"Column span" = "Số cột gộp" +"Columns" = "Các cột" +"Columns {count}" = "Cột {count}" +"Comment" = "Bình luận" +"Comments" = "Bình luận" +"Commits" = "Commit" +"Company" = "Công ty" +"Compare changes" = "So sánh thay đổi" +"Compatibility notes" = "Ghi chú tương thích" +"Completed" = "Đã hoàn thành" +"Component" = "Thành phần" +"Component name" = "Tên thành phần" +"Component property" = "Thuộc tính thành phần" +"Component set" = "Bộ thành phần" +"Component sets" = "Các bộ thành phần" +"Components" = "Các thành phần" +"Conditional" = "Điều kiện" +"Confetti" = "Hoa giấy" +"Conflicts" = "Xung đột" +"Connect to code" = "Kết nối với mã" +"Connected" = "Đã kết nối" +"Connected to code: {from}" = "Đã kết nối với mã: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Mất kết nối, đang kết nối lại (lần thử {attempt})…" +"Constrain proportions" = "Giữ tỷ lệ" +"Constraints" = "Ràng buộc" +"Content" = "Nội dung" +"Contents hidden" = "Đã ẩn nội dung" +"Contents only" = "Chỉ nội dung" +"Contextual alternates" = "Ký tự thay thế theo ngữ cảnh" +"Contrast" = "Độ tương phản" +"Contributors" = "Người đóng góp" +"Contributors: {people} · Commits: {commits}" = "Người đóng góp: {people} · Commit: {commits}" +"Controller" = "Tay cầm điều khiển" +"Convert to frame" = "Chuyển thành khung" +"Convert to section" = "Chuyển thành phần" +"Cool" = "Lạnh" +"Copied as CSS" = "Đã sao chép dưới dạng CSS" +"Copied as PNG" = "Đã sao chép dưới dạng PNG" +"Copied as SVG" = "Đã sao chép dưới dạng SVG" +"Copied as text" = "Đã sao chép dưới dạng văn bản" +"Copied {value}" = "Đã sao chép {value}" +"Copy" = "Sao chép" +"Copy as" = "Sao chép dưới dạng" +"Copy as CSS" = "Sao chép dưới dạng CSS" +"Copy as PNG" = "Sao chép dưới dạng PNG" +"Copy as SVG" = "Sao chép dưới dạng SVG" +"Copy code" = "Sao chép mã" +"Copy link" = "Sao chép liên kết" +"Copy the {label}" = "Sao chép {label}" +"Copy {value}" = "Sao chép {value}" +"Corner radius" = "Bán kính góc" +"Corner smoothing" = "Làm mượt góc" +"Couldn't download the variables" = "Không thể tải xuống các biến" +"Couldn't reach {server}: showing what this computer remembers." = "Không thể kết nối tới {server}: đang hiển thị dữ liệu máy tính này ghi nhớ." +"Couldn't read {name}." = "Không thể đọc {name}." +"Couldn't save automatically: {error}" = "Không thể tự động lưu: {error}" +"Count" = "Số lượng" +"Create" = "Tạo" +"Create a variable collection first" = "Hãy tạo bộ sưu tập biến trước" +"Create a variable first" = "Hãy tạo biến trước" +"Create alias" = "Tạo bí danh" +"Create branch" = "Tạo nhánh" +"Create collection" = "Tạo bộ sưu tập" +"Create component" = "Tạo thành phần" +"Create component property" = "Tạo thuộc tính thành phần" +"Create components, styles or variables to publish them as a library." = "Hãy tạo các thành phần, kiểu hoặc biến để xuất bản thành thư viện." +"Create property…" = "Tạo thuộc tính…" +"Create style" = "Tạo kiểu" +"Create style from the selected text" = "Tạo kiểu từ văn bản đã chọn" +"Created" = "Đã tạo" +"Crop" = "Cắt xén" +"Crop marks" = "Dấu xén" +"Ctrl+arrows" = "Ctrl+mũi tên" +"Ctrl+click" = "Ctrl+nhấp" +"Ctrl+scroll" = "Ctrl+cuộn" +"Current name" = "Tên hiện tại" +"Current version" = "Phiên bản hiện tại" +"Cursor chat" = "Trò chuyện con trỏ" +"Custom" = "Tùy chỉnh" +"Custom size" = "Kích thước tùy chỉnh" +"Cut" = "Cắt" +"Dark" = "Tối" +"Darken" = "Làm tối" +"Dash" = "Nét đứt" +"Dashboard" = "Bảng điều khiển" +"Date created" = "Ngày tạo" +"Dec" = "Thg 12" +"Decoration" = "Trang trí chữ" +"Decoration style" = "Kiểu trang trí chữ" +"Deep select" = "Chọn sâu" +"Default" = "Mặc định" +"Default content" = "Nội dung mặc định" +"Default value (the top-left variant)" = "Giá trị mặc định (biến thể ở góc trên trái)" +"Delay" = "Độ trễ" +"Delete" = "Xóa" +"Delete category (its annotations stay)" = "Xóa danh mục (các chú thích vẫn được giữ lại)" +"Delete collection" = "Xóa bộ sưu tập" +"Delete group" = "Xóa nhóm" +"Delete mode" = "Xóa chế độ" +"Delete page" = "Xóa trang" +"Delete preset" = "Xóa thiết lập sẵn" +"Delete property" = "Xóa thuộc tính" +"Delete the style (layers keep their values)" = "Xóa kiểu (các lớp vẫn giữ nguyên giá trị)" +"Delete thread" = "Xóa luồng bình luận" +"Delete variable" = "Xóa biến" +"Delete “{name}”? This can't be undone." = "Xóa “{name}”? Thao tác này không thể hoàn tác." +"Density" = "Mật độ" +"Depth" = "Độ sâu" +"Describe how and when to use this component" = "Mô tả cách thức và thời điểm sử dụng thành phần này" +"Describe what changed" = "Mô tả những gì đã thay đổi" +"Description" = "Mô tả" +"Description (optional)" = "Mô tả (tùy chọn)" +"Design" = "Thiết kế" +"Design system starter" = "Bộ khởi đầu hệ thống thiết kế" +"Desktop" = "Máy tính" +"Destination" = "Đích" +"Detach alias" = "Tách bí danh" +"Detach instance" = "Tách bản thể" +"Detach property" = "Tách thuộc tính" +"Detach style" = "Tách kiểu" +"Detach styles" = "Tách các kiểu" +"Detach variable" = "Tách biến" +"Details" = "Chi tiết" +"Dev Mode" = "Dev Mode" +"Dev resources" = "Tài nguyên dev" +"Development" = "Phát triển" +"Device" = "Thiết bị" +"Device at 100%" = "Thiết bị ở mức 100%" +"Diamond" = "Hình thoi" +"Difference" = "Khác biệt" +"Dimensions" = "Kích thước" +"Direction" = "Hướng" +"Disconnect from code" = "Ngắt kết nối mã" +"Dispersion" = "Tán sắc" +"Display name" = "Tên hiển thị" +"Dissolve" = "Hòa tan" +"Distribute and tidy" = "Phân bổ và sắp xếp gọn" +"Distribute horizontal spacing" = "Phân bổ đều khoảng cách ngang" +"Distribute vertical spacing" = "Phân bổ đều khoảng cách dọc" +"Divider" = "Đường phân cách" +"Don't truncate" = "Không cắt bớt" +"Done" = "Xong" +"Done editing" = "Hoàn tất chỉnh sửa" +"Dots" = "Chấm" +"Dotted" = "Chấm tròn" +"Down" = "Xuống" +"Drafts" = "Bản nháp" +"Drag the stroke to add a width point" = "Kéo nét để thêm điểm độ rộng" +"Draw" = "Vẽ" +"Draw a frame" = "Vẽ khung" +"Drawing tools" = "Công cụ vẽ" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Thả tệp .figcraft, .fig, .json, .svg hoặc hình ảnh vào đây." +"Drop shadow" = "Đổ bóng" +"Drop to import" = "Thả để nhập" +"Drop to open" = "Thả để mở" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Cấp phép kép MIT HOẶC Apache-2.0. Được phát triển bởi đội ngũ và cộng đồng ArtCraft." +"Duo" = "Hai tông" +"Duplicate" = "Nhân bản" +"Duplicate by dragging" = "Nhân bản bằng cách kéo" +"Duplicate mode" = "Nhân bản chế độ" +"Duplicate page" = "Nhân bản trang" +"Edit" = "Chỉnh sửa" +"Edit as an expression" = "Chỉnh sửa dưới dạng biểu thức" +"Edit colour" = "Chỉnh sửa màu" +"Edit comment" = "Chỉnh sửa bình luận" +"Edit dev resource" = "Chỉnh sửa tài nguyên dev" +"Edit link" = "Liên kết chỉnh sửa" +"Edit link copied" = "Đã sao chép liên kết chỉnh sửa" +"Edit style" = "Chỉnh sửa kiểu" +"Edit variable" = "Chỉnh sửa biến" +"Edit with the builder" = "Chỉnh sửa bằng bộ dựng" +"Edited {when}" = "Đã chỉnh sửa {when}" +"Effect settings" = "Thiết lập hiệu ứng" +"Effect styles" = "Kiểu hiệu ứng" +"Effects" = "Hiệu ứng" +"Ellipse" = "Hình elip" +"Ellipses" = "Hình elip" +"Else" = "Ngược lại" +"Else if" = "Hoặc nếu" +"Embedded profile" = "Hồ sơ nhúng sẵn" +"Empty" = "Trống" +"End" = "Cuối" +"End point" = "Điểm cuối" +"Ends the session; everyone keeps their copy" = "Kết thúc phiên; mọi người vẫn giữ bản sao của mình" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Nhập địa chỉ figcraft-server và nhấn Duyệt để xem các plugin mà máy chủ cung cấp." +"Enter to save · Esc to cancel" = "Enter để lưu · Esc để hủy" +"Eraser" = "Cục tẩy" +"Esc" = "Esc" +"Essential" = "Thiết yếu" +"Every conflict has a choice." = "Mọi xung đột đều đã có lựa chọn." +"Every top-level frame already starts a flow" = "Mọi khung cấp cao nhất đều đã là điểm bắt đầu luồng" +"Exact opacity (4 then 5: 45%)" = "Độ mờ chính xác (nhấn 4 rồi 5: 45%)" +"Exclude" = "Loại trừ" +"Excluded" = "Đã loại trừ" +"Excluded from layout" = "Đã loại trừ khỏi bố cục" +"Excludes" = "Phép loại trừ" +"Exclusion" = "Loại trừ" +"Exit focus view (Esc)" = "Thoát chế độ xem tập trung (Esc)" +"Expand" = "Mở rộng" +"Export" = "Xuất" +"Export all" = "Xuất tất cả" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Xuất các khung và lớp dưới dạng PNG, JPG, SVG hoặc PDF" +"Export page as PNG" = "Xuất trang dưới dạng PNG" +"Export settings" = "Thiết lập xuất" +"Export variables" = "Xuất các biến" +"Export {name}" = "Xuất {name}" +"Export {n} layers" = { other = "Xuất {n} lớp" } +"Exported {file}" = "Đã xuất {file}" +"Exported {name}" = "Đã xuất {name}" +"Exported {name}; {n} kinds of content were left out" = { other = "Đã xuất {name}; {n} loại nội dung đã bị lược bỏ" } +"Exported {n} files" = { other = "Đã xuất {n} tệp" } +"Export…" = "Xuất…" +"Expose properties to instances" = "Hiển thị thuộc tính từ các bản thể lồng nhau" +"Exposure" = "Phơi sáng" +"Expression" = "Biểu thức" +"Extend collection" = "Mở rộng bộ sưu tập" +"Extends \"{name}\": overridden values are marked" = "Mở rộng \"{name}\": các giá trị ghi đè được đánh dấu" +"Eye" = "Mắt" +"F" = "F" +"Fade" = "Mờ dần" +"False" = "Sai" +"Feb" = "Thg 2" +"Fetching from the server…" = "Đang tải từ máy chủ…" +"FigCraft couldn't open its window: {error}" = "FigCraft không thể mở cửa sổ: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft chưa hỗ trợ mọi tính năng của các tệp này. Các ghi chú dưới đây liệt kê những gì đã được lược bỏ hoặc thay đổi trong bản sao đã nhập; tệp gốc không bị thay đổi." +"File" = "Tệp" +"File name" = "Tên tệp" +"File not found" = "Không tìm thấy tệp" +"File published" = "Đã xuất bản tệp" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Các tệp không được mã hóa đầu cuối: người vận hành máy chủ có thể đọc được chúng." +"Files nobody opens for {n} days are deleted." = { other = "Các tệp không ai mở trong {n} ngày sẽ bị xóa." } +"Fill" = "Màu tô" +"Fill (fr)" = "Lấp đầy (fr)" +"Fill container" = "Lấp đầy khung chứa" +"Fill window" = "Lấp đầy cửa sổ" +"Fills" = "Màu tô" +"Fill|sizing" = "Lấp đầy" +"Filter and sort" = "Lọc và sắp xếp" +"Find" = "Tìm" +"Find and replace" = "Tìm và thay thế" +"Find layers" = "Tìm lớp" +"First" = "Đầu tiên" +"First commit" = "Commit đầu tiên" +"First on top" = "Mục đầu ở trên cùng" +"Fit" = "Vừa khít" +"Fit device to window" = "Vừa thiết bị với cửa sổ" +"Fit to contents" = "Vừa khít nội dung" +"Fit to window" = "Vừa khít cửa sổ" +"Fit width" = "Vừa chiều rộng" +"Fixed" = "Cố định" +"Fixed gap" = "Khoảng hở cố định" +"Fixed size" = "Kích thước cố định" +"Flatten" = "Làm phẳng" +"Flip" = "Lật" +"Flip horizontal" = "Lật ngang" +"Flip text to the other side" = "Lật văn bản sang phía bên kia" +"Flip vertical" = "Lật dọc" +"Flip width profile" = "Lật biên dạng độ rộng" +"Flow starting point" = "Điểm bắt đầu luồng" +"Flow starting points" = "Các điểm bắt đầu luồng" +"Flows" = "Các luồng" +"Focus view" = "Chế độ xem tập trung" +"Fold autosaves" = "Gộp các bản tự động lưu" +"Folder: click to collapse" = "Thư mục: nhấp để thu gọn" +"Folder: click to expand" = "Thư mục: nhấp để mở rộng" +"Follow" = "Theo dõi" +"Follow {name}" = "Theo dõi {name}" +"Following" = "Đang theo dõi" +"Following {name} · {key} to stop" = "Đang theo dõi {name} · {key} để dừng" +"Font" = "Phông chữ" +"Font not installed" = "Phông chữ chưa được cài đặt" +"Font size" = "Cỡ chữ" +"Font style" = "Kiểu phông chữ" +"Forget on this computer" = "Quên trên máy tính này" +"Four screens of a trail app with components and a prototype flow" = "Bốn màn hình ứng dụng đường mòn với các thành phần và luồng nguyên mẫu" +"Fractions" = "Phân số" +"Frame" = "Khung" +"Frame selection" = "Đóng khung vùng chọn" +"Frame tools" = "Công cụ khung" +"Frames" = "Các khung" +"From the library \"{name}\": read-only" = "Từ thư viện \"{name}\": chỉ đọc" +"From {name}" = "Từ {name}" +"From {server}" = "Từ {server}" +"Frost" = "Độ mờ sương" +"Gap" = "Khoảng hở" +"Gap between columns" = "Khoảng hở giữa các cột" +"Gap between items" = "Khoảng hở giữa các mục" +"Gap between rows" = "Khoảng hở giữa các hàng" +"Getting started" = "Bắt đầu" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Nhập địa chỉ của một máy chủ công khai, hoặc địa chỉ máy chủ kèm theo khóa (`figcraft-server key` sẽ in khóa đó)." +"Glass" = "Kính" +"Go to main component" = "Đi tới thành phần chính" +"Go to {name}" = "Đi tới {name}" +"Grab bag" = "Tổng hợp" +"Gradient" = "Chuyển sắc" +"Gray" = "Xám" +"Green" = "Xanh lá" +"Greyscale components and two example screens to sketch a flow with" = "Các thành phần thang độ xám và hai màn hình mẫu để phác thảo luồng" +"Grid" = "Lưới" +"Grid ({size})" = "Lưới ({size})" +"Grid styles" = "Kiểu lưới" +"Grid {size}" = "Lưới {size}" +"Group" = "Nhóm" +"Group selection" = "Nhóm vùng chọn" +"Groups" = "Các nhóm" +"Gutter" = "Rãnh cột" +"Hand tool" = "Công cụ bàn tay" +"Hanging list" = "Danh sách nhô đầu dòng" +"Hanging punctuation" = "Dấu câu treo ngoài lề" +"Hard light" = "Ánh sáng gắt" +"Height" = "Chiều cao" +"Help" = "Trợ giúp" +"Help and resources" = "Trợ giúp và tài nguyên" +"Help page" = "Trang trợ giúp" +"Here: {names}" = "Tại đây: {names}" +"Hidden" = "Đã ẩn" +"Hide contents" = "Ẩn nội dung" +"Hide from publishing" = "Ẩn khi xuất bản" +"Hide section contents" = "Ẩn nội dung phần" +"Hide these tips (Getting started in the Help menu shows them again)" = "Ẩn các mẹo này (chọn Bắt đầu trong trình đơn Trợ giúp để hiện lại)" +"Hide when publishing" = "Ẩn khi xuất bản" +"Highlight layers on hover" = "Làm nổi bật lớp khi di chuột qua" +"Highlights" = "Vùng sáng" +"Hold Alt" = "Giữ Alt" +"Hold Alt+Z" = "Giữ Alt+Z" +"Hold Ctrl" = "Giữ Ctrl" +"Hold Z" = "Giữ Z" +"Hold ⌃ or ⌘" = "Giữ ⌃ hoặc ⌘" +"Hold ⌥" = "Giữ ⌥" +"Hold ⌥Z" = "Giữ ⌥Z" +"Home" = "Trang chủ" +"Horizontal" = "Ngang" +"Horizontal align" = "Căn ngang" +"Horizontal hex" = "Lục giác ngang" +"Horizontal padding" = "Đệm ngang" +"Horizontal spacing" = "Khoảng cách ngang" +"Hosted by {operator}" = "Được vận hành bởi {operator}" +"Hover a layer to see its properties" = "Di chuột qua một lớp để xem thuộc tính" +"Hover other layers to measure the distance" = "Di chuột qua các lớp khác để đo khoảng cách" +"Hue" = "Sắc độ" +"Hug" = "Ôm khít" +"Hug contents" = "Ôm khít nội dung" +"Icon components on a 24 px grid with keylines and a size preview" = "Các thành phần biểu tượng trên lưới 24 px có đường định chuẩn và khung xem trước kích thước" +"Icon set starter" = "Bộ khởi đầu biểu tượng" +"If" = "Nếu" +"If it keeps happening, please report it at {link} with this message." = "Nếu lỗi vẫn tiếp diễn, vui lòng báo cáo tại {link} kèm theo thông báo này." +"If {condition}" = "Nếu {condition}" +"Ignore auto layout (absolute position)" = "Bỏ qua bố cục tự động (vị trí tuyệt đối)" +"Image" = "Hình ảnh" +"Images" = "Hình ảnh" +"Import" = "Nhập" +"Import path" = "Đường dẫn nhập" +"Import plugin from manifest" = "Nhập plugin từ tệp kê khai" +"Import plugin from manifest…" = "Nhập plugin từ tệp kê khai…" +"Import report" = "Báo cáo nhập" +"Import variables" = "Nhập các biến" +"Imported document" = "Tài liệu đã nhập" +"Imported {name}" = "Đã nhập {name}" +"Imported {n} new variables, updated {updated}" = { other = "Đã nhập {n} biến mới, cập nhật {updated}" } +"Import…" = "Nhập…" +"In an instance, this comes from the main component" = "Trong bản thể, giá trị này được lấy từ thành phần chính" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "Trên trình duyệt, bạn có thể tham gia các phiên do ứng dụng máy tính chủ trì và các tệp trên máy chủ FigCraft. Trang mở qua https chỉ kết nối tới máy tính khác qua liên kết wss:// (máy chủ có TLS)." +"Include bounding box" = "Bao gồm khung giới hạn" +"Include id attribute" = "Bao gồm thuộc tính id" +"Included" = "Đã bao gồm" +"Included in layout" = "Được tính trong bố cục" +"Individual corners" = "Từng góc riêng" +"Individual padding" = "Đệm từng cạnh" +"Individual strokes" = "Nét từng cạnh" +"Ink" = "Mực" +"Inner shadow" = "Bóng trong" +"Inside" = "Bên trong" +"Inspect" = "Kiểm tra" +"Inspect the layer under the pointer: size, colours, typography" = "Kiểm tra lớp dưới con trỏ: kích thước, màu sắc, kiểu chữ" +"Install" = "Cài đặt" +"Install plugin" = "Cài đặt plugin" +"Installed" = "Đã cài đặt" +"Installed {name}" = "Đã cài đặt {name}" +"Installing…" = "Đang cài đặt…" +"Instance" = "Bản thể" +"Instance properties" = "Thuộc tính bản thể" +"Instance swap" = "Hoán đổi bản thể" +"Instance swap (no other component)" = "Hoán đổi bản thể (không có thành phần khác)" +"Instances" = "Các bản thể" +"Instant" = "Tức thì" +"Intensity" = "Cường độ" +"Interaction" = "Tương tác" +"Interactions" = "Các tương tác" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Thiết kế giao diện và tạo nguyên mẫu. Công cụ thiết kế miễn phí, mã nguồn mở, được viết mới hoàn toàn bằng Rust, thuộc bộ ứng dụng sáng tạo ArtCraft." +"Interface size" = "Kích thước giao diện" +"Intersect" = "Giao nhau" +"Intersects" = "Phép giao" +"Invalid color" = "Màu không hợp lệ" +"Invite link" = "Liên kết mời" +"Invite link copied" = "Đã sao chép liên kết mời" +"It asks permission to:" = "Plugin yêu cầu quyền:" +"Jan" = "Thg 1" +"Join" = "Tham gia" +"Join code copied" = "Đã sao chép mã tham gia" +"Join code or invite link" = "Mã tham gia hoặc liên kết mời" +"Join live session" = "Tham gia phiên trực tiếp" +"Join us on Discord" = "Tham gia Discord cùng chúng tôi" +"Join with a code" = "Tham gia bằng mã" +"Joined" = "Đã tham gia" +"Joined {file}" = "Đã tham gia {file}" +"Joining…" = "Đang tham gia…" +"Join|stroke" = "Khớp nối" +"Jul" = "Thg 7" +"Jun" = "Thg 6" +"Just now" = "Vừa xong" +"Justify" = "Căn đều" +"Keep appearance" = "Giữ nguyên diện mạo" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Giữ nguyên diện mạo sẽ chuyển đổi mọi giá trị màu sang {profile} để màu sắc hiển thị như cũ. Giữ nguyên giá trị sẽ giữ nguyên các con số nên màu sắc sẽ thay đổi." +"Keep tool selected after use" = "Giữ công cụ được chọn sau khi dùng" +"Keep values" = "Giữ nguyên giá trị" +"Key/Gamepad" = "Phím/Tay cầm" +"Key/Gamepad {key}" = "Phím/Tay cầm {key}" +"Keyboard shortcuts" = "Phím tắt" +"Kits" = "Bộ công cụ" +"Landing page" = "Trang đích" +"Language" = "Ngôn ngữ" +"Lasso" = "Dây quây" +"Last" = "Cuối cùng" +"Last change: {note}" = "Thay đổi gần nhất: {note}" +"Last commit" = "Commit cuối cùng" +"Last modified" = "Sửa đổi lần cuối" +"Last on top" = "Mục cuối ở trên cùng" +"Layer blur" = "Làm mờ lớp" +"Layer name" = "Tên lớp" +"Layers" = "Các lớp" +"Layers in an instance take their position from the main component" = "Các lớp trong bản thể lấy vị trí từ thành phần chính" +"Layout" = "Bố cục" +"Layout grid" = "Lưới bố cục" +"Layout grid settings" = "Thiết lập lưới bố cục" +"Layout grids" = "Lưới bố cục" +"Leave session" = "Rời phiên" +"Leaves" = "Lá cây" +"Left" = "Trái" +"Left & right" = "Trái & phải" +"Left padding" = "Đệm trái" +"Left side" = "Cạnh trái" +"Left side width" = "Độ rộng cạnh trái" +"Letter case" = "Kiểu chữ hoa/thường" +"Letter spacing" = "Khoảng cách chữ" +"Libraries" = "Thư viện" +"Libraries in this file" = "Thư viện trong tệp này" +"Library updates available" = "Có bản cập nhật thư viện" +"Library: click to collapse" = "Thư viện: nhấp để thu gọn" +"Library: click to expand" = "Thư viện: nhấp để mở rộng" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Thư viện: các biến của thư viện chỉ đọc tại đây; hãy chỉnh sửa chúng trong tệp thư viện rồi xuất bản" +"Light" = "Sáng" +"Lighten" = "Làm sáng" +"Light|glass" = "Ánh sáng" +"Line" = "Đường thẳng" +"Line arrow" = "Mũi tên nét mảnh" +"Line delta" = "Chênh lệch dòng" +"Line height" = "Chiều cao dòng" +"Linear" = "Tuyến tính" +"Lines" = "Đường thẳng" +"Lines +/−" = "Dòng +/−" +"Lines added" = "Dòng đã thêm" +"Lines deleted" = "Dòng đã xóa" +"Link" = "Liên kết" +"Link copied" = "Đã sao chép liên kết" +"Link docs, code or tickets for developers." = "Liên kết tài liệu, mã nguồn hoặc thẻ công việc cho nhà phát triển." +"Link frames on the Prototype tab, then present the flow" = "Liên kết các khung trong thẻ Nguyên mẫu, sau đó trình chiếu luồng" +"Link this component to its code component: instances then show your real snippet with their values." = "Liên kết thành phần này với thành phần mã nguồn: các bản thể sẽ hiển thị đoạn mã thực kèm theo giá trị của chúng." +"List spacing" = "Khoảng cách mục danh sách" +"List style" = "Kiểu danh sách" +"Live session" = "Phiên trực tiếp" +"Live · {people}" = "Trực tiếp · {people}" +"Local components" = "Thành phần cục bộ" +"Local network" = "Mạng cục bộ" +"Local styles" = "Kiểu cục bộ" +"Local variables" = "Biến cục bộ" +"Lock/Unlock" = "Khóa/Mở khóa" +"Locked" = "Đã khóa" +"Lowercase" = "Chữ thường" +"Luminance" = "Độ chói" +"Luminosity" = "Độ sáng" +"Main" = "Chính" +"Main component deleted" = "Thành phần chính đã bị xóa" +"Main menu" = "Trình đơn chính" +"Make a component" = "Tạo thành phần" +"Make slot" = "Tạo ô chứa" +"Mar" = "Thg 3" +"Margin" = "Lề" +"Mark all as read" = "Đánh dấu tất cả là đã đọc" +"Mark as resolved" = "Đánh dấu là đã giải quyết" +"Mark as unread" = "Đánh dấu là chưa đọc" +"Mark as unresolved" = "Đánh dấu là chưa giải quyết" +"Mask" = "Mặt nạ" +"Mass · Stiffness · Damping" = "Khối lượng · Độ cứng · Độ giảm chấn" +"Match (empty: the whole name)" = "Khớp (để trống: toàn bộ tên)" +"Max H" = "Cao tối đa" +"Max W" = "Rộng tối đa" +"Max lines" = "Số dòng tối đa" +"Maximize" = "Phóng to cực đại" +"May" = "Tháng 5" +"Measure to the selection" = "Đo khoảng cách tới vùng chọn" +"Merge into main" = "Hợp nhất vào nhánh chính" +"Merge regions" = "Hợp nhất các vùng" +"Merge the branch into the main file (one undo step) and archive it" = "Hợp nhất nhánh vào tệp chính (một bước hoàn tác) và lưu trữ nhánh" +"Merged" = "Đã hợp nhất" +"Merged PRs" = "PR đã hợp nhất" +"Min H" = "Cao tối thiểu" +"Min W" = "Rộng tối thiểu" +"Minimize" = "Thu nhỏ" +"Mirror angle" = "Đối xứng góc" +"Mirror angle and length" = "Đối xứng góc và độ dài" +"Mirroring" = "Đối xứng tay nắm" +"Missing" = "Thiếu" +"Missing fonts" = "Phông chữ bị thiếu" +"Missing layer" = "Lớp bị thiếu" +"Missing style" = "Kiểu bị thiếu" +"Missing variable" = "Biến bị thiếu" +"Miter" = "Góc nhọn" +"Miter angle" = "Góc vát nhọn" +"Mixed" = "Hỗn hợp" +"Mixed fonts" = "Nhiều phông chữ" +"Mixed interactions: click + to replace them" = "Tương tác hỗn hợp: nhấp + để thay thế" +"Mobile app" = "Ứng dụng di động" +"Mode" = "Chế độ" +"Model" = "Mô hình" +"Models" = "Mô hình AI" +"Mono" = "Đơn sắc" +"More" = "Thêm" +"More instance actions" = "Thêm hành động cho bản thể" +"Mouse down" = "Nhấn chuột xuống" +"Mouse enter" = "Đưa chuột vào" +"Mouse leave" = "Đưa chuột ra" +"Mouse up" = "Nhả chuột" +"Move" = "Di chuyển" +"Move along the toolbar or the layer rows" = "Di chuyển dọc thanh công cụ hoặc các hàng lớp" +"Move column left" = "Chuyển cột sang trái" +"Move column right" = "Chuyển cột sang phải" +"Move down" = "Di chuyển xuống" +"Move in" = "Di chuyển vào" +"Move out" = "Di chuyển ra" +"Move tools" = "Công cụ di chuyển" +"Move up" = "Di chuyển lên" +"Moved to {name}" = "Đã chuyển sang {name}" +"Multi" = "Đa sắc" +"Multiply" = "Nhân" +"Muted" = "Trầm" +"Name" = "Tên" +"Name (A–Z)" = "Tên (A–Z)" +"Named instance" = "Bản thể có tên" +"Navigate to" = "Điều hướng đến" +"Nested instance" = "Bản thể lồng nhau" +"New design file" = "Tệp thiết kế mới" +"Newest first" = "Mới nhất trước" +"Next frame" = "Khung tiếp theo" +"Next or previous control in the focused region" = "Điều khiển tiếp theo hoặc trước đó trong vùng đang giữ tiêu điểm" +"Next ready for dev" = "Mục Sẵn sàng cho dev tiếp theo" +"Next size option" = "Tùy chọn kích thước tiếp theo" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Không có tài nguyên: chưa có thiết lập xuất, biểu tượng hay hình ảnh. Hãy thêm thiết lập xuất trong thẻ Thiết kế." +"No boolean variables fit here." = "Không có biến boolean nào phù hợp tại đây." +"No boolean variables yet. Create them in Local variables." = "Chưa có biến boolean nào. Hãy tạo trong mục Biến cục bộ." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Chưa có nhánh nào. Nhánh là bản sao của tệp để thử nghiệm thay đổi, sau đó xem xét và hợp nhất." +"No category" = "Không có danh mục" +"No changes" = "Không có thay đổi" +"No color styles" = "Không có kiểu màu" +"No colour variables fit here." = "Không có biến màu nào phù hợp tại đây." +"No colour variables yet. Create them in Local variables." = "Chưa có biến màu nào. Hãy tạo trong mục Biến cục bộ." +"No comments yet" = "Chưa có bình luận nào" +"No compatibility warnings reported" = "Không có cảnh báo tương thích nào" +"No components yet. Select layers and press {keys}." = "Chưa có thành phần nào. Hãy chọn các lớp và nhấn {keys}." +"No conflicts." = "Không có xung đột." +"No contributor data was built into this copy." = "Bản sao này không có dữ liệu người đóng góp." +"No device" = "Không có thiết bị" +"No files or templates match your search" = "Không có tệp hoặc mẫu nào khớp với tìm kiếm của bạn" +"No fonts match" = "Không có phông chữ nào khớp" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Không có mã tham gia tại đây (máy chủ cũ hơn tính năng mã tham gia, hoặc địa chỉ này không thể tạo mã): hãy chia sẻ liên kết thay thế." +"No layer changes to show." = "Không có thay đổi lớp nào để hiển thị." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Chưa có thư viện nào. Hãy thêm tệp .figcraft đã xuất bản làm thư viện, hoặc dán liên kết thư viện nhóm." +"No list" = "Không tạo danh sách" +"No matching comments" = "Không có bình luận phù hợp" +"No matching components" = "Không có thành phần phù hợp" +"No matching layers" = "Không có lớp phù hợp" +"No matching styles" = "Không có kiểu phù hợp" +"No matching variables" = "Không có biến phù hợp" +"No mirroring" = "Không đối xứng" +"No model credits were built into this copy." = "Bản sao này không có thông tin ghi nhận mô hình." +"No number variables fit here." = "Không có biến số nào phù hợp tại đây." +"No number variables yet. Create them in Local variables." = "Chưa có biến số nào. Hãy tạo trong mục Biến cục bộ." +"No other pages" = "Không có trang nào khác" +"No paints" = "Không có màu tô" +"No plugins on {server}." = "Không có plugin nào trên {server}." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Chưa có plugin nào. Hãy nhập một plugin phát triển từ tệp figcraft-plugin.json." +"No preferred components" = "Chưa có thành phần ưu tiên" +"No preset" = "Không có thiết lập sẵn" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Chưa có phiên bản đã lưu nào. Hãy lưu một phiên bản để đánh dấu mốc công việc có thể quay lại sau." +"No scrolling" = "Không cuộn" +"No shortcuts match" = "Không có phím tắt nào khớp" +"No status" = "Không có trạng thái" +"No string variables fit here." = "Không có biến chuỗi nào phù hợp tại đây." +"No string variables yet. Create them in Local variables." = "Chưa có biến chuỗi nào. Hãy tạo trong mục Biến cục bộ." +"No styles yet." = "Chưa có kiểu nào." +"No styles yet. Click + to create one from the selection." = "Chưa có kiểu nào. Nhấp + để tạo kiểu từ vùng chọn." +"No templates match your search" = "Không có mẫu nào khớp với tìm kiếm của bạn" +"No text styles yet. Click + to create one from the selected text." = "Chưa có kiểu văn bản nào. Nhấp + để tạo kiểu từ văn bản đã chọn." +"No variables fit here." = "Không có biến nào phù hợp tại đây." +"No variables yet. Create them in Local variables." = "Chưa có biến nào. Hãy tạo trong mục Biến cục bộ." +"Nobody else is here yet" = "Chưa có ai khác ở đây" +"Noise" = "Nhiễu" +"None" = "Không có" +"None." = "Không có." +"Normal" = "Bình thường" +"Not bound" = "Chưa gắn biến" +"Not in a live session" = "Không ở trong phiên trực tiếp" +"Not in this font" = "Không có trong phông chữ này" +"Not properties, kept as written: {names}" = "Không phải thuộc tính, được giữ nguyên như đã viết: {names}" +"Not read yet" = "Chưa đọc" +"Not shared" = "Chưa chia sẻ" +"Note {n}" = "Ghi chú {n}" +"Nothing changed since this was marked ready for dev." = "Chưa có gì thay đổi kể từ khi được đánh dấu sẵn sàng cho dev." +"Nothing here yet. Set a frame's status to list it." = "Chưa có gì ở đây. Hãy đặt trạng thái cho một khung để hiển thị tại đây." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Chưa có tệp trực tuyến nào. Hãy dùng nút Chia sẻ trên một tệp để đưa lên trực tuyến và tệp sẽ xuất hiện tại đây." +"Nothing to export" = "Không có gì để xuất" +"Nov" = "Thg 11" +"Now" = "Bây giờ" +"Nudge (Shift: big nudge)" = "Dịch nhẹ (Shift: dịch bước lớn)" +"Nudge amount" = "Mức dịch chuyển nhẹ" +"Number" = "Số" +"Number ↑" = "Số ↑" +"Number ↓" = "Số ↓" +"Numbered list" = "Danh sách đánh số" +"Object" = "Đối tượng" +"Oct" = "Thg 10" +"Off" = "Tắt" +"Offered first when filling the slot (any content is allowed)" = "Được đề xuất đầu tiên khi thêm vào ô chứa (cho phép mọi nội dung)" +"Offered first when swapping the instance (any component is allowed)" = "Được đề xuất đầu tiên khi hoán đổi bản thể (cho phép mọi thành phần)" +"Offset" = "Độ lệch" +"Oldest first" = "Cũ nhất trước" +"On click" = "Khi nhấp" +"On drag" = "Khi kéo" +"On this page" = "Trên trang này" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Một thông báo với năm kích thước phổ biến: vuông, dọc, story, liên kết và biểu ngữ" +"Online" = "Trực tuyến" +"Online files" = "Tệp trực tuyến" +"Online sharing is off: files are shared on the local network only." = "Chia sẻ trực tuyến đang tắt: tệp chỉ được chia sẻ trong mạng cục bộ." +"Only the first changed layers are listed." = "Chỉ liệt kê những lớp thay đổi đầu tiên." +"Only the first {n} changed layers are listed." = "Chỉ liệt kê {n} lớp thay đổi đầu tiên." +"Only your threads" = "Chỉ luồng của bạn" +"Opacity" = "Độ mờ" +"Opacity & blend" = "Độ mờ & hòa trộn" +"Opacity 10%…90%, 0: 100%" = "Độ mờ 10%…90%, 0: 100%" +"Open" = "Mở" +"Open .figcraft, .svg or images" = "Mở tệp .figcraft, .svg hoặc hình ảnh" +"Open branch" = "Mở nhánh" +"Open file" = "Mở tệp" +"Open files" = "Các tệp đang mở" +"Open in focus view" = "Mở trong chế độ xem tập trung" +"Open in new window" = "Mở trong cửa sổ mới" +"Open invite links in FigCraft" = "Mở liên kết mời trong FigCraft" +"Open link" = "Mở liên kết" +"Open link {url}" = "Mở liên kết {url}" +"Open overlay" = "Mở lớp phủ" +"Open prototype preview" = "Mở xem trước nguyên mẫu" +"Open variables ({count})" = "Mở các biến ({count})" +"Opened as a new document" = "Đã mở dưới dạng tài liệu mới" +"Opened “{name}” in a new tab" = "Đã mở “{name}” trong thẻ mới" +"Opening the file…" = "Đang mở tệp…" +"Opening {n} files as new files" = { other = "Đang mở {n} tệp dưới dạng tệp mới" } +"Open|comments" = "Đang mở" +"Options" = "Tùy chọn" +"Orange" = "Cam" +"Order" = "Thứ tự" +"Other" = "Khác" +"Other apps may not open every .fig file exported here." = "Các ứng dụng khác có thể không mở được mọi tệp .fig xuất từ đây." +"Others lose access and keep their copy; the file stays on the server for you" = "Những người khác sẽ mất quyền truy cập và giữ lại bản sao của họ; tệp vẫn còn trên máy chủ cho bạn" +"Others type this under Share ▸ Join with a code" = "Người khác nhập mã này tại mục Chia sẻ ▸ Tham gia bằng mã" +"Outline stroke" = "Chuyển nét thành đường viền" +"Outline text" = "Chuyển văn bản thành đường viền" +"Outlines" = "Đường viền nét" +"Outside" = "Bên ngoài" +"Overflow" = "Nội dung tràn" +"Overlay" = "Lớp phủ" +"Overlay position" = "Vị trí lớp phủ" +"Overridden here (the collection it extends has another value)" = "Đã ghi đè tại đây (bộ sưu tập gốc có giá trị khác)" +"Overrides" = "Ghi đè" +"Owner token" = "Mã thông báo chủ sở hữu" +"Owner token copied" = "Đã sao chép mã thông báo chủ sở hữu" +"PDF print options" = "Tùy chọn in PDF" +"PDF print settings" = "Thiết lập in PDF" +"Padding" = "Đệm" +"Page" = "Trang" +"Page deleted" = "Đã xóa trang" +"Pages" = "Các trang" +"Pages added to the document" = "Các trang đã được thêm vào tài liệu" +"Pages: {pages}" = "Trang: {pages}" +"Paint bucket" = "Thùng sơn" +"Pan the canvas" = "Di chuyển vùng vẽ" +"Pan with nothing selected (Shift: farther)" = "Di chuyển vùng vẽ khi không chọn gì (Shift: xa hơn)" +"Panels" = "Bảng" +"Paper" = "Giấy" +"Paragraph indent" = "Thụt lề đoạn văn" +"Paragraph indent applies to left-aligned text" = "Thụt lề đoạn văn áp dụng cho văn bản căn trái" +"Paragraph spacing" = "Khoảng cách đoạn văn" +"Pass through" = "Xuyên qua" +"Paste a link (https://…)" = "Dán một liên kết (https://…)" +"Paste a team library link (a view link)" = "Dán liên kết thư viện nhóm (liên kết xem)" +"Paste here" = "Dán vào đây" +"Paste to replace" = "Dán để thay thế" +"Pasted design layers; {n} kinds of content were left out" = { other = "Đã dán các lớp thiết kế; {n} loại nội dung đã bị lược bỏ" } +"Pattern" = "Họa tiết" +"Pattern alignment" = "Căn chỉnh họa tiết" +"Pattern scale" = "Tỷ lệ họa tiết" +"Pattern source" = "Nguồn họa tiết" +"Pen" = "Bút" +"Pencil" = "Bút chì" +"People in this file" = "Những người trong tệp này" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Mọi người có thể tham gia từ bất kỳ đâu bằng mã. Tệp được lưu trên {server} trong khi bạn chia sẻ." +"People on your network join with a code. Nothing goes through a server." = "Mọi người trong mạng của bạn tham gia bằng mã. Không có dữ liệu nào đi qua máy chủ." +"Phone" = "Điện thoại" +"Pick color" = "Chọn màu" +"Pick color (I)" = "Chọn màu (I)" +"Pick one font family first" = "Hãy chọn một họ phông chữ trước" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Chọn công cụ Khung, sau đó kéo trên vùng vẽ hoặc chọn kích thước thiết bị" +"Pin a property" = "Ghim thuộc tính" +"Pinch" = "Bóp nhọn" +"Pixel grid" = "Lưới pixel" +"Pixel preview 1x" = "Xem trước pixel 1x" +"Pixel preview 2x" = "Xem trước pixel 2x" +"Place a shape or frame (its tool chosen)" = "Đặt hình dạng hoặc khung (khi đã chọn công cụ tương ứng)" +"Place automatically" = "Đặt tự động" +"Place image" = "Đặt hình ảnh" +"Placed on the current page" = "Đã đặt trên trang hiện tại" +"Playground" = "Sân thử nghiệm" +"Plugin" = "Plugin" +"Plugins" = "Plugin" +"Plugins & widgets" = "Plugin & tiện ích" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugin chạy trong môi trường cô lập của FigCraft, không truy cập tệp, mạng hay đồng hồ. Chỉ cài đặt những plugin bạn tin tưởng." +"Plus darker" = "Cộng tối hơn" +"Plus lighter" = "Cộng sáng hơn" +"Point" = "Điểm" +"Points" = "Các điểm" +"Polygon" = "Đa giác" +"Polygons" = "Đa giác" +"Port" = "Cổng" +"Position" = "Vị trí" +"Post" = "Đăng" +"Preferences" = "Tùy chọn" +"Preferred" = "Ưu tiên" +"Present" = "Trình chiếu" +"Present or preview" = "Trình chiếu hoặc xem trước" +"Present this flow" = "Trình chiếu luồng này" +"Presentation" = "Thuyết trình" +"Presentation slides" = "Trang trình chiếu" +"Presenting: comments" = "Khi trình chiếu: bình luận" +"Presenting: cycle the size options" = "Khi trình chiếu: chuyển đổi tùy chọn kích thước" +"Presenting: full screen" = "Khi trình chiếu: toàn màn hình" +"Presenting: next frame" = "Khi trình chiếu: khung tiếp theo" +"Presenting: previous frame" = "Khi trình chiếu: khung trước" +"Presenting: restart" = "Khi trình chiếu: bắt đầu lại" +"Preserve scroll position" = "Giữ nguyên vị trí cuộn" +"Presets" = "Thiết lập sẵn" +"Press a key…" = "Nhấn một phím…" +"Preview" = "Xem trước" +"Preview this flow" = "Xem trước luồng này" +"Preview this frame" = "Xem trước khung này" +"Previous frame" = "Khung trước" +"Previous ready for dev" = "Mục Sẵn sàng cho dev trước đó" +"Progressive" = "Lũy tiến" +"Properties" = "Thuộc tính" +"Prototype" = "Nguyên mẫu" +"Prototype preview" = "Xem trước nguyên mẫu" +"Prototype settings" = "Thiết lập nguyên mẫu" +"Publish" = "Xuất bản" +"Publish changes" = "Xuất bản thay đổi" +"Publish to another server…" = "Xuất bản lên máy chủ khác…" +"Publish to server" = "Xuất bản lên máy chủ" +"Published version {v}" = "Đã xuất bản phiên bản {v}" +"Published version {v} to the server" = "Đã xuất bản phiên bản {v} lên máy chủ" +"Published version {v}: save the file so other files can use it" = "Đã xuất bản phiên bản {v}: hãy lưu tệp để các tệp khác có thể sử dụng" +"Published · {people}" = "Đã xuất bản · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Cần ứng dụng máy tính để xuất bản lên máy chủ; trên trình duyệt bạn có thể tham gia các tệp trên máy chủ" +"Publishing with the server's key." = "Đang xuất bản bằng khóa của máy chủ." +"Publishing…" = "Đang xuất bản…" +"Push" = "Đẩy" +"Push changes to main component" = "Đẩy thay đổi lên thành phần chính" +"Put text on path" = "Đặt văn bản lên đường dẫn" +"R" = "R" +"Radial" = "Hướng tâm" +"Radius" = "Bán kính" +"Ratio" = "Tỷ lệ" +"Reading the library…" = "Đang đọc thư viện…" +"Ready for dev" = "Sẵn sàng cho dev" +"Real name" = "Tên thật" +"Recent" = "Gần đây" +"Recent files" = "Tệp gần đây" +"Recents" = "Gần đây" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { other = "Đã khôi phục {n} tệp chưa lưu sau khi ứng dụng thoát đột ngột: hãy lưu lại để giữ chúng" } +"Rectangle" = "Hình chữ nhật" +"Rectangles" = "Hình chữ nhật" +"Red" = "Đỏ" +"Reduce motion" = "Giảm chuyển động" +"Refraction" = "Khúc xạ" +"Regular expression" = "Biểu thức chính quy" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Tương ứng với tỷ lệ hiển thị của hệ thống (100% là kích thước chuẩn của hệ thống). Mức thu phóng tài liệu là riêng biệt." +"Remove" = "Xóa" +"Remove all" = "Xóa tất cả" +"Remove annotation" = "Xóa chú thích" +"Remove auto layout" = "Xóa bố cục tự động" +"Remove dev resource" = "Xóa tài nguyên dev" +"Remove export setting" = "Xóa thiết lập xuất" +"Remove from recents" = "Xóa khỏi danh sách gần đây" +"Remove from server" = "Xóa khỏi máy chủ" +"Remove from server…" = "Xóa khỏi máy chủ…" +"Remove guide" = "Xóa đường gióng" +"Remove interaction" = "Xóa tương tác" +"Remove library from this file" = "Xóa thư viện khỏi tệp này" +"Remove min and max" = "Xóa giới hạn tối thiểu và tối đa" +"Remove min and max height" = "Xóa chiều cao tối thiểu và tối đa" +"Remove min and max width" = "Xóa chiều rộng tối thiểu và tối đa" +"Remove regions" = "Xóa các vùng" +"Remove slot" = "Xóa ô chứa" +"Remove starting point" = "Xóa điểm bắt đầu" +"Remove stop" = "Xóa điểm dừng màu" +"Remove text from path" = "Gỡ văn bản khỏi đường dẫn" +"Remove the else and its actions" = "Xóa nhánh ngược lại và các hành động của nó" +"Remove this condition and its actions" = "Xóa điều kiện này và các hành động của nó" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Xóa tệp này khỏi máy chủ? Tệp sẽ không thể chia sẻ hay mở lại từ đó nữa. Bản sao của bạn vẫn mở tại đây." +"Remove {name} from session" = "Xóa {name} khỏi phiên" +"Remove {name} from {server}?" = "Xóa {name} khỏi {server}?" +"Removed" = "Đã xóa" +"Removed from the server" = "Đã xóa khỏi máy chủ" +"Removed since it was marked" = "Đã bị xóa kể từ khi đánh dấu" +"Rename" = "Đổi tên" +"Rename duplicated layers" = "Đổi tên các lớp nhân bản" +"Rename folder" = "Đổi tên thư mục" +"Rename group" = "Đổi tên nhóm" +"Rename layers" = "Đổi tên các lớp" +"Rename mode" = "Đổi tên chế độ" +"Rename on the server" = "Đổi tên trên máy chủ" +"Rename on the server…" = "Đổi tên trên máy chủ…" +"Rename page" = "Đổi tên trang" +"Rename to" = "Đổi tên thành" +"Reorder selection" = "Sắp xếp lại vùng chọn" +"Replace all" = "Thay thế tất cả" +"Replace mixed export settings" = "Thay thế thiết lập xuất hỗn hợp" +"Replace with" = "Thay thế bằng" +"Replace with a new interaction" = "Thay thế bằng tương tác mới" +"Reply" = "Trả lời" +"Report" = "Báo cáo" +"Report this file…" = "Báo cáo tệp này…" +"Reset" = "Đặt lại" +"Reset adjustments" = "Đặt lại điều chỉnh" +"Reset all changes" = "Đặt lại mọi thay đổi" +"Reset all overrides on this layer" = "Đặt lại mọi ghi đè trên lớp này" +"Reset overrides" = "Đặt lại ghi đè" +"Reset slot" = "Đặt lại ô chứa" +"Reset this override" = "Đặt lại ghi đè này" +"Reset to style" = "Đặt lại theo kiểu" +"Reset to the inherited value" = "Đặt lại về giá trị kế thừa" +"Resize by the nudge amount" = "Đổi kích thước theo mức dịch chuyển nhẹ" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Đổi kích thước khung theo màn hình thiết bị (hoặc cửa sổ nếu không có thiết bị): các ràng buộc và bố cục tự động sẽ tự điều chỉnh" +"Resize to fit" = "Đổi kích thước vừa khít" +"Resizing" = "Đổi kích thước" +"Resolve" = "Giải quyết" +"Resolve change" = "Giải quyết thay đổi" +"Resolved" = "Đã giải quyết" +"Resources" = "Tài nguyên" +"Responsive" = "Đáp ứng" +"Restart" = "Bắt đầu lại" +"Restore" = "Khôi phục" +"Restore main component" = "Khôi phục thành phần chính" +"Restore this version" = "Khôi phục phiên bản này" +"Restored “{name}”" = "Đã khôi phục “{name}”" +"Reverse the order" = "Đảo ngược thứ tự" +"Reversed triangle" = "Tam giác ngược" +"Review and merge" = "Xem xét và hợp nhất" +"Ribbon" = "Dải ruy băng" +"Right" = "Phải" +"Right padding" = "Đệm phải" +"Right side" = "Cạnh phải" +"Right side width" = "Độ rộng cạnh phải" +"Rotate 90°" = "Xoay 90°" +"Rotate 90° right" = "Xoay 90° sang phải" +"Rotate to landscape" = "Xoay sang chiều ngang" +"Rotate to portrait" = "Xoay sang chiều dọc" +"Rotation" = "Góc xoay" +"Rotation jitter" = "Độ ngẫu nhiên góc xoay" +"Round" = "Tròn" +"Rounded" = "Bo tròn" +"Row" = "Hàng" +"Row gap" = "Khoảng hở hàng" +"Row sizes" = "Kích thước hàng" +"Row span" = "Số hàng gộp" +"Rows" = "Các hàng" +"Rows {count}" = "Hàng {count}" +"Rulers" = "Thước" +"Run" = "Chạy" +"Run {label}" = "Chạy {label}" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Giống như lớp" +"Saturation" = "Độ bão hòa" +"Save" = "Lưu" +"Save as" = "Lưu thành" +"Save as preset…" = "Lưu thành thiết lập sẵn…" +"Save files automatically" = "Tự động lưu tệp" +"Save version {key}" = "Lưu phiên bản {key}" +"Saved" = "Đã lưu" +"Saved {name}" = "Đã lưu {name}" +"Say something" = "Nhập nội dung trò chuyện" +"Scale" = "Tỷ lệ" +"Scatter" = "Phân tán" +"Scoping: where pickers offer it" = "Phạm vi: nơi biến xuất hiện trong bộ chọn" +"Screen" = "Màn lọc" +"Scroll behavior" = "Hành vi cuộn" +"Scroll to" = "Cuộn tới" +"Scroll to layer" = "Cuộn tới lớp" +"Scroll with parent" = "Cuộn theo lớp cha" +"Search" = "Tìm kiếm" +"Search actions, layers and tools" = "Tìm kiếm hành động, lớp và công cụ" +"Search assets" = "Tìm tài nguyên" +"Search comments" = "Tìm bình luận" +"Search files and templates" = "Tìm tệp và mẫu" +"Search fonts" = "Tìm phông chữ" +"Search layers" = "Tìm lớp" +"Search pages" = "Tìm trang" +"Search plugins" = "Tìm plugin" +"Search results" = "Kết quả tìm kiếm" +"Search shortcuts" = "Tìm phím tắt" +"Search templates" = "Tìm mẫu" +"Section" = "Phần" +"Section color" = "Màu phần" +"Sections" = "Các phần" +"See all templates" = "Xem tất cả mẫu" +"Select a frame and add a starting point to present it as a flow." = "Hãy chọn một khung và thêm điểm bắt đầu để trình chiếu thành một luồng." +"Select a frame to preview it" = "Chọn một khung để xem trước" +"Select a layer to see its properties and code" = "Chọn một lớp để xem thuộc tính và mã nguồn" +"Select all" = "Chọn tất cả" +"Select all with same" = "Chọn tất cả có cùng" +"Select layer" = "Chọn lớp" +"Select layers and they space themselves as their content changes" = "Chọn các lớp để chúng tự động giãn cách khi nội dung thay đổi" +"Select layers to apply the style to" = "Chọn các lớp để áp dụng kiểu" +"Select layers with this color" = "Chọn các lớp có màu này" +"Select overlapping shapes" = "Chọn các hình chồng lên nhau" +"Select points to edit their position, radius and mirroring" = "Chọn các điểm để chỉnh sửa vị trí, bán kính và đối xứng tay nắm" +"Select slot" = "Chọn ô chứa" +"Selection" = "Vùng chọn" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Khung chọn, {w} × {h} tại {x}, {y}: phím mũi tên để di chuyển, Shift+mũi tên hoặc {cmd}+mũi tên để đổi kích thước, Enter để chọn, Escape để hủy" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Khung chọn: phím mũi tên để di chuyển, Shift+mũi tên hoặc {cmd}+mũi tên để đổi kích thước, Enter để chọn, Escape để hủy" +"Selection colors" = "Màu của vùng chọn" +"Send backward" = "Đưa lùi một lớp" +"Send report" = "Gửi báo cáo" +"Send to back" = "Đưa xuống dưới cùng" +"Sep" = "Thg 9" +"Server address" = "Địa chỉ máy chủ" +"Server address (ws://host:47900/)" = "Địa chỉ máy chủ (ws://host:47900/)" +"Server for online sharing" = "Máy chủ chia sẻ trực tuyến" +"Set as default" = "Đặt làm mặc định" +"Set variable" = "Đặt biến" +"Set variable mode" = "Đặt chế độ biến" +"Set variable {name}" = "Đặt biến {name}" +"Set {name} to {value}" = "Đặt {name} thành {value}" +"Shadows" = "Vùng tối" +"Shape" = "Hình dạng" +"Shape builder" = "Tạo hình" +"Shape tools" = "Công cụ hình dạng" +"Share" = "Chia sẻ" +"Share this file" = "Chia sẻ tệp này" +"Share with a code" = "Chia sẻ bằng mã" +"Share: live session, export" = "Chia sẻ: phiên trực tiếp, xuất tệp" +"Shared" = "Đã chia sẻ" +"Shared: copy its code from the file's menu" = "Đã chia sẻ: sao chép mã từ trình đơn của tệp" +"Sharing a file needs the desktop app; here you can join one." = "Cần ứng dụng máy tính để chia sẻ tệp; tại đây bạn có thể tham gia một phiên." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Đang chia sẻ trên mạng cục bộ (chia sẻ trực tuyến đang tắt: chưa đặt máy chủ trong Tùy chọn)." +"Sharing on your network · {people}" = "Đang chia sẻ trên mạng của bạn · {people}" +"Sharing online · {people}" = "Đang chia sẻ trực tuyến · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Đã dừng chia sẻ: tệp vẫn còn trên máy chủ và chỉ mình bạn mở được" +"Show" = "Hiện" +"Show all templates" = "Hiện tất cả mẫu" +"Show as grid" = "Hiện dạng lưới" +"Show as list" = "Hiện dạng danh sách" +"Show autosaves" = "Hiện các bản tự động lưu" +"Show behind transparent areas" = "Hiện phía sau vùng trong suốt" +"Show comments (Shift+C)" = "Hiện bình luận (Shift+C)" +"Show device frame" = "Hiện khung thiết bị" +"Show dimensions on objects" = "Hiện kích thước trên đối tượng" +"Show hotspot hints" = "Hiện gợi ý vùng tương tác" +"Show in Explorer" = "Hiện trong Explorer" +"Show in Finder" = "Hiện trong Finder" +"Show in folder" = "Hiện trong thư mục" +"Show only this on the canvas" = "Chỉ hiện mục này trên vùng vẽ" +"Show open" = "Hiện mục đang mở" +"Show section contents" = "Hiện nội dung phần" +"Show this layer" = "Hiện lớp này" +"Show usage" = "Hiện nơi sử dụng" +"Show/Hide" = "Hiện/Ẩn" +"Show/Hide UI" = "Hiện/Ẩn giao diện" +"Side by side" = "Cạnh nhau" +"Sidebar" = "Thanh bên" +"Simplify stroke" = "Đơn giản hóa nét" +"Size" = "Kích thước" +"Size jitter" = "Độ ngẫu nhiên kích thước" +"Skip ink" = "Chừa nét chữ dưới" +"Slashed zero" = "Số 0 gạch chéo" +"Slice" = "Lát cắt" +"Slices" = "Các lát cắt" +"Slide in" = "Trượt vào" +"Slide out" = "Trượt ra" +"Slot" = "Ô chứa" +"Slot content" = "Nội dung ô chứa" +"Small capitals" = "Chữ hoa nhỏ" +"Small caps" = "Chữ hoa nhỏ" +"Smart animate" = "Hoạt ảnh thông minh" +"Snap to pixel grid" = "Bắt dính vào lưới pixel" +"Social and slides" = "Mạng xã hội và trang chiếu" +"Social media" = "Mạng xã hội" +"Social post sizes" = "Kích thước bài đăng mạng xã hội" +"Soft light" = "Ánh sáng dịu" +"Solid" = "Đặc" +"Some variants have the same property values: give each one a unique combination" = "Một số biến thể có cùng giá trị thuộc tính: hãy đặt cho mỗi biến thể một tổ hợp duy nhất" +"Sort" = "Sắp xếp" +"Sort by" = "Sắp xếp theo" +"Sort by date" = "Sắp xếp theo ngày" +"Sort by page" = "Sắp xếp theo trang" +"Sort by {column}" = "Sắp xếp theo {column}" +"Space around" = "Cách đều xung quanh" +"Space between" = "Cách đều giữa các mục" +"Space drag" = "Space kéo" +"Space evenly" = "Cách đều bằng nhau" +"Space+drag" = "Space+kéo" +"Spacing" = "Khoảng cách" +"Spam, stolen work, abuse…" = "Thư rác, đánh cắp tác phẩm, lạm dụng…" +"Splay" = "Xòe tia" +"Spotlight me" = "Chiếu tâm điểm vào tôi" +"Spread" = "Độ lan" +"Square" = "Hình vuông" +"Standard" = "Tiêu chuẩn" +"Standard ligatures" = "Chữ ghép tiêu chuẩn" +"Star" = "Ngôi sao" +"Stars" = "Các ngôi sao" +"Start" = "Bắt đầu" +"Start ascending from / stop descending at" = "Bắt đầu tăng dần từ / dừng giảm dần tại" +"Start from a blank canvas" = "Bắt đầu từ vùng vẽ trống" +"Start from a template" = "Bắt đầu từ một mẫu" +"Start on path" = "Điểm bắt đầu trên đường dẫn" +"Start on the Home screen" = "Khởi động tại màn hình Trang chủ" +"Start point" = "Điểm đầu" +"Start sharing" = "Bắt đầu chia sẻ" +"Start sharing with the Share button, then pass on the code it gives" = "Bắt đầu chia sẻ bằng nút Chia sẻ, sau đó gửi mã nhận được cho người khác" +"Starting to share…" = "Đang bắt đầu chia sẻ…" +"Status" = "Trạng thái" +"Sticky" = "Ghim cố định khi cuộn" +"Stop" = "Dừng" +"Stop following {name}" = "Dừng theo dõi {name}" +"Stop session" = "Dừng phiên" +"Stop sharing" = "Dừng chia sẻ" +"Stop spotlighting" = "Dừng chiếu tâm điểm" +"Stop {n} {hex}" = "Điểm dừng {n} {hex}" +"Stretch" = "Kéo giãn" +"Strikethrough" = "Gạch ngang chữ" +"String" = "Chuỗi" +"Stroke" = "Nét" +"Stroke style" = "Kiểu nét" +"Stroke weight" = "Độ dày nét" +"Strokes" = "Các nét" +"Style" = "Kiểu" +"Styles" = "Các kiểu" +"Stylistic set 1" = "Bộ kiểu chữ 1" +"Stylistic set 2" = "Bộ kiểu chữ 2" +"Stylistic set 3" = "Bộ kiểu chữ 3" +"Stylistic set 4" = "Bộ kiểu chữ 4" +"Stylistic set 5" = "Bộ kiểu chữ 5" +"Subtract" = "Trừ" +"Subtracts" = "Phép trừ" +"Suffix" = "Hậu tố" +"Suspend snapping while dragging" = "Tạm ngừng bắt dính khi đang kéo" +"Swap instance" = "Hoán đổi bản thể" +"Swap library: use another library's assets of the same names" = "Hoán đổi thư viện: dùng tài nguyên cùng tên từ một thư viện khác" +"Swap orientation" = "Đổi chiều ngang/dọc" +"Swap overlay" = "Hoán đổi lớp phủ" +"Swap to {name}" = "Hoán đổi sang {name}" +"Swap width and height" = "Đổi chiều rộng và chiều cao" +"Swapped instance" = "Bản thể đã hoán đổi" +"Swapped {n} assets" = { other = "Đã hoán đổi {n} tài nguyên" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { other = "Đã hoán đổi {n} tài nguyên; {missed} mục không khớp và vẫn dùng thư viện cũ" } +"Sweep" = "Góc quét" +"System" = "Hệ thống" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Bảng" +"Tablet" = "Máy tính bảng" +"Tabular numbers" = "Chữ số thẳng cột" +"Take the values from the first selected layer" = "Lấy giá trị từ lớp được chọn đầu tiên" +"Taper" = "Thuôn nhọn" +"Taper both ends" = "Thuôn nhọn cả hai đầu" +"Tell the server's operator what is wrong with it" = "Cho người vận hành máy chủ biết vấn đề của tệp này" +"Temperature" = "Nhiệt độ màu" +"Template" = "Mẫu" +"Templates" = "Các mẫu" +"Terms" = "Điều khoản" +"Text" = "Văn bản" +"Text align" = "Căn chỉnh văn bản" +"Text content" = "Nội dung văn bản" +"Text style" = "Kiểu văn bản" +"Text styles" = "Kiểu văn bản" +"Texts" = "Văn bản" +"Texture" = "Kết cấu" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Vùng vẽ xem trước màu theo sRGB; các tệp PNG, JPG và WebP xuất ra được gắn thẻ Display P3." +"The destination document is unavailable. Nothing was imported." = "Tài liệu đích không khả dụng. Không có nội dung nào được nhập." +"The destination document was closed. Nothing was imported." = "Tài liệu đích đã đóng. Không có nội dung nào được nhập." +"The destination page was removed. Nothing was imported." = "Trang đích đã bị xóa. Không có nội dung nào được nhập." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Tệp sẽ bị xóa khỏi máy chủ: không ai có thể mở hoặc tham gia tệp từ đó nữa, và thao tác này không thể hoàn tác. Các bản sao đang mở vẫn được giữ lại ở máy người dùng." +"The file now holds another library" = "Tệp hiện chứa một thư viện khác" +"The file now holds another library (\"{name}\")" = "Tệp hiện chứa một thư viện khác (\"{name}\")" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "Các tệp bạn đã chia sẻ hoặc xuất bản trực tuyến từ máy tính này. Chúng sẽ được lưu trên máy chủ cho đến khi bạn xóa." +"The link carries the session's secret: share it only with people you invite." = "Liên kết chứa khóa bí mật của phiên: chỉ chia sẻ với những người bạn mời." +"The name in your online files and on the server. The document inside keeps its own name." = "Tên hiển thị trong danh sách tệp trực tuyến của bạn và trên máy chủ. Tài liệu bên trong vẫn giữ tên riêng của nó." +"The selection uses several text styles: click to apply one" = "Vùng chọn đang dùng nhiều kiểu văn bản: nhấp để áp dụng một kiểu" +"The server deletes this file if nobody opens it before {date}." = "Máy chủ sẽ xóa tệp này nếu không ai mở trước {date}." +"The whole file" = "Toàn bộ tệp" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "Cửa sổ quá nhỏ cho mức {size}%, nên giao diện được hiển thị ở mức {drawn}%. Hãy phóng rộng cửa sổ để dùng mức {size}%." +"Theme" = "Giao diện" +"There's no destination for that action" = "Không có đích đến cho hành động đó" +"Thickness" = "Độ dày" +"This branch can't be reviewed: {e}" = "Không thể xem xét nhánh này: {e}" +"This branch was merged or archived." = "Nhánh này đã được hợp nhất hoặc lưu trữ." +"This computer only" = "Chỉ máy tính này" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Máy tính này ghi nhớ mã thông báo chủ sở hữu để bạn có thể quản lý tệp từ danh sách Trực tuyến ở màn hình Trang chủ. Hãy sao chép mã này nếu muốn quản lý tệp từ nơi khác: đây là mật khẩu của tệp và sẽ không hiển thị lại." +"This condition is edited as an expression" = "Điều kiện này được chỉnh sửa dưới dạng biểu thức" +"This file" = "Tệp này" +"This file has unsaved changes" = "Tệp này có thay đổi chưa lưu" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Tệp này đang ở trên {server} và chỉ mình bạn mở được. Hãy chia sẻ để người khác tham gia bằng mã mới." +"This file is too large to join here (up to {mb} MB)" = "Tệp này quá lớn để tham gia tại đây (tối đa {mb} MB)" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Tệp này sử dụng phông chữ chưa được cài đặt trên máy tính này. Văn bản sẽ hiển thị bằng phông chữ dự phòng cho đến khi bạn cài đặt chúng." +"This frame is new on the branch." = "Khung này là mới trên nhánh." +"This frame isn't on the branch." = "Khung này không có trên nhánh." +"This server is open to the public: no key needed." = "Máy chủ này mở công khai: không cần khóa." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Máy chủ này yêu cầu khóa: hãy thêm ?key=… vào địa chỉ (`figcraft-server key` sẽ in khóa trên máy chủ)." +"This server takes files only with its key: see Advanced." = "Máy chủ này chỉ nhận tệp khi có khóa: xem phần Nâng cao." +"Tidy up" = "Sắp xếp gọn" +"Tile" = "Lặp ô" +"Tile type" = "Kiểu lặp ô" +"Tint" = "Sắc thái" +"Title" = "Tiêu đề" +"Title case" = "Viết hoa đầu từ" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Để chia sẻ thư viện này với nhóm của bạn, hãy xuất bản tệp lên máy chủ (Chia sẻ ▸ Xuất bản lên máy chủ…), sau đó xuất bản thư viện tại đó." +"Toggle" = "Bật/tắt" +"Toggle light/dark theme" = "Chuyển đổi giao diện sáng/tối" +"Toggle visibility" = "Bật/tắt hiển thị" +"Tools" = "Công cụ" +"Top" = "Trên" +"Top & bottom" = "Trên & dưới" +"Top left radius" = "Bán kính góc trên trái" +"Top padding" = "Đệm trên" +"Top right radius" = "Bán kính góc trên phải" +"Transform" = "Biến đổi" +"Triangle arrow" = "Mũi tên tam giác" +"Trigger" = "Kích hoạt" +"True" = "Đúng" +"Truncate text" = "Cắt bớt văn bản" +"Truncate with an ellipsis" = "Cắt bớt bằng dấu ba chấm" +"Try again" = "Thử lại" +"Try another search or filter." = "Hãy thử từ khóa hoặc bộ lọc khác." +"Try another search, or browse templates." = "Hãy thử tìm kiếm khác, hoặc duyệt các mẫu." +"Try another word, or look at every kind." = "Hãy thử từ khác, hoặc xem tất cả các loại." +"Turn a layer into a component, reuse it, and change every copy at once" = "Biến một lớp thành thành phần, tái sử dụng và cập nhật mọi bản sao cùng lúc" +"Two digits" = "Hai chữ số" +"Type" = "Loại" +"Type settings" = "Thiết lập kiểu chữ" +"Type the join code you were given, or paste an invite link." = "Nhập mã tham gia bạn được cấp, hoặc dán liên kết mời." +"Typography" = "Kiểu chữ" +"Underline" = "Gạch chân" +"Undo takes back only your own changes; what others changed since stays." = "Hoàn tác chỉ quay lại các thay đổi của riêng bạn; những gì người khác đã thay đổi vẫn giữ nguyên." +"Ungroup" = "Bỏ nhóm" +"Uniform" = "Đồng đều" +"Union" = "Hợp" +"Unions" = "Phép hợp" +"Unknown — treated as sRGB" = "Không rõ — được xử lý như sRGB" +"Unread" = "Chưa đọc" +"Unresolve" = "Mở lại bình luận" +"Unsaved changes" = "Thay đổi chưa lưu" +"Unsaved changes in {names}. Close the window and lose them?" = "Có thay đổi chưa lưu trong {names}. Đóng cửa sổ và hủy bỏ các thay đổi này?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Có thay đổi chưa lưu: hãy lưu trước ({save}), hoặc giữ {alt} khi nhấp đóng thẻ để hủy bỏ" +"Unsaved changes: save first, or close again with {key} to discard" = "Có thay đổi chưa lưu: hãy lưu trước, hoặc đóng lại lần nữa bằng {key} để hủy bỏ" +"Up" = "Lên" +"Update" = "Cập nhật" +"Update from main" = "Cập nhật từ nhánh chính" +"Update from selection" = "Cập nhật từ vùng chọn" +"Update plugin" = "Cập nhật plugin" +"Updated {components} and {styles}" = "Đã cập nhật {components} và {styles}" +"Updated {name}" = "Đã cập nhật {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Việc cập nhật trình điều khiển đồ họa thường sẽ khắc phục được lỗi. Để chỉ sử dụng OpenGL, hãy khởi chạy FigCraft từ Command Prompt bằng:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Việc cập nhật trình điều khiển đồ họa thường sẽ khắc phục được lỗi. Biến WGPU_BACKEND (vulkan, gl, metal) cho phép thử một API đồ họa khác." +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Tải tệp này lên máy chủ FigCraft và giữ thẻ này đồng bộ với máy chủ. Bạn sẽ nhận được một liên kết chỉnh sửa và một liên kết xem." +"Uppercase" = "Chữ hoa" +"Use a fixed value" = "Dùng giá trị cố định" +"Use an expression, e.g. count + 1" = "Dùng biểu thức, ví dụ: count + 1" +"Use as mask" = "Dùng làm mặt nạ" +"Use auto layout" = "Dùng bố cục tự động" +"Use number keys for opacity" = "Dùng phím số để chỉnh độ mờ" +"Use the .figcraft or .fig extension." = "Hãy dùng phần mở rộng .figcraft hoặc .fig." +"Use the layer's fill" = "Dùng màu tô của lớp" +"Use your browser's zoom to change the interface size." = "Hãy dùng tính năng thu phóng của trình duyệt để thay đổi kích thước giao diện." +"Used in {files} · {instances}" = "Được dùng trong {files} · {instances}" +"Username" = "Tên người dùng" +"Uses variables: each layer shows them in its own mode" = "Có sử dụng biến: mỗi lớp hiển thị giá trị theo chế độ riêng của lớp đó" +"Value" = "Giá trị" +"Variable" = "Biến" +"Variable collections" = "Bộ sưu tập biến" +"Variable modes" = "Chế độ biến" +"Variable width" = "Độ rộng biến thiên" +"Variables" = "Các biến" +"Variables JSON…" = "JSON biến…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Biến lưu trữ các giá trị tái sử dụng (màu sắc, số, chuỗi, boolean) theo các chế độ như Sáng và Tối." +"Variant" = "Biến thể" +"Vector" = "Vector" +"Vectors" = "Vector" +"Version" = "Phiên bản" +"Version history" = "Lịch sử phiên bản" +"Version {version}" = "Phiên bản {version}" +"Version {v} · {components} · {styles} · {variables}" = "Phiên bản {v} · {components} · {styles} · {variables}" +"Vertical" = "Dọc" +"Vertical align" = "Căn dọc" +"Vertical hex" = "Lục giác dọc" +"Vertical padding" = "Đệm dọc" +"Vertical spacing" = "Khoảng cách dọc" +"Vertical trim" = "Cắt gọn khoảng đệm dọc" +"View" = "Xem" +"View link" = "Liên kết xem" +"View link copied" = "Đã sao chép liên kết xem" +"View only" = "Chỉ xem" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Chỉ xem: bạn thấy mọi thay đổi theo thời gian thực nhưng không thể chỉnh sửa. Hãy rời phiên để giữ lại một bản sao có thể chỉnh sửa." +"Viewing “{name}”" = "Đang xem “{name}”" +"Violet" = "Tím" +"Visibility" = "Hiển thị" +"Visible" = "Hiển thị" +"Vivid" = "Rực rỡ" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "Biến WGPU_BACKEND đang được đặt để chỉ định API đồ họa; hãy bỏ đặt biến này để FigCraft tự chọn." +"Warm" = "Ấm" +"Watch" = "Đồng hồ" +"Wavy" = "Lượn sóng" +"Web" = "Web" +"WebSocket URL" = "URL WebSocket" +"WebSocket URL copied" = "Đã sao chép URL WebSocket" +"Websites" = "Trang web" +"Wedge" = "Hình nêm" +"Weight" = "Độ dày" +"What changed since it was marked ready for dev" = "Những thay đổi kể từ khi được đánh dấu sẵn sàng cho dev" +"What is wrong with this file? The server's operator gets your report." = "Tệp này có vấn đề gì? Người vận hành máy chủ sẽ nhận được báo cáo của bạn." +"What it's for" = "Mục đích sử dụng" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Không rõ trạng thái trước khi thay đổi: khung đã thay đổi trước khi FigCraft lưu bản sao của các khung sẵn sàng cho dev. Hãy giải quyết thay đổi (hoặc đánh dấu sẵn sàng lại) để so sánh từ nay về sau." +"What you try here changes this preview and the code below, not the file" = "Những gì bạn thử tại đây chỉ làm thay đổi bản xem trước này và đoạn mã bên dưới, không thay đổi tệp" +"While hovering" = "Khi di chuột qua" +"While pressing" = "Khi nhấn giữ" +"White" = "Trắng" +"Who runs it, its terms and how long it keeps files" = "Đơn vị vận hành, điều khoản và thời gian lưu giữ tệp" +"Why it changed" = "Lý do thay đổi" +"Widget" = "Tiện ích" +"Widgets" = "Các tiện ích" +"Width" = "Chiều rộng" +"Width point" = "Điểm độ rộng" +"Width profile" = "Biên dạng độ rộng" +"Wireframe kit" = "Bộ khung dây (Wireframe)" +"With the layers' export settings" = "Theo thiết lập xuất của các lớp" +"Wrap" = "Ngắt dòng" +"Wrap in new section" = "Bọc trong phần mới" +"Yellow" = "Vàng" +"Yesterday" = "Hôm qua" +"You" = "Bạn" +"You'll get the shared file as a new tab and edit it together." = "Bạn sẽ nhận được tệp chia sẻ trong một thẻ mới và cùng nhau chỉnh sửa." +"You're spotlighting: everyone follows your view" = "Bạn đang chiếu tâm điểm: mọi người đang theo dõi khung nhìn của bạn" +"Your copy of the file stays open" = "Bản sao tệp của bạn vẫn mở" +"Your copy stays open; the file stays on the server" = "Bản sao của bạn vẫn mở; tệp vẫn được lưu trên máy chủ" +"Your name" = "Tên của bạn" +"Your name in comments" = "Tên của bạn trong bình luận" +"Your system asks for reduced motion: animations stay off." = "Hệ thống của bạn yêu cầu giảm chuyển động: các hoạt ảnh sẽ được tắt." +"Z" = "Z" +"Z to A" = "Z đến A" +"Zoom" = "Thu phóng" +"Zoom around the pointer" = "Thu phóng quanh con trỏ" +"Zoom device to fill window" = "Thu phóng thiết bị lấp đầy cửa sổ" +"Zoom in" = "Phóng to" +"Zoom out" = "Thu nhỏ" +"Zoom to 100%" = "Thu phóng về 100%" +"Zoom to 200%" = "Thu phóng về 200%" +"Zoom to 50%" = "Thu phóng về 50%" +"Zoom to fit" = "Thu phóng vừa khít" +"Zoom to selection" = "Thu phóng vừa vùng chọn" +"Zoom tool: click to zoom in, drag an area" = "Công cụ thu phóng: nhấp để phóng to, kéo chọn vùng" +"Zoom tool: zoom out" = "Công cụ thu phóng: thu nhỏ" +"and {n} more" = { other = "và {n} mục nữa" } +"bleed marks" = "dấu tràn lề" +"by {author}" = "bởi {author}" +"call any command its access allows" = "gọi bất kỳ lệnh nào mà quyền truy cập cho phép" +"call only: {commands}" = "chỉ gọi: {commands}" +"can edit the document" = "có thể chỉnh sửa tài liệu" +"conditional prototype actions nested too deeply were skipped" = "đã bỏ qua các hành động nguyên mẫu có điều kiện lồng quá sâu" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "đã bỏ qua các hành động nguyên mẫu có điều kiện không thể tính giá trị" +"connector label background was skipped: style data is missing" = "đã bỏ qua nền nhãn đường nối: thiếu dữ liệu kiểu" +"connector label was skipped: background bounds are invalid" = "đã bỏ qua nhãn đường nối: khung giới hạn nền không hợp lệ" +"connector label was skipped: background transform is invalid" = "đã bỏ qua nhãn đường nối: phép biến đổi nền không hợp lệ" +"connector label was skipped: composed text transform is invalid" = "đã bỏ qua nhãn đường nối: phép biến đổi văn bản tổng hợp không hợp lệ" +"connector label was skipped: derived background bounds are missing" = "đã bỏ qua nhãn đường nối: thiếu khung giới hạn nền suy dẫn" +"connector label was skipped: derived text bounds are missing" = "đã bỏ qua nhãn đường nối: thiếu khung giới hạn văn bản suy dẫn" +"connector label was skipped: text bounds are invalid" = "đã bỏ qua nhãn đường nối: khung giới hạn văn bản không hợp lệ" +"connector label was skipped: text style data is missing" = "đã bỏ qua nhãn đường nối: thiếu dữ liệu kiểu văn bản" +"connector label was skipped: text transform is invalid" = "đã bỏ qua nhãn đường nối: phép biến đổi văn bản không hợp lệ" +"connector labels were skipped" = "đã bỏ qua các nhãn đường nối" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "các lớp đường nối được nhập dưới dạng khung/vector tĩnh kèm theo nhãn nếu có; liên kết động ở hai đầu mút không được giữ lại" +"connector snapshot vector data is missing; connector was left empty" = "thiếu dữ liệu vector tĩnh của đường nối; đường nối được để trống" +"connectors whose ends can't be placed are empty" = "các đường nối không thể đặt hai đầu mút sẽ được để trống" +"crop marks" = "dấu xén" +"damaged vector geometry was skipped" = "đã bỏ qua hình học vector bị hỏng" +"damaged vector networks import as outlines" = "mạng vector bị hỏng được nhập dưới dạng đường viền" +"development: {path}" = "phát triển: {path}" +"document children other than pages were skipped" = "đã bỏ qua các mục con của tài liệu không phải là trang" +"duplicate grid track sizing entries use equal tracks" = "các mục kích thước dải lưới trùng lặp sẽ dùng kích thước dải bằng nhau" +"e.g. count + 1" = "ví dụ: count + 1" +"e.g. count >= 3 and not done" = "ví dụ: count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "các đường nối gấp khúc và cong không có đường dẫn đã lưu được nhập thành đường thẳng" +"entries without a node type were skipped" = "đã bỏ qua các mục không có loại nút" +"figcraft:// links from a browser, chat or email open this app and join the session." = "Liên kết figcraft:// từ trình duyệt, ứng dụng trò chuyện hoặc email sẽ mở ứng dụng này và tham gia phiên." +"filled areas of a vector with their own fill use the layer's fill" = "các vùng tô riêng trong một vector sẽ dùng màu tô của lớp" +"from {server}" = "từ {server}" +"gamepad triggers without exactly one known controller button were skipped" = "đã bỏ qua các kích hoạt tay cầm không có đúng một nút điều khiển đã biết" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "danh sách dải lưới vượt quá 1000 mục; các dải sẽ dùng kích thước đều nhau mặc định" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "kích thước dải lưới không được hỗ trợ hoặc sai định dạng; các dải bị ảnh hưởng sẽ dùng kích thước đều nhau" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "danh sách kích thước dải lưới vượt quá 1000 mục; các dải sẽ dùng kích thước đều nhau mặc định" +"hidden" = "đã ẩn" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "bộ lọc hình ảnh không rõ ràng do có cả hai vùng chứa bộ lọc; đã bỏ qua chuyển đổi bộ lọc" +"image filters use FigCraft's approximate renderer" = "bộ lọc hình ảnh sử dụng bộ kết xuất xấp xỉ của FigCraft" +"instance swap properties whose default component isn't in the file were skipped" = "đã bỏ qua các thuộc tính hoán đổi bản thể có thành phần mặc định không nằm trong tệp" +"instances inside their own main component were imported as frames" = "các bản thể nằm bên trong chính thành phần chính của chúng đã được nhập thành khung" +"instances of components that aren't in the file show no layers" = "các bản thể của thành phần không có trong tệp sẽ không hiển thị lớp nào" +"instances of components that aren't in the file were imported as frames" = "các bản thể của thành phần không có trong tệp đã được nhập thành khung" +"instances that would nest a component inside itself were imported as frames" = "các bản thể gây lồng thành phần vào chính nó đã được nhập thành khung" +"instances without a main component were imported as empty frames" = "các bản thể không có thành phần chính đã được nhập thành khung trống" +"invalid internal .fig text path data was ignored" = "đã bỏ qua dữ liệu đường dẫn văn bản .fig nội bộ không hợp lệ" +"invalid or oversized vector networks import as outlines" = "mạng vector không hợp lệ hoặc quá lớn được nhập dưới dạng đường viền" +"invalid or oversized vector path data was skipped" = "đã bỏ qua dữ liệu đường dẫn vector không hợp lệ hoặc quá lớn" +"just now" = "vừa xong" +"key triggers without a supported key were skipped" = "đã bỏ qua các kích hoạt phím không có phím được hỗ trợ" +"key up triggers import as key presses" = "kích hoạt nhả phím được nhập thành nhấn phím" +"layers without a position were placed at their parent's origin" = "các lớp không có vị trí đã được đặt tại gốc tọa độ của lớp cha" +"layers without a type were skipped" = "đã bỏ qua các lớp không có loại" +"legacy image filter container is unsupported; filter conversion was skipped" = "vùng chứa bộ lọc hình ảnh kiểu cũ không được hỗ trợ; đã bỏ qua chuyển đổi bộ lọc" +"locked" = "đã khóa" +"malformed image filters container was skipped" = "đã bỏ qua vùng chứa bộ lọc hình ảnh bị lỗi định dạng" +"malformed image paintFilter container was skipped" = "đã bỏ qua vùng chứa paintFilter hình ảnh bị lỗi định dạng" +"malformed slot content assignment was skipped" = "đã bỏ qua phép gán nội dung ô chứa bị lỗi định dạng" +"no bleed" = "không tràn lề" +"nobody else here yet" = "chưa có ai khác ở đây" +"non-default slot settings are not represented and were skipped" = "các thiết lập ô chứa không mặc định không được biểu diễn và đã bị bỏ qua" +"now" = "bây giờ" +"only in a text style or variable" = "chỉ trong kiểu văn bản hoặc biến" +"or" = "hoặc" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "khớp nối nét tại điểm được giữ lại; nét đứt hoặc nét có độ rộng biến thiên sẽ kết xuất theo khớp nối của lớp" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "không thể phân giải khóa thành phần ưu tiên của ô chứa thành nút trong tệp nên đã bỏ qua" +"prototype actions of unknown types were skipped" = "đã bỏ qua các hành động nguyên mẫu thuộc loại không xác định" +"prototype actions without a destination in the file were skipped" = "đã bỏ qua các hành động nguyên mẫu không có đích đến trong tệp" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "đã bỏ qua các biểu thức nguyên mẫu dùng biến không có trong JSON biến" +"prototype expressions with unsupported functions were skipped" = "đã bỏ qua các biểu thức nguyên mẫu có hàm không được hỗ trợ" +"prototype interactions without supported actions were skipped" = "đã bỏ qua các tương tác nguyên mẫu không có hành động được hỗ trợ" +"prototype navigations of unknown types were skipped" = "đã bỏ qua các điều hướng nguyên mẫu thuộc loại không xác định" +"prototype transitions of unknown types play instantly" = "các chuyển cảnh nguyên mẫu thuộc loại không xác định sẽ phát tức thì" +"prototype triggers of unknown types were skipped" = "đã bỏ qua các kích hoạt nguyên mẫu thuộc loại không xác định" +"put a widget on the canvas" = "đặt một tiện ích lên vùng vẽ" +"read and edit the document" = "đọc và chỉnh sửa tài liệu" +"read the document" = "đọc tài liệu" +"reads the document" = "đọc tài liệu" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "đã bỏ qua các hành động đặt biến trên biến không có trong JSON biến" +"set-variable actions with values that can't be evaluated were skipped" = "đã bỏ qua các hành động đặt biến có giá trị không thể tính toán" +"set-variable actions with values that don't fit the variable were skipped" = "đã bỏ qua các hành động đặt biến có giá trị không khớp kiểu biến" +"set-variable actions without a value were skipped" = "đã bỏ qua các hành động đặt biến không có giá trị" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "đã bỏ qua các hành động đặt chế độ biến trên bộ sưu tập hoặc chế độ không có trong JSON biến" +"slot content assignment target is missing or is not slot content" = "đích gán nội dung ô chứa bị thiếu hoặc không phải là nội dung ô chứa" +"slot content assignment uses the empty GUID sentinel and was skipped" = "phép gán nội dung ô chứa dùng GUID rỗng nên đã bị bỏ qua" +"slot layers without a matching slot property were imported as frames" = "các lớp ô chứa không có thuộc tính ô chứa tương ứng đã được nhập thành khung" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "một số điểm vector tham chiếu đến thiết lập điểm bị thiếu trong tệp; chúng sẽ dùng khớp nối của lớp và góc thường" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "một số đoạn vector tham chiếu đến thiết lập kiểu bị thiếu trong tệp; chúng sẽ dùng nét của lớp" +"stale or invalid grid track sizing entries were ignored" = "đã bỏ qua các mục kích thước dải lưới cũ hoặc không hợp lệ" +"stars and polygons without point counts or geometry use the default point count" = "ngôi sao và đa giác không có số đỉnh hoặc hình học sẽ dùng số đỉnh mặc định" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "thiết lập kiểu của một số đoạn vector không được hỗ trợ; các đoạn đó sẽ dùng nét của lớp" +"text path underline or strikethrough decoration is not rendered" = "không kết xuất gạch chân hoặc gạch ngang chữ đối với văn bản trên đường dẫn" +"text style overrides refer to missing table entries" = "các ghi đè kiểu văn bản tham chiếu đến mục bảng bị thiếu" +"the file has no pages" = "tệp không có trang nào" +"the plugin" = "plugin" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "JSON biến không có `variableCollections` (cần phản hồi biến cục bộ) nên đã bị bỏ qua" +"this session only" = "chỉ trong phiên này" +"unknown component property types were skipped" = "đã bỏ qua các loại thuộc tính thành phần không xác định" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "đã bỏ qua các bí danh biến trỏ tới biến không có trong JSON biến (hoặc khác kiểu)" +"variable bindings to properties the layer doesn't have were skipped" = "đã bỏ qua các gắn kết biến vào thuộc tính mà lớp không có" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "đã bỏ qua các gắn kết biến trỏ tới biến không có trong JSON biến (biến thư viện)" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "đã bỏ qua các gắn kết biến: JSON tệp không có định nghĩa biến (hãy truyền JSON biến cục bộ vào `variables`, hoặc nhúng `meta` của nó cạnh JSON tệp)" +"variable bindings whose variable type doesn't fit the property were skipped" = "đã bỏ qua các gắn kết biến có kiểu biến không phù hợp với thuộc tính" +"variable collections without modes were skipped" = "đã bỏ qua các bộ sưu tập biến không có chế độ" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "đã bỏ qua các tra cứu chế độ biến trong biểu thức nguyên mẫu không phải là một biến và một chế độ của nó" +"variable modes of collections that aren't in the file were skipped" = "đã bỏ qua các chế độ biến của bộ sưu tập không có trong tệp" +"variable modes of collections that aren't in the variables JSON were skipped" = "đã bỏ qua các chế độ biến của bộ sưu tập không có trong JSON biến" +"variable values that don't match their type (or composed colours) were skipped" = "đã bỏ qua các giá trị biến không khớp kiểu (hoặc màu tổng hợp)" +"variables of unknown types were skipped" = "đã bỏ qua các biến thuộc kiểu không xác định" +"variables whose collection isn't in the variables JSON were skipped" = "đã bỏ qua các biến có bộ sưu tập không nằm trong JSON biến" +"variables without readable values were skipped" = "đã bỏ qua các biến không có giá trị đọc được" +"vector points with unknown stroke joins use the layer join" = "các điểm vector có khớp nối nét không xác định sẽ dùng khớp nối của lớp" +"vectors without geometry are empty" = "các vector không có dữ liệu hình học sẽ để trống" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "các vector không có dữ liệu hình học sẽ để trống (hãy xuất JSON tệp với geometry=paths)" +"very long text was cut to 1,048,576 characters" = "văn bản quá dài đã được cắt còn 1.048.576 ký tự" +"very long vector paths were skipped" = "đã bỏ qua các đường dẫn vector quá dài" +"video actions are not supported and were skipped" = "hành động video không được hỗ trợ và đã bị bỏ qua" +"video triggers are not supported and were skipped" = "kích hoạt video không được hỗ trợ và đã bị bỏ qua" +"yesterday" = "hôm qua" +"{instances} in {files}" = "{instances} trong {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: bản dịch nháp, đang chờ người bản ngữ hiệu đính." +"{length} bleed" = "tràn lề {length}" +"{month} {day}, {year}" = "{day} {month}, {year}" +"{name}\nClick to apply to the selection" = "{name}\nNhấp để áp dụng cho vùng chọn" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nKéo vào vùng vẽ, hoặc nhấp để đặt ở giữa khung nhìn.\nGiữ Alt (Option) khi thả vào một bản thể để hoán đổi." +"{name} (from {library})" = "{name} (từ {library})" +"{name} (you)" = "{name} (bạn)" +"{name} ({layers} layers)" = "{name} ({layers} lớp)" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, mặc định {default}. Nhấp đúp vào thanh trượt để quay lại giá trị của kiểu; nhấn Escape khi đang kéo để hủy." +"{name} cleared from the list" = "Đã xóa {name} khỏi danh sách" +"{name} from {path}" = "{name} từ {path}" +"{name} is forgotten on this computer; the file stays on {server}" = "Đã quên {name} trên máy tính này; tệp vẫn còn trên {server}" +"{name} is on the server." = "{name} đang ở trên máy chủ." +"{name} is spotlighting" = "{name} đang chiếu tâm điểm" +"{name} is too large to open in the browser (over 512 MB)." = "{name} quá lớn để mở trong trình duyệt (trên 512 MB)." +"{name} was removed from the server" = "Đã xóa {name} khỏi máy chủ" +"{name} · deleted on main" = "{name} · đã xóa trên nhánh chính" +"{name} · deleted on the branch" = "{name} · đã xóa trên nhánh" +"{name} · not published" = "{name} · chưa xuất bản" +"{name} · on a server" = "{name} · trên máy chủ" +"{name} · published version {v}" = "{name} · đã xuất bản phiên bản {v}" +"{name} · version {v} on the server" = "{name} · phiên bản {v} trên máy chủ" +"{name}, in a live session" = "{name}, trong phiên trực tiếp" +"{name}, online, open only to you" = "{name}, trực tuyến, chỉ mình bạn mở được" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, đã chọn" +"{n} added" = { other = "{n} đã thêm" } +"{n} autosave versions" = { other = "{n} phiên bản tự động lưu" } +"{n} changed" = { other = "{n} đã thay đổi" } +"{n} columns" = { other = "{n} cột" } +"{n} components" = { other = "{n} thành phần" } +"{n} components and styles have newer versions" = { other = "{n} thành phần và kiểu có phiên bản mới hơn" } +"{n} conflicts to resolve" = { other = "{n} xung đột cần giải quyết" } +"{n} days ago" = { other = "{n} ngày trước" } +"{n} files" = { other = "{n} tệp" } +"{n} hours ago" = { other = "{n} giờ trước" } +"{n} instances" = { other = "{n} bản thể" } +"{n} layers" = { other = "{n} lớp" } +"{n} layers as one file" = { other = "{n} lớp thành một tệp" } +"{n} layers selected" = { other = "Đã chọn {n} lớp" } +"{n} layers · {parts}" = { other = "{n} lớp · {parts}" } +"{n} matches" = { other = "{n} kết quả khớp" } +"{n} minutes ago" = { other = "{n} phút trước" } +"{n} months ago" = { other = "{n} tháng trước" } +"{n} more notes omitted from this report" = { other = "{n} ghi chú khác đã được lược bớt khỏi báo cáo này" } +"{n} of {total}" = "{n} / {total}" +"{n} people joined" = { other = "{n} người đã tham gia" } +"{n} preferred components" = { other = "{n} thành phần ưu tiên" } +"{n} ready" = { other = "{n} sẵn sàng" } +"{n} removed" = { other = "{n} đã xóa" } +"{n} replies" = { other = "{n} phản hồi" } +"{n} rows" = { other = "{n} hàng" } +"{n} styles" = { other = "{n} kiểu" } +"{n} text layers" = { other = "{n} lớp văn bản" } +"{n} variables" = { other = "{n} biến" } +"{n} weeks ago" = { other = "{n} tuần trước" } +"{n} years ago" = { other = "{n} năm trước" } +"{n}d" = "{n}ng" +"{n}h" = "{n}g" +"{n}m" = "{n}ph" +"{n}w" = "{n}t" +"{page}, nothing selected" = "{page}, chưa chọn gì" +"{percent}%" = "{percent}%" +"{profile} (current)" = "{profile} (hiện tại)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} là giá trị thuộc tính; {property ? \"a\" : \"b\"} chọn một chuỗi. Các {…} khác được giữ nguyên như đã viết." +"{server} can't say what became of your files (it is older than this app)." = "{server} không thể cho biết trạng thái các tệp của bạn (máy chủ cũ hơn ứng dụng này)." +"{server} · No longer on the server" = "{server} · Không còn trên máy chủ" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Đã mở {date}" +"{tool} hold {key}" = "{tool} giữ {key}" +"{value} isn't a colour" = "{value} không phải là một màu" +"{when} · edited" = "{when} · đã chỉnh sửa" +"←" = "←" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"→, Space or N" = "→, Space hoặc N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ mũi tên" +"⌘ click" = "⌘ nhấp" +"⌘ scroll" = "⌘ cuộn" +"⌥ drag" = "⌥ kéo" + +[commands] +"align.bottom" = "Căn dưới" +"align.hcenter" = "Căn tâm ngang" +"align.left" = "Căn trái" +"align.right" = "Căn phải" +"align.top" = "Căn trên" +"align.vcenter" = "Căn tâm dọc" +"annotation.add" = "Thêm chú thích" +"annotation.addCategory" = "Thêm danh mục chú thích" +"annotation.categories" = "Danh mục chú thích" +"annotation.edit" = "Chỉnh sửa chú thích" +"annotation.editCategory" = "Chỉnh sửa danh mục chú thích" +"annotation.list" = "Danh sách chú thích" +"annotation.remove" = "Xóa chú thích" +"annotation.removeCategory" = "Xóa danh mục chú thích" +"app.actions" = "Hành động" +"app.preferences" = "Tùy chọn" +"arrange.back" = "Đưa xuống dưới cùng" +"arrange.backward" = "Đưa lùi một lớp" +"arrange.forward" = "Đưa tiến một lớp" +"arrange.front" = "Đưa lên trên cùng" +"arrange.packH" = "Xếp sát ngang" +"arrange.packV" = "Xếp sát dọc" +"arrange.reorder" = "Sắp xếp lại vùng chọn" +"arrange.roundToPixels" = "Làm tròn theo pixel" +"arrange.spacing" = "Khoảng cách" +"arrange.tidy" = "Sắp xếp gọn" +"bool.exclude" = "Loại trừ vùng chọn" +"bool.flatten" = "Làm phẳng nhóm boolean" +"bool.intersect" = "Giao vùng chọn" +"bool.subtract" = "Trừ vùng chọn" +"bool.union" = "Hợp vùng chọn" +"branch.archive" = "Lưu trữ nhánh" +"branch.create" = "Tạo nhánh" +"branch.list" = "Danh sách nhánh" +"branch.merge" = "Hợp nhất nhánh" +"branch.open" = "Mở nhánh" +"branch.review" = "Xem xét nhánh" +"branch.updateFromMain" = "Cập nhật từ nhánh chính" +"codeConnect.export" = "Xuất kết nối mã" +"codeConnect.import" = "Nhập kết nối mã" +"codeConnect.remove" = "Ngắt kết nối mã" +"codeConnect.set" = "Kết nối với mã" +"collab.cursor" = "Hiện con trỏ tại" +"collab.cursorChat" = "Trò chuyện con trỏ" +"collab.diff" = "Hiện thay đổi đồng bộ" +"collab.files" = "Tệp trực tuyến của bạn" +"collab.follow" = "Theo dõi" +"collab.forgetFile" = "Quên tệp trực tuyến" +"collab.host" = "Bắt đầu phiên trực tiếp" +"collab.join" = "Tham gia phiên trực tiếp" +"collab.leave" = "Rời phiên trực tiếp" +"collab.manageFile" = "Quản lý tệp đã xuất bản" +"collab.openFile" = "Mở tệp trực tuyến" +"collab.peers" = "Những người trong tệp này" +"collab.publish" = "Xuất bản lên máy chủ" +"collab.recoverFile" = "Khôi phục tệp trực tuyến" +"collab.registerLinks" = "Mở liên kết mời trong FigCraft" +"collab.removePerson" = "Xóa khỏi phiên" +"collab.report" = "Báo cáo tệp cho người vận hành máy chủ" +"collab.serverInfo" = "Thông tin máy chủ" +"collab.share" = "Chia sẻ bằng mã tham gia" +"collab.spotlight" = "Chiếu tâm điểm vào tôi" +"collab.stop" = "Dừng phiên trực tiếp" +"command.batch" = "Chạy nhiều lệnh" +"comment.add" = "Thêm bình luận" +"comment.delete" = "Xóa bình luận" +"comment.edit" = "Chỉnh sửa bình luận" +"comment.link" = "Sao chép liên kết đến bình luận" +"comment.list" = "Danh sách bình luận" +"comment.markRead" = "Đánh dấu bình luận đã đọc" +"comment.move" = "Di chuyển bình luận" +"comment.next" = "Bình luận tiếp theo" +"comment.open" = "Mở bình luận" +"comment.people" = "Người trong bình luận" +"comment.react" = "Thả cảm xúc cho bình luận" +"comment.reply" = "Trả lời bình luận" +"comment.resolve" = "Giải quyết bình luận" +"comment.zoom" = "Thu phóng đến bình luận" +"component.addProperty" = "Thêm thuộc tính thành phần" +"component.addVariant" = "Thêm biến thể" +"component.bindProperty" = "Gắn lớp vào thuộc tính" +"component.combineVariants" = "Kết hợp thành các biến thể" +"component.create" = "Tạo thành phần" +"component.createMultiple" = "Tạo nhiều thành phần" +"component.deleteProperty" = "Xóa thuộc tính thành phần" +"component.detach" = "Tách bản thể" +"component.editProperty" = "Chỉnh sửa thuộc tính thành phần" +"component.exposeInstance" = "Hiển thị bản thể lồng nhau" +"component.goToMain" = "Đi tới thành phần chính" +"component.hideWhenPublishing" = "Ẩn khi xuất bản" +"component.list" = "Danh sách thành phần" +"component.makeSlot" = "Tạo ô chứa" +"component.playground" = "Sân thử nghiệm thành phần" +"component.properties" = "Thuộc tính thành phần" +"component.pushChanges" = "Đẩy thay đổi lên thành phần chính" +"component.removeSlot" = "Xóa ô chứa" +"component.resetInstance" = "Đặt lại mọi thay đổi" +"component.resetOverride" = "Đặt lại ghi đè" +"component.restoreMain" = "Khôi phục thành phần chính" +"dev.compare" = "So sánh thay đổi" +"dev.focus" = "Chế độ xem tập trung" +"dev.resolveChange" = "Giải quyết thay đổi" +"dev.unfocus" = "Thoát chế độ xem tập trung" +"devResource.add" = "Thêm tài nguyên dev" +"devResource.edit" = "Chỉnh sửa tài nguyên dev" +"devResource.list" = "Danh sách tài nguyên dev" +"devResource.remove" = "Xóa tài nguyên dev" +"distribute.horizontal" = "Phân bổ đều khoảng cách ngang" +"distribute.vertical" = "Phân bổ đều khoảng cách dọc" +"doc.home" = "Trang chủ" +"doc.list" = "Danh sách tài liệu" +"doc.nextTab" = "Thẻ tiếp theo" +"doc.previousTab" = "Thẻ trước" +"doc.switch" = "Chuyển tài liệu" +"edit.copy" = "Sao chép" +"edit.copyAsCss" = "Sao chép dưới dạng CSS" +"edit.copyAsPng" = "Sao chép dưới dạng PNG" +"edit.copyAsSvg" = "Sao chép dưới dạng SVG" +"edit.copyAsText" = "Sao chép dưới dạng văn bản" +"edit.copyLink" = "Sao chép liên kết đến vùng chọn" +"edit.copyProperties" = "Sao chép thuộc tính" +"edit.cut" = "Cắt" +"edit.delete" = "Xóa" +"edit.duplicate" = "Nhân bản" +"edit.findReplace" = "Tìm và thay thế" +"edit.paste" = "Dán" +"edit.pasteDesignLayers" = "Dán các lớp thiết kế" +"edit.pasteOver" = "Dán đè lên vùng chọn" +"edit.pasteProperties" = "Dán thuộc tính" +"edit.pasteReplace" = "Dán để thay thế" +"edit.pickColor" = "Chọn màu" +"edit.redo" = "Làm lại" +"edit.repeat" = "Lặp lại lệnh cuối" +"edit.setDefaultProperties" = "Đặt thuộc tính mặc định" +"edit.undo" = "Hoàn tác" +"effect.add" = "Thêm hiệu ứng" +"effect.move" = "Di chuyển hiệu ứng" +"effect.remove" = "Xóa hiệu ứng" +"effect.set" = "Đặt hiệu ứng" +"export.add" = "Thêm thiết lập xuất" +"export.remove" = "Xóa thiết lập xuất" +"export.update" = "Chỉnh sửa thiết lập xuất" +"file.close" = "Đóng thẻ" +"file.colorProfile" = "Hồ sơ màu" +"file.duplicate" = "Nhân bản tệp" +"file.export" = "Xuất" +"file.exportFramesPdf" = "Xuất các khung ra PDF" +"file.exportLayers" = "Xuất các lớp" +"file.exportSvg" = "Xuất vùng chọn ra SVG" +"file.import" = "Nhập" +"file.moveTab" = "Di chuyển thẻ" +"file.new" = "Tệp thiết kế mới" +"file.newFromTemplate" = "Mới từ mẫu" +"file.open" = "Mở" +"file.openRecent" = "Mở tệp gần đây" +"file.placeImage" = "Đặt hình ảnh" +"file.recover" = "Khôi phục tệp" +"file.rename" = "Đổi tên tệp" +"file.revert" = "Hoàn nguyên về bản đã lưu" +"file.save" = "Lưu" +"file.saveAs" = "Lưu thành" +"file.saveVersion" = "Lưu vào lịch sử phiên bản" +"file.versionHistory" = "Hiện lịch sử phiên bản" +"fill.add" = "Thêm màu tô" +"fill.move" = "Di chuyển màu tô" +"fill.pattern" = "Tô họa tiết" +"fill.remove" = "Xóa màu tô" +"fill.set" = "Đặt màu tô" +"frame.create" = "Tạo khung" +"frame.presets" = "Danh sách kích thước khung dựng sẵn" +"frame.swapOrientation" = "Đổi chiều ngang/dọc" +"grid.add" = "Thêm lưới bố cục" +"grid.move" = "Di chuyển lưới bố cục" +"grid.remove" = "Xóa lưới bố cục" +"guide.add" = "Thêm đường gióng" +"guide.clear" = "Xóa mọi đường gióng" +"guide.move" = "Di chuyển đường gióng" +"guide.remove" = "Xóa đường gióng" +"guide.select" = "Chọn đường gióng" +"help.about" = "Giới thiệu về FigCraft" +"help.gettingStarted" = "Bắt đầu" +"help.page" = "Trang trợ giúp" +"help.shortcuts" = "Phím tắt" +"image.adjust" = "Điều chỉnh hình ảnh" +"image.deletePreset" = "Xóa thiết lập sẵn hình ảnh" +"image.place" = "Đặt hình ảnh" +"image.presets" = "Thiết lập sẵn hình ảnh" +"image.savePreset" = "Lưu thiết lập sẵn hình ảnh" +"inspect.assets" = "Kiểm tra tài nguyên" +"inspect.code" = "Mã cho lớp" +"inspect.componentCode" = "Mã thành phần" +"inspect.properties" = "Kiểm tra thuộc tính" +"inspect.readyForDev" = "Sẵn sàng cho dev" +"instance.create" = "Tạo bản thể" +"instance.exposed" = "Các bản thể lồng nhau được hiển thị" +"instance.place" = "Đặt bản thể" +"instance.resetSlot" = "Đặt lại ô chứa" +"instance.setProperties" = "Đặt thuộc tính bản thể" +"instance.slotInsert" = "Chèn vào ô chứa" +"instance.swap" = "Hoán đổi bản thể" +"instance.swapAt" = "Hoán đổi bản thể tại điểm" +"layers.collapseAll" = "Thu gọn các lớp" +"layout.add" = "Thêm bố cục tự động" +"layout.remove" = "Xóa bố cục tự động" +"layout.setTrack" = "Đặt kích thước dải lưới" +"layout.suggest" = "Gợi ý bố cục tự động" +"libraries.open" = "Thư viện" +"libraries.publish" = "Xuất bản thư viện…" +"library.acceptUpdates" = "Chấp nhận cập nhật thư viện" +"library.add" = "Thêm thư viện" +"library.aliasVariable" = "Tạo bí danh biến thư viện" +"library.applyStyle" = "Áp dụng kiểu thư viện" +"library.bindVariable" = "Gắn biến thư viện" +"library.extendCollection" = "Mở rộng bộ sưu tập thư viện" +"library.import" = "Nhập thành phần thư viện" +"library.importVariable" = "Nhập biến thư viện" +"library.list" = "Danh sách thư viện" +"library.place" = "Đặt thành phần thư viện" +"library.publish" = "Xuất bản thư viện" +"library.publishToServer" = "Xuất bản thư viện lên máy chủ" +"library.remove" = "Xóa thư viện" +"library.setVariableMode" = "Đặt chế độ biến thư viện" +"library.swap" = "Hoán đổi thư viện" +"library.swapAt" = "Hoán đổi bản thể sang thành phần thư viện" +"library.updates" = "Cập nhật thư viện" +"library.usage" = "Mức sử dụng thư viện" +"measure.distances" = "Đo khoảng cách" +"measure.padding" = "Đo khoảng đệm bố cục tự động" +"node.create" = "Tạo lớp" +"node.get" = "Lấy lớp" +"node.reorder" = "Sắp xếp lại lớp" +"node.reparent" = "Di chuyển lớp vào trong" +"node.set" = "Đặt thuộc tính" +"object.batchRename" = "Đổi tên hàng loạt" +"object.convertToFrame" = "Chuyển thành khung" +"object.convertToGroup" = "Chuyển thành nhóm" +"object.convertToSection" = "Chuyển thành phần" +"object.flipH" = "Lật ngang" +"object.flipV" = "Lật dọc" +"object.frameSelection" = "Đóng khung vùng chọn" +"object.group" = "Nhóm vùng chọn" +"object.hideOthers" = "Ẩn các lớp khác" +"object.mask" = "Dùng làm mặt nạ" +"object.moveToPage" = "Di chuyển sang trang" +"object.opacityKeys" = "Độ mờ từ phím số" +"object.rasterize" = "Điểm ảnh hóa vùng chọn" +"object.releaseMask" = "Bỏ mặt nạ" +"object.removeFill" = "Xóa màu tô" +"object.removeFrame" = "Bỏ khung" +"object.removeStroke" = "Xóa nét" +"object.rename" = "Đổi tên" +"object.resizeToFit" = "Đổi kích thước vừa khít" +"object.rotate180" = "Xoay 180°" +"object.rotateLeft" = "Xoay 90° sang trái" +"object.rotateRight" = "Xoay 90° sang phải" +"object.setThumbnail" = "Đặt làm ảnh thu nhỏ" +"object.swapFillStroke" = "Đổi chỗ màu tô và nét" +"object.toggleLock" = "Khóa/Mở khóa vùng chọn" +"object.toggleVisible" = "Hiện/Ẩn vùng chọn" +"object.ungroup" = "Bỏ nhóm vùng chọn" +"object.wrapInSection" = "Bọc trong phần mới" +"page.add" = "Thêm trang" +"page.delete" = "Xóa trang" +"page.duplicate" = "Nhân bản trang" +"page.list" = "Danh sách trang" +"page.move" = "Di chuyển trang" +"page.rename" = "Đổi tên trang" +"page.select" = "Đi tới trang" +"page.setBackground" = "Màu nền trang" +"paint.documentColors" = "Màu trong tài liệu" +"plugin.close" = "Đóng bảng plugin" +"plugin.directory.install" = "Cài đặt plugin từ thư mục" +"plugin.directory.list" = "Duyệt thư mục plugin" +"plugin.directory.publish" = "Xuất bản plugin lên thư mục" +"plugin.directory.unpublish" = "Gỡ xuất bản plugin khỏi thư mục" +"plugin.event" = "Dùng điều khiển bảng plugin" +"plugin.import" = "Nhập plugin từ tệp kê khai…" +"plugin.list" = "Danh sách plugin" +"plugin.remove" = "Gỡ plugin" +"plugin.run" = "Chạy plugin" +"plugin.ui" = "Bảng plugin" +"plugins.manage" = "Quản lý plugin…" +"plugins.runLast" = "Chạy plugin vừa dùng" +"pref.autoSave" = "Tự động lưu tệp" +"pref.highlightHover" = "Làm nổi bật lớp khi di chuột qua" +"pref.keepTool" = "Giữ công cụ được chọn sau khi dùng" +"pref.language" = "Ngôn ngữ giao diện" +"pref.nudge" = "Mức dịch chuyển nhẹ" +"pref.numberKeysOpacity" = "Dùng phím số để chỉnh độ mờ" +"pref.publishServer" = "Máy chủ chia sẻ trực tuyến" +"pref.reduceMotion" = "Giảm chuyển động" +"pref.renameDuplicates" = "Đổi tên các lớp nhân bản" +"pref.showDimensions" = "Hiện kích thước trên đối tượng" +"pref.startOnHome" = "Khởi động tại màn hình Trang chủ" +"pref.uiScale" = "Kích thước giao diện" +"pref.userName" = "Tên của bạn trong bình luận" +"proto.action.add" = "Thêm hành động nguyên mẫu" +"proto.action.move" = "Sắp xếp lại hành động nguyên mẫu" +"proto.action.remove" = "Xóa hành động nguyên mẫu" +"proto.action.set" = "Chỉnh sửa hành động nguyên mẫu" +"proto.closePreview" = "Đóng xem trước nguyên mẫu" +"proto.conditional.addBlock" = "Thêm nhánh else if / else" +"proto.conditional.removeBlock" = "Xóa nhánh if / else if / else" +"proto.conditional.setCondition" = "Chỉnh sửa điều kiện" +"proto.evaluate" = "Tính biểu thức nguyên mẫu" +"proto.flow.add" = "Thêm điểm bắt đầu luồng" +"proto.flow.remove" = "Xóa luồng" +"proto.flow.rename" = "Đổi tên luồng" +"proto.interaction.add" = "Thêm tương tác" +"proto.interaction.clear" = "Xóa mọi tương tác" +"proto.interaction.remove" = "Xóa tương tác" +"proto.interaction.set" = "Chỉnh sửa tương tác" +"proto.present" = "Trình chiếu" +"proto.presentOptions" = "Tùy chọn trình chiếu" +"proto.preview" = "Mở xem trước nguyên mẫu" +"proto.settings" = "Thiết lập nguyên mẫu" +"section.toggleContents" = "Ẩn/Hiện nội dung phần" +"select.add" = "Thêm vào vùng chọn" +"select.all" = "Chọn tất cả" +"select.area" = "Chọn vùng" +"select.byName" = "Chọn lớp theo tên" +"select.children" = "Chọn các lớp con" +"select.inverse" = "Đảo ngược vùng chọn" +"select.layersAt" = "Các lớp tại một điểm" +"select.matching" = "Chọn các lớp tương ứng" +"select.next" = "Chọn lớp đồng cấp tiếp theo" +"select.none" = "Bỏ chọn tất cả" +"select.parent" = "Chọn lớp cha" +"select.previous" = "Chọn lớp đồng cấp trước đó" +"select.remove" = "Bỏ khỏi vùng chọn" +"select.sameEffect" = "Chọn cùng hiệu ứng" +"select.sameFill" = "Chọn cùng màu tô" +"select.sameFont" = "Chọn cùng phông chữ" +"select.sameInstance" = "Chọn cùng bản thể" +"select.sameProperties" = "Chọn cùng thuộc tính" +"select.sameStroke" = "Chọn cùng nét" +"select.sameText" = "Chọn cùng thuộc tính văn bản" +"select.set" = "Chọn" +"selection.applyColorStyle" = "Áp dụng kiểu màu cho vùng chọn" +"selection.colors" = "Màu của vùng chọn" +"selection.selectColor" = "Chọn các lớp có màu này" +"selection.setPaint" = "Đổi màu vùng chọn" +"shape.arrow" = "Mũi tên" +"shape.ellipse" = "Hình elip" +"shape.line" = "Đường thẳng" +"shape.polygon" = "Đa giác" +"shape.rectangle" = "Hình chữ nhật" +"shape.star" = "Ngôi sao" +"shapeBuilder.merge" = "Hợp nhất các vùng" +"shapeBuilder.mode" = "Chế độ tạo hình" +"shapeBuilder.regions" = "Các vùng tạo hình" +"shapeBuilder.remove" = "Xóa các vùng" +"stroke.add" = "Thêm nét" +"stroke.brush" = "Cọ vẽ" +"stroke.move" = "Di chuyển nét" +"stroke.remove" = "Xóa nét" +"stroke.set" = "Đặt nét" +"stroke.widthProfile" = "Biên dạng độ rộng" +"style.apply" = "Áp dụng kiểu" +"style.bind" = "Gắn biến trong kiểu" +"style.create" = "Tạo kiểu" +"style.delete" = "Xóa kiểu" +"style.detach" = "Tách kiểu" +"style.duplicate" = "Nhân bản kiểu" +"style.list" = "Danh sách kiểu" +"style.move" = "Di chuyển kiểu" +"style.renameFolder" = "Đổi tên thư mục kiểu" +"style.update" = "Chỉnh sửa kiểu" +"template.list" = "Danh sách mẫu" +"text.alignCenter" = "Căn giữa" +"text.alignJustify" = "Căn đều" +"text.alignLeft" = "Căn trái" +"text.alignRight" = "Căn phải" +"text.bold" = "Đậm" +"text.bulletList" = "Danh sách dấu đầu dòng" +"text.case" = "Kiểu chữ hoa/thường" +"text.caseLower" = "Chữ thường" +"text.caseOriginal" = "Giữ nguyên chữ hoa/thường" +"text.caseSmallCaps" = "Chữ hoa nhỏ" +"text.caseTitle" = "Viết hoa đầu từ" +"text.caseUpper" = "Chữ hoa" +"text.create" = "Văn bản" +"text.decoration" = "Trang trí chữ" +"text.edit.begin" = "Chỉnh sửa văn bản" +"text.edit.caret" = "Đặt con trỏ văn bản" +"text.edit.end" = "Dừng chỉnh sửa văn bản" +"text.edit.insert" = "Nhập văn bản" +"text.edit.key" = "Phím văn bản" +"text.edit.preedit" = "Soạn văn bản (IME)" +"text.edit.selectLine" = "Chọn dòng" +"text.edit.selectWord" = "Chọn từ" +"text.edit.selection" = "Vùng chọn văn bản" +"text.endPreview" = "Kết thúc xem trước văn bản" +"text.fonts" = "Danh sách phông chữ" +"text.italic" = "Nghiêng" +"text.lineHeightDown" = "Giảm chiều cao dòng" +"text.lineHeightUp" = "Tăng chiều cao dòng" +"text.link" = "Tạo liên kết" +"text.listIndent" = "Thụt lề danh sách" +"text.listOutdent" = "Nhô lề danh sách" +"text.listStyle" = "Kiểu danh sách" +"text.loadFont" = "Tải tệp phông chữ" +"text.maxLines" = "Số dòng tối đa" +"text.missingFonts" = "Phông chữ bị thiếu" +"text.numberList" = "Danh sách đánh số" +"text.onPath" = "Văn bản trên đường dẫn" +"text.paragraphIndent" = "Thụt lề đoạn văn" +"text.paragraphSpacing" = "Khoảng cách đoạn văn" +"text.pathOptions" = "Tùy chọn đường dẫn văn bản" +"text.preview" = "Xem trước thay đổi văn bản" +"text.refit" = "Khớp lại văn bản theo phông chữ" +"text.removeFromPath" = "Gỡ văn bản khỏi đường dẫn" +"text.rescanFonts" = "Quét lại phông chữ đã cài đặt" +"text.set" = "Đặt văn bản" +"text.setFeatures" = "Tính năng OpenType" +"text.setVariations" = "Trục phông chữ biến thiên" +"text.sizeDown" = "Giảm cỡ chữ" +"text.sizeUp" = "Tăng cỡ chữ" +"text.strikethrough" = "Gạch ngang chữ" +"text.style" = "Định kiểu đoạn văn bản" +"text.trackingDown" = "Giảm khoảng cách chữ" +"text.trackingUp" = "Tăng khoảng cách chữ" +"text.underline" = "Gạch chân" +"text.weightDown" = "Giảm độ đậm chữ" +"text.weightUp" = "Tăng độ đậm chữ" +"tool.arrow" = "Mũi tên" +"tool.comment" = "Bình luận" +"tool.ellipse" = "Hình elip" +"tool.frame" = "Khung" +"tool.hand" = "Bàn tay" +"tool.line" = "Đường thẳng" +"tool.move" = "Di chuyển" +"tool.pen" = "Bút" +"tool.pencil" = "Bút chì" +"tool.pencilBrush" = "Cọ bút chì" +"tool.place" = "Đặt bằng công cụ" +"tool.polygon" = "Đa giác" +"tool.rectangle" = "Hình chữ nhật" +"tool.scale" = "Tỷ lệ" +"tool.scaleAnchor" = "Điểm neo tỷ lệ" +"tool.section" = "Phần" +"tool.set" = "Chọn công cụ" +"tool.shapeBuilder" = "Tạo hình" +"tool.slice" = "Lát cắt" +"tool.star" = "Ngôi sao" +"tool.text" = "Văn bản" +"transform.move" = "Di chuyển" +"transform.moveTo" = "Di chuyển tới" +"transform.nudge" = "Dịch chuyển nhẹ" +"transform.nudgeSize" = "Đổi kích thước theo bước dịch nhẹ" +"transform.resize" = "Đổi kích thước" +"transform.rotate" = "Xoay" +"transform.rotationOrigin" = "Di chuyển tâm xoay" +"transform.scale" = "Tỷ lệ" +"transform.setRotationOrigin" = "Đặt tâm xoay" +"variable.addMode" = "Thêm chế độ" +"variable.bind" = "Gắn biến" +"variable.create" = "Tạo biến" +"variable.createCollection" = "Tạo bộ sưu tập biến" +"variable.delete" = "Xóa biến" +"variable.deleteCollection" = "Xóa bộ sưu tập" +"variable.duplicate" = "Nhân bản biến" +"variable.export" = "Xuất các biến" +"variable.import" = "Nhập các biến" +"variable.list" = "Danh sách biến" +"variable.moveMode" = "Di chuyển chế độ" +"variable.removeMode" = "Xóa chế độ" +"variable.renameCollection" = "Đổi tên bộ sưu tập" +"variable.renameMode" = "Đổi tên chế độ" +"variable.resetOverride" = "Đặt lại ghi đè biến" +"variable.set" = "Đặt giá trị biến" +"variable.setMode" = "Đặt chế độ biến" +"variable.update" = "Chỉnh sửa biến" +"vector.create" = "Vector" +"vector.deleteHeal" = "Xóa và nối liền vùng chọn" +"vector.edit.addVertex" = "Thêm điểm" +"vector.edit.begin" = "Chỉnh sửa vector" +"vector.edit.bend" = "Uốn đoạn" +"vector.edit.bounds" = "Khung giới hạn các điểm đã chọn" +"vector.edit.connect" = "Nối các điểm" +"vector.edit.cornerRadius" = "Bán kính góc của điểm" +"vector.edit.cut" = "Cắt đường dẫn" +"vector.edit.delete" = "Xóa các điểm" +"vector.edit.end" = "Hoàn tất chỉnh sửa" +"vector.edit.erase" = "Tẩy xóa" +"vector.edit.lasso" = "Chọn bằng dây quây" +"vector.edit.mirroring" = "Đối xứng tay nắm" +"vector.edit.moveHandles" = "Di chuyển tay nắm" +"vector.edit.moveVertices" = "Di chuyển các điểm" +"vector.edit.nudge" = "Dịch nhẹ các điểm" +"vector.edit.paint" = "Thùng sơn" +"vector.edit.pen" = "Điểm bút" +"vector.edit.penEnd" = "Kết thúc đường dẫn bút" +"vector.edit.position" = "Vị trí điểm" +"vector.edit.resize" = "Đổi kích thước các điểm" +"vector.edit.rotate" = "Xoay các điểm" +"vector.edit.select" = "Chọn các điểm" +"vector.edit.selectAll" = "Chọn tất cả các điểm" +"vector.edit.setHandle" = "Đặt tay nắm" +"vector.edit.smooth" = "Làm mượt hoặc làm nhọn điểm" +"vector.edit.state" = "Trạng thái chỉnh sửa vector" +"vector.edit.strokeCap" = "Đầu mút nét tại điểm" +"vector.edit.strokeJoin" = "Khớp nối nét tại điểm" +"vector.edit.tool" = "Công cụ chỉnh sửa vector" +"vector.edit.transform" = "Biến đổi các điểm" +"vector.edit.widthPoint" = "Điểm độ rộng" +"vector.flatten" = "Làm phẳng" +"vector.join" = "Nối vùng chọn" +"vector.offset" = "Đường dẫn lệch" +"vector.outlineStroke" = "Chuyển nét thành đường viền" +"vector.reverse" = "Đảo chiều đường dẫn" +"vector.simplify" = "Đơn giản hóa vector" +"vector.smoothJoin" = "Nối mượt vùng chọn" +"version.autosave" = "Phiên bản tự động lưu" +"version.delete" = "Xóa phiên bản" +"version.duplicate" = "Nhân bản phiên bản" +"version.exitPreview" = "Quay lại phiên bản hiện tại" +"version.list" = "Danh sách phiên bản" +"version.preview" = "Xem phiên bản" +"version.rename" = "Đổi tên phiên bản" +"version.restore" = "Khôi phục phiên bản" +"view.assets" = "Bảng Tài nguyên" +"view.comments" = "Bình luận" +"view.contextMenu" = "Hiện trình đơn ngữ cảnh" +"view.design" = "Bảng Thiết kế" +"view.designPrototype" = "Chuyển đổi giữa Thiết kế và Nguyên mẫu" +"view.devMode" = "Dev Mode" +"view.frameOutlines" = "Đường viền khung" +"view.fullscreen" = "Toàn màn hình" +"view.get" = "Lấy khung nhìn" +"view.layers" = "Bảng Các lớp" +"view.layoutGrids" = "Lưới bố cục" +"view.leftSidebar" = "Hiện/Ẩn thanh bên trái" +"view.maskOutlines" = "Đường viền mặt nạ" +"view.multiplayerCursors" = "Con trỏ nhiều người dùng" +"view.nextFrame" = "Thu phóng đến khung tiếp theo" +"view.nextPage" = "Trang tiếp theo" +"view.nextRegion" = "Chuyển tiêu điểm sang vùng tiếp theo" +"view.outlines" = "Đường viền nét" +"view.outlinesHiddenLayers" = "Bao gồm các lớp ẩn trong đường viền nét" +"view.outlinesObjectBounds" = "Bao gồm khung giới hạn đối tượng trong đường viền nét" +"view.outlinesSelection" = "Đường viền nét cho vùng chọn" +"view.pan" = "Di chuyển khung nhìn" +"view.panelWidths" = "Đặt lại độ rộng bảng" +"view.pixelGrid" = "Lưới pixel" +"view.pixelPreview" = "Xem trước pixel" +"view.prevFrame" = "Thu phóng đến khung trước" +"view.prevPage" = "Trang trước" +"view.previousRegion" = "Chuyển tiêu điểm về vùng trước" +"view.proofSrgb" = "Kiểm thử mềm sRGB" +"view.prototype" = "Bảng Nguyên mẫu" +"view.rulers" = "Thước" +"view.set" = "Đặt khung nhìn" +"view.slices" = "Lát cắt" +"view.snapGeometry" = "Bắt dính vào hình học" +"view.snapObjects" = "Bắt dính vào đối tượng" +"view.snapPixel" = "Bắt dính vào lưới pixel" +"view.themeDark" = "Giao diện tối" +"view.themeLight" = "Giao diện sáng" +"view.themeSystem" = "Theo giao diện hệ thống" +"view.toggleUi" = "Hiện/Ẩn giao diện" +"view.zoom100" = "Thu phóng về 100%" +"view.zoom200" = "Thu phóng về 200%" +"view.zoom50" = "Thu phóng về 50%" +"view.zoomAt" = "Công cụ thu phóng" +"view.zoomFit" = "Thu phóng vừa khít" +"view.zoomIn" = "Phóng to" +"view.zoomOut" = "Thu nhỏ" +"view.zoomSelection" = "Thu phóng vừa vùng chọn" +"widget.click" = "Nhấp tiện ích" +"widget.insert" = "Chèn tiện ích" +"widget.list" = "Danh sách tiện ích" +"window.focus" = "Đưa cửa sổ lên trước" +"window.new" = "Cửa sổ mới" + +[menu] +"bool.exclude" = "Loại trừ" +"bool.flatten" = "Làm phẳng" +"bool.intersect" = "Giao nhau" +"bool.subtract" = "Trừ" +"bool.union" = "Hợp" +"file.duplicate" = "Nhân bản" +"file.import" = "Nhập từ tệp…" +"file.rename" = "Đổi tên" +"libraries.open" = "Mở thư viện" +"select.sameEffect" = "Hiệu ứng" +"select.sameFill" = "Màu tô" +"select.sameFont" = "Phông chữ" +"select.sameInstance" = "Bản thể" +"select.sameProperties" = "Thuộc tính" +"select.sameStroke" = "Nét" +"select.sameText" = "Thuộc tính văn bản" +"text.alignCenter" = "Căn giữa văn bản" +"text.alignJustify" = "Căn đều văn bản" +"text.alignLeft" = "Căn trái văn bản" +"text.alignRight" = "Căn phải văn bản" +"view.assets" = "Tài nguyên" +"view.design" = "Thiết kế" +"view.layers" = "Các lớp" +"view.outlines" = "Hiện đường viền nét" +"view.outlinesHiddenLayers" = "Bao gồm các lớp ẩn" +"view.outlinesObjectBounds" = "Bao gồm khung giới hạn đối tượng" +"view.outlinesSelection" = "Hiện đường viền nét cho vùng chọn" +"view.prototype" = "Nguyên mẫu" +"view.themeDark" = "Tối" +"view.themeLight" = "Sáng" +"view.themeSystem" = "Theo hệ thống" diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index 35323efe..88f5e516 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -157,7 +157,7 @@ mod tests { assert_eq!(language("zh-Hans"), HAN); assert_eq!(language("bn"), BENGALI); assert_eq!(language("fr"), 0); - assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id"].iter().all(|c| can_show(c))); + assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id", "vi"].iter().all(|c| can_show(c))); } #[test] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 9faff001..871f66cf 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -176,7 +176,7 @@ fn plural(code: &str, n: usize) -> Plural { "ru" if n % 10 == 1 && n % 100 != 11 => Plural::One, "ru" | "pl" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few, "ru" | "pl" => Plural::Many, - "ja" | "ko" | "zh-Hans" => Plural::Other, + "ja" | "ko" | "zh-Hans" | "vi" => Plural::Other, "fr" | "pt-BR" if n <= 1 => Plural::One, _ if n == 1 => Plural::One, _ => Plural::Other, @@ -187,7 +187,7 @@ fn plural(code: &str, n: usize) -> Plural { // "other" wording. Czech supplies separate few forms; Russian and Polish supply few/many forms. /// Whether count `n` takes the "one" form: 0 and 1 in French and Brazilian Portuguese, never in -/// Japanese, Korean and Chinese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules). +/// Japanese, Korean, Chinese and Vietnamese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules). #[cfg(test)] fn plural_one(lang: usize, n: usize) -> bool { plural(LANGS.get(lang).map_or("en", |l| l.code), n) == Plural::One diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 046212c1..85b006ff 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -349,6 +349,7 @@ fn system_languages_pick_the_first_one_with_a_catalog() { assert_eq!(code("system", Some("pt-BR")), "pt-BR"); assert_eq!(code("system", Some("tr_TR.UTF-8")), "tr"); assert_eq!(code("system", Some("id_ID.UTF-8")), "id"); + assert_eq!(code("system", Some("vi_VN.UTF-8")), "vi"); // Portugal's Portuguese and a bare "pt" read the Brazilian catalog. assert_eq!(code("system", Some("pt-PT")), "pt-BR"); assert_eq!(code("pt", None), "pt-BR"); @@ -417,12 +418,15 @@ fn french_and_portuguese_count_zero_as_singular() { } #[test] -fn japanese_korean_and_chinese_have_no_plural_forms() { +fn japanese_korean_chinese_and_vietnamese_have_no_plural_forms() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; assert_eq!(code("system", Some("ja-JP")), "ja"); assert_eq!(code("system", Some("ko_KR.UTF-8")), "ko"); + assert_eq!(code("system", Some("vi-VN")), "vi"); assert_eq!(code("system", Some("ja,en-US")), "ja"); - for code in ["ja", "ko", "zh-Hans"] { + assert_eq!(LANGS[resolve("vi", None)].name, "Tiếng Việt"); + assert!(LANGS[resolve("vi", None)].draft); + for code in ["ja", "ko", "zh-Hans", "vi"] { let lang = LANGS.iter().position(|l| l.code == code).unwrap(); assert!(!plural_one(lang, 0) && !plural_one(lang, 1) && !plural_one(lang, 2), "{code}"); let ones: Vec<_> = LANGS[lang].ui.iter().filter(|(_, t)| matches!(t, Text::Plural { one: Some(_), .. })).map(|(k, _)| k).collect(); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 50fcd4ec..05596fa3 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -4,7 +4,7 @@ FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian -Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts (machine-drafted, +Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Czech drafts (machine-drafted, waiting for native speakers' review), plus Swedish and Turkish catalogs. This page lists the twelve key languages of the craftrules standard, then the other languages Figma ships. How to add a language: [`translating.md`](translating.md). @@ -32,7 +32,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, then each catalog by its own name: English, Čeština, Deutsch, Español, Español (Latinoamérica), Français, Bahasa Indonesia, 日本語, - 한국어, Português (Brasil), Русский, Svenska, Türkçe, 简体中文), applied at once without a + 한국어, Português (Brasil), Русский, Svenska, Türkçe, Tiếng Việt, 简体中文), applied at once without a restart; System takes the first of the OS's preferred languages that has a catalog (on Windows only the first preferred language counts, since that list also holds the languages added for a keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional @@ -53,7 +53,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v - **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values such as "inside"), number formatting (decimal points stay `.`), and decimal plural rules (`trn` takes integers: Czech uses one/few/other and Russian one/few/many; French and Brazilian - Portuguese take the singular for 0 as well; Japanese, Korean and Chinese have one form; + Portuguese take the singular for 0 as well; Japanese, Korean, Chinese and Vietnamese have one form; CLDR's "many" for millions in French, Spanish and Portuguese reads as the plural). - **Script support in documents:** Unicode bidi with Arabic shaping and mirrored brackets in text layers; right-to-left list markers and indents; right-to-left names in the layers panel, tabs @@ -96,7 +96,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Japanese | ja | 2,254 / 2,272 (99.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | German | de | 2,176 / 2,272 (95.8%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | | Korean | ko | 2,166 / 2,272 (95.3%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | -| Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | +| Vietnamese | vi | 2,352 / 2,352 (100%, draft) | yes | Latin with stacked diacritics | no | draft | 1–2 (after native review) | Other languages (measured 2026-10-17): Russian (`ru`) 2,244 / 2,272 (98.8%), reviewed and approved by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,264 (99.6%), its command labels and menu @@ -113,8 +113,8 @@ right-to-left mirroring of the panels is in Arabic's hours. Total: **29–51 h** every string adds). Priority: native reviews of the drafts and the es-419 overlay (every one of Figma's languages -and variants has one, plus Simplified Chinese, Indonesian and Czech, which Figma doesn't ship), then -Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users see English). +and variants has one, plus Simplified Chinese, Indonesian, Vietnamese and Czech, which Figma doesn't ship), then +Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see English). ## Revision history diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..d8fb52a7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -436,7 +436,7 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: - `pref.startOnHome {on?}`: the desktop app starts on the Home screen instead of in a new file (off by default; files named on the command line still open, and `--control` starts in a new file anyway, so control callers have a document). -- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|zh-Hans|…}`: the app's interface language, applied live ("system": +- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|vi|zh-Hans|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and every result stay English. diff --git a/docs/translating.md b/docs/translating.md index eaa59755..73ee5136 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -17,7 +17,7 @@ Simplified. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay `es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish -(`fi`), Polish (`pl`) and Bengali (`bn`), machine-drafted and waiting for native speakers' review. Georgian +(`fi`), Polish (`pl`), Bengali (`bn`) and Vietnamese (`vi`), machine-drafted and waiting for native speakers' review. Georgian (`ka`) was written with AI assistance and reviewed by a native speaker, and stays a draft until a maintainer changes its status. Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar @@ -61,7 +61,9 @@ formal Anda (never kamu), keeps established design terms in English (frame, laye instance, auto layout, plugin, file) and keeps a noun unchanged after a count, so its plural entries give only `other`. Georgian has no letter case (never write Mtavruli for capitals), uses verbal nouns for commands (გვერდის დამატება) and keeps a noun singular after a numeral, so counts read -"შრე: {n}" and its plural entries give the same text for `one` and `other`. +"შრე: {n}" and its plural entries give the same text for `one` and `other`. Vietnamese uses concise +imperatives for buttons and menu items (Lưu, Xóa) and keeps a noun unchanged after a count, so its +plural entries give only `other`. CJK text needs a CJK font, which egui and Inter lack: the desktop app adds an installed one when something on screen needs it (see `crates/ui-egui/src/cjk.rs`). The web build has no system fonts, @@ -80,8 +82,8 @@ picker leaves Georgian out. A new CJK or other non-Latin language also needs its set `[meta] name` and `status = "draft — needs native review"`. 2. Translate every value; keep the keys. Keep `{placeholders}` exactly, "…" where English has it, brand names and keyboard glyphs. English labels of commands: `figcraft-cli commands`. -3. Plurals: give `one` only if the language has a singular form (Japanese, Korean and Chinese give - only `other`). `other` is required; `one`, `few` and `many` are optional. `trn` picks `one` for +3. Plurals: give `one` only if the language has a singular form (Japanese, Korean, Chinese and + Vietnamese give only `other`). `other` is required; `one`, `few` and `many` are optional. `trn` picks `one` for 1 (also 0 in French and Brazilian Portuguese). Russian uses `one` for 1, 21, 31… except 11; `few` for 2–4, 22–24… except 12–14; and `many` for the other integers, including 0. Keep `{n}` in all Russian forms. Add new rules to `plural` in @@ -94,8 +96,8 @@ picker leaves Georgian out. A new CJK or other non-Latin language also needs its 4. To require full coverage, add the code to `COMPLETE` in [`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs). Every later PR that adds a string then has to translate it into each complete language, so only Spanish is there: - French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and - Georgian are registered as drafts, Russian and Turkish are reviewed but not required, and a string added + French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian, + Georgian and Vietnamese are registered as drafts, Russian and Turkish are reviewed but not required, and a string added later shows English in them until it is translated. To list what a draft lacks, add its code to `COMPLETE` locally and run the check. 5. Run the check and look at the panels in the app (longer words must fit the right panel): From d3a1538614ee897ce408b9b70fffb681e425e496 Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 13:00:27 +0800 Subject: [PATCH 35/87] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=9F=A2?= =?UTF-8?q?=E9=87=8F=E7=BC=96=E8=BE=91=E6=A8=A1=E5=BC=8F=E4=B8=8B=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E7=A9=BA=E7=94=BB=E5=B8=83=E7=9A=84=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/vector_behaviour_tests.rs | 4 ++- .../src/vector_multi_behaviour_tests.rs | 7 +++-- crates/engine/src/vectoredit/pointer.rs | 8 ++++-- crates/ui-egui/src/canvas.rs | 17 +++++++++--- .../ui-egui/src/canvas_double_click_tests.rs | 26 ++++++++++++++++--- docs/ui-parity.md | 3 ++- 6 files changed, 53 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 230d2998..60cf960e 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -94,7 +94,8 @@ fn entering_and_leaving_vector_edit_mode() { ("Enter", false, "Enter leaves edit mode"), ("Escape", false, "Escape leaves edit mode"), ("Escape with an open pen path", true, "Escape first ends the pen's path and stays in edit mode"), - ("click outside", false, "silent: a click away from the shape leaves edit mode (pinned)"), + ("click outside", true, "silent: a click away from the shape only clears the points (pinned)"), + ("double-click outside", false, "a double-click away from the shape leaves edit mode"), ]; for (how, after, why) in rows { let mut s = Session::new(); @@ -113,6 +114,7 @@ fn entering_and_leaving_vector_edit_mode() { key(&mut s, "Escape", false); assert!(editing(&s).is_some_and(|e| e.anchor.is_none()), "{why}: the path ended"); } + "double-click outside" => s.pointer(&ev(PointerKind::DoubleClick, (600.0, 600.0), NONE)).unwrap(), _ => click(&mut s, (600.0, 600.0), NONE), } assert_eq!(editing(&s).is_some(), after, "{why}"); diff --git a/crates/engine/src/vector_multi_behaviour_tests.rs b/crates/engine/src/vector_multi_behaviour_tests.rs index 1d2075b1..28559a48 100644 --- a/crates/engine/src/vector_multi_behaviour_tests.rs +++ b/crates/engine/src/vector_multi_behaviour_tests.rs @@ -203,13 +203,16 @@ fn editing_several_vectors_at_once() { assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (5, 4), "silent: the pen adds to the active layer (pinned)"); ex(&mut s, "vector.edit.penEnd", json!({})); ex(&mut s, "vector.edit.tool", json!({"tool": "move"})); - // A click on empty canvas inside an edited layer's area keeps edit mode; far away it ends. + // A click on empty canvas inside an edited layer's area keeps edit mode; so does one far + // away, which only clears the points (a double-click or Escape leaves). click(&mut s, (250.0, 50.0), NONE); assert!(editing(&s).is_some(), "a click inside an edited layer clears the points and stays"); let st = ex(&mut s, "vector.edit.state", json!({})); assert_eq!(st["layers"].as_array().map(Vec::len), Some(2), "the state lists every edited layer"); click(&mut s, (900.0, 900.0), NONE); - assert!(editing(&s).is_none(), "a click away from every edited layer leaves edit mode"); + assert!(editing(&s).is_some(), "a click away from every edited layer keeps edit mode"); + s.pointer(&ev(PointerKind::DoubleClick, (900.0, 900.0), NONE)).unwrap(); + assert!(editing(&s).is_none(), "a double-click away from every edited layer leaves edit mode"); } #[test] diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 1684bf26..4bf8477c 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -695,10 +695,14 @@ impl Session { } } None => { - // Outside every edited layer (and not shift): leave edit mode. + // A press on another layer (and not shift) leaves edit mode, so the normal tool + // takes it; empty canvas only clears the points and starts a marquee (a + // double-click or Escape leaves edit mode). let inside = ve.parts().iter().any(|p| doc.world_render_bounds(p.id).is_some_and(|b| b.inflate(tol * 4.0, tol * 4.0).contains(ev.p()))); - if !inside && !ev.shift { + let page = self.ds()?.page; + let other = !inside && self.hit(&doc, page, ev.p(), tol * 2.0).is_some(); + if other && !ev.shift { super::end(self)?; return Ok(false); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 841c95e8..fe9a0344 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -606,9 +606,20 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { } // A third quick click is counted as a triple, not a double (a click elsewhere in between still // leaves its time in the sequence): it means the same here — edit, enter, open — unless a - // vector or text is being edited, where a triple already has its own meaning. - let editing = app.session.ds().is_ok_and(|d| d.vector_edit.is_some() || d.text_edit.is_some()); - if resp.double_clicked() || (resp.triple_clicked() && !editing) { + // vector or text is being edited, where a triple already has its own meaning. A triple on + // empty canvas still means a double in vector edit mode: the double-click that entered edit + // mode counts on, so leaving it right away would otherwise take a fourth click or a pause. + let ds = app.session.ds().ok(); + let editing = ds.is_some_and(|d| d.vector_edit.is_some() || d.text_edit.is_some()); + let empty_canvas = ds.is_some_and(|d| { + let Some(ve) = d.vector_edit.as_ref().filter(|v| v.anchor.is_none() && d.text_edit.is_none()) else { + return false; + }; + let p = app.to_page(pos); + let tol = 6.0 / d.view.zoom.max(1e-6); + ve.parts().iter().all(|part| d.doc.world_render_bounds(part.id).is_none_or(|b| !b.inflate(tol * 4.0, tol * 4.0).contains(p))) + }); + if resp.double_clicked() || (resp.triple_clicked() && (!editing || empty_canvas)) { let ev = pointer_event(app, PointerKind::DoubleClick, pos, &mods); if let Err(e) = app.session.pointer(&ev) { app.toast(e.to_string()); diff --git a/crates/ui-egui/src/canvas_double_click_tests.rs b/crates/ui-egui/src/canvas_double_click_tests.rs index c47faa74..72f91b76 100644 --- a/crates/ui-egui/src/canvas_double_click_tests.rs +++ b/crates/ui-egui/src/canvas_double_click_tests.rs @@ -85,7 +85,23 @@ fn double_clicking_an_unselected_shape_edits_its_points() { } #[test] -fn a_click_that_leaves_vector_edit_does_not_keep_the_next_double_click_out() { +fn a_double_click_on_empty_canvas_right_after_entering_leaves_vector_edit() { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap(); + let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let shape = u.app.to_screen(250.0, 200.0); + let empty = Pos2::new(u.app.canvas.rect.left() + 30.0, u.app.canvas.rect.bottom() - 30.0); + // The double-click that enters edit mode, then straight away one on empty canvas. + double_click_at(&mut u, shape); + assert_eq!(u.app.session.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(r)); + double_click_at(&mut u, empty); + assert!(u.app.session.ds().unwrap().vector_edit.is_none(), "a double-click on empty canvas leaves vector edit at once"); +} + +#[test] +fn a_click_on_empty_canvas_keeps_vector_edit_until_a_double_click() { let mut s = Session::new(); let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap(); let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); @@ -95,9 +111,13 @@ fn a_click_that_leaves_vector_edit_does_not_keep_the_next_double_click_out() { let empty = Pos2::new(u.app.canvas.rect.left() + 30.0, u.app.canvas.rect.bottom() - 30.0); double_click_at(&mut u, shape); assert!(u.app.session.ds().unwrap().vector_edit.is_some()); - // One click on empty canvas leaves the edit mode. + // One click on empty canvas only clears the points: edit mode stays. click(&mut u, empty); - assert!(u.app.session.ds().unwrap().vector_edit.is_none(), "a click outside leaves vector edit"); + assert!(u.app.session.ds().unwrap().vector_edit.is_some(), "a click on empty canvas keeps vector edit"); + // A double-click on empty canvas leaves it, the earlier click and the counts from entering + // included. + double_click_at(&mut u, empty); + assert!(u.app.session.ds().unwrap().vector_edit.is_none(), "a double-click on empty canvas leaves vector edit"); // The next double-click edits the shape again. double_click_at(&mut u, shape); assert!(u.app.session.ds().unwrap().vector_edit.is_some(), "a double-click edits it again"); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d87103f0..2355de89 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -82,7 +82,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape or Enter ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). A click on empty canvas only clears the points and starts a marquee (edit mode stays; a double-click or Escape leaves it), while a click on another layer leaves it and selects that layer. Not yet: the transform box around points of several layers; areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | | Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Vector edit mode stays on a click on empty canvas: with the Move tool it only clears the points and starts a marquee, and a double-click (or Escape) leaves it; a click on another layer still leaves it and selects that layer. A double-click on empty canvas leaves it even straight after the one that entered edit mode (the platform counts those clicks on, so the last one arrives as a triple; on empty canvas a triple means a double here, while triples on the vector keep their own meaning) (`vector_behaviour_tests`, `canvas_double_click_tests`). Checked in the app on Windows with real mouse clicks over the control channel | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | | 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | From 80b9b491092bc21d8dae7d7251c9db0bafbd415e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=E1=BB=91ng=20Huy=20S=C6=A1n?= Date: Thu, 22 Oct 2026 12:02:08 +0700 Subject: [PATCH 36/87] docs(i18n): reconcile catalog entry counts to 2,352 in localization-parity.md --- docs/localization-parity.md | 40 ++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 05596fa3..86c09178 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Vietnamese draft catalog; string counts re-measured: 2,352 entries; macOS finds its downloadable CJK system fonts (#406); Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian @@ -18,16 +18,16 @@ browser's page translation. The `.lproj` folders in `/Applications/Figma.app` (6 Electron/Chromium locale packs, not Figma's own UI languages. Text *content* in any script is supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no vertical text. -## What FigCraft has (measured 2026-10-17, from source) +## What FigCraft has (measured 2026-10-22, from source) - **Layer:** one TOML catalog per language in `crates/ui-egui/locales/`, baked into the binary by the UI crate's build script (desktop and web; nothing read at run time). Keys are the English text (`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/few/many/other plural forms), so a missing translation shows English, never a raw key. One English text that needs two translations takes a context (`"Fill|sizing"`). -- **Strings:** 1,680 UI keys in `en.toml` (41 with plural forms; ~110 are labels from engine and - document data, such as tool names, blend modes and Dev Mode properties) plus 561 command labels - and 31 menu wordings by command id: 2,272 entries (counted against `en.toml` and the complete +- **Strings:** 1,747 UI keys in `en.toml` (42 with plural forms; ~110 are labels from engine and + document data, such as tool names, blend modes and Dev Mode properties) plus 574 command labels + and 31 menu wordings by command id: 2,352 entries (counted against `en.toml` and the complete Spanish catalog's command and menu tables). Command ids, the CLI, MCP and the control channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, then each catalog by its own name: @@ -48,8 +48,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v lacks, keys no code uses, unknown command ids, placeholders that differ from English, overlay entries that read as in their base language and, for complete languages (Spanish), anything untranslated. The other catalogs are not held to it: a string added later shows English there - until someone translates it. Every draft translated every string on 2026-10-12; by 2026-10-17 - the strings added since left them at 95–99% (table below). + until someone translates it. Every draft translated every string on 2026-10-12; by 2026-10-22 + the strings added since left them at 94–99% (table below). - **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values such as "inside"), number formatting (decimal points stay `.`), and decimal plural rules (`trn` takes integers: Czech uses one/few/other and Russian one/few/many; French and Brazilian @@ -85,23 +85,23 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | |---|---|---|---|---|---|---|---| -| English | en | 2,272 / 2,272 (100%, source) | yes | Latin | yes (source) | full | 0 | -| Simplified Chinese (Mandarin) | zh-Hans | 2,166 / 2,272 (95.3%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | -| Spanish | es, es-419 | 2,272 / 2,272 (100%, draft); es-419 overlays 101 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | +| English | en | 2,352 / 2,352 (100%, source) | yes | Latin | yes (source) | full | 0 | +| Simplified Chinese (Mandarin) | zh-Hans | 2,216 / 2,352 (94.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | +| Spanish | es, es-419 | 2,352 / 2,352 (100%, draft); es-419 overlays 104 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | | Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 | | Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 | -| French | fr | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Portuguese | pt-BR | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Indonesian | id | 2,244 / 2,272 (98.8%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Japanese | ja | 2,254 / 2,272 (99.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | -| German | de | 2,176 / 2,272 (95.8%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | -| Korean | ko | 2,166 / 2,272 (95.3%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| French | fr | 2,328 / 2,352 (99.0%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Portuguese | pt-BR | 2,331 / 2,352 (99.1%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Indonesian | id | 2,292 / 2,352 (97.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Japanese | ja | 2,304 / 2,352 (98.0%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| German | de | 2,226 / 2,352 (94.6%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | +| Korean | ko | 2,216 / 2,352 (94.2%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 2,352 / 2,352 (100%, draft) | yes | Latin with stacked diacritics | no | draft | 1–2 (after native review) | -Other languages (measured 2026-10-17): Russian (`ru`) 2,244 / 2,272 (98.8%), reviewed and approved -by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,264 (99.6%), its command labels and menu -wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,243 (98.7%), whose header -says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,244 (98.8%), a draft +Other languages (measured 2026-10-22): Russian (`ru`) 2,352 / 2,352 (100%), reviewed and approved +by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,314 / 2,352 (98.4%), its command labels and menu +wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,291 / 2,352 (97.4%), whose header +says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,292 / 2,352 (97.4%), a draft with one/few/other plurals. Figma ships none beyond the eight above. The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries From 90004dc7cd1cb1ccaad971af75b81f72bbbcab62 Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 13:52:06 +0800 Subject: [PATCH 37/87] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A?= =?UTF-8?q?=E9=80=89=E7=9F=A2=E9=87=8F=E7=82=B9=E6=97=B6=E5=8F=98=E6=8D=A2?= =?UTF-8?q?=E6=A1=86=E7=9A=84=E4=BA=A4=E4=BA=92=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复了多选矢量点时,变换框角点会误判为可拖动的点的问题,同时更新了相关注释和测试用例,确保未被点占据的角点和边缘仍可用于缩放操作。 --- crates/engine/src/vectoredit/transform.rs | 8 ++- .../engine/src/vectoredit/transform_tests.rs | 55 +++++++++++++------ crates/ui-egui/src/canvas.rs | 4 +- docs/ui-parity.md | 2 +- 4 files changed, 48 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/vectoredit/transform.rs b/crates/engine/src/vectoredit/transform.rs index 1d5f27a6..8ec9826a 100644 --- a/crates/engine/src/vectoredit/transform.rs +++ b/crates/engine/src/vectoredit/transform.rs @@ -85,14 +85,16 @@ pub enum BoxHit { } /// The box's part under page point `p` when it wins over `h`, the network's part there: Bezier -/// handles come first, then the box's corners, points, the box's edges, segments, the rotation -/// zones, and the inside of the box last. Edges along an axis without extent are left out. +/// handles come first, then points, the box's corners, its edges, segments and the rotation zones, +/// and the inside of the box last. A point under a corner or an edge is dragged with the others +/// (the box draws no handles of its own), so it keeps the box off. Edges along an axis without +/// extent are left out. pub fn box_hit(doc: &Document, ve: &VectorEdit, h: Option, p: Point, zoom: f64) -> Option { let b = shown_box(doc, ve)?; match handle_at_corners(&b.corners(), p, zoom, false) { Some(handle) => { let wins = match (handle, h) { - (_, Some(VHit::Handle { .. })) => false, + (_, Some(VHit::Handle { .. } | VHit::Vertex { .. })) => false, (Handle::Corner(_), _) => true, (Handle::Edge(e), None | Some(VHit::Segment { .. })) => { if e % 2 == 0 { diff --git a/crates/engine/src/vectoredit/transform_tests.rs b/crates/engine/src/vectoredit/transform_tests.rs index d69baf52..c608c3aa 100644 --- a/crates/engine/src/vectoredit/transform_tests.rs +++ b/crates/engine/src/vectoredit/transform_tests.rs @@ -7,6 +7,7 @@ use super::tests::{click, drag, ev, ex, net, pt, rect, ve, vertex_at}; use super::transform::{BoxHit, box_hit, shown_box}; use super::*; use crate::EngineError; +use crate::params::id_of; use crate::tools::{Handle, PointerKind, ToolId}; /// Page position of point `v` of vector `id`. @@ -47,13 +48,15 @@ fn the_box_shows_for_two_or_more_points_with_the_move_tool() { let b = shown_box(s.doc().unwrap(), &ve(&s)).unwrap(); assert_eq!((b.width, b.height, b.points.len()), (100.0, 100.0, 4)); assert!(near(b.corners()[2], pt(100.0, 100.0))); - // Corners win over the points under them, points over edges, edges over segments. + // A point under a corner is dragged with the others, not scaled (the box draws no handles of + // its own), so it keeps the box off; corners and edges clear of points still scale, and edges + // win over segments. let doc = s.doc().unwrap().clone(); let at = |x, y| { let h = hit(&doc, &ve(&s), pt(x, y), 6.0); box_hit(&doc, &ve(&s), h, pt(x, y), 1.0) }; - assert_eq!(at(100.0, 100.0), Some(BoxHit::Handle(Handle::Corner(2)))); + assert_eq!(at(100.0, 100.0), None, "a point under the corner: a drag moves the points"); assert_eq!(at(100.0, 50.0), Some(BoxHit::Handle(Handle::Edge(1)))); assert_eq!(at(110.0, -10.0), Some(BoxHit::Handle(Handle::Rotate(1)))); assert_eq!(at(40.0, 60.0), Some(BoxHit::Inside)); @@ -64,27 +67,47 @@ fn the_box_shows_for_two_or_more_points_with_the_move_tool() { } #[test] -fn corners_scale_points_in_one_undo_step() { +fn corners_clear_of_points_scale_them_in_one_undo_step() { let mut s = Session::new(); - let id = rect(&mut s); + // An L of three points: the box's bottom-right corner (50, 50) has no point under it. + let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [50, 0], [0, 50]], "closed": false}))["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"id": id})); select_all(&mut s); - drag(&mut s, (100.0, 100.0), (150.0, 120.0)); - let b = s.doc().unwrap().world_bounds(id).unwrap(); - assert_eq!((b.x0, b.y0, b.x1, b.y1), (0.0, 0.0, 150.0, 120.0)); - assert_eq!(ve(&s).vertices.len(), 4, "the points stay selected"); + drag(&mut s, (50.0, 50.0), (150.0, 120.0)); + assert!(near(page_pt(&s, id, 1), pt(150.0, 0.0)) && near(page_pt(&s, id, 2), pt(0.0, 120.0)), "{:?}", page_pt(&s, id, 1)); + assert_eq!(ve(&s).vertices.len(), 3, "the points stay selected"); s.undo().unwrap(); - let b = s.doc().unwrap().world_bounds(id).unwrap(); - assert_eq!((b.width(), b.height()), (100.0, 100.0), "one undo step"); + assert!(near(page_pt(&s, id, 1), pt(50.0, 0.0)), "one undo step"); // Shift keeps proportions; Alt scales from the centre. select_all(&mut s); - drag_with(&mut s, (100.0, 100.0), (150.0, 110.0), true, false); - let b = s.doc().unwrap().world_bounds(id).unwrap(); - assert_eq!((b.x0, b.y0, b.x1, b.y1), (0.0, 0.0, 150.0, 150.0)); + drag_with(&mut s, (50.0, 50.0), (150.0, 110.0), true, false); + assert!(near(page_pt(&s, id, 1), pt(150.0, 0.0)) && near(page_pt(&s, id, 2), pt(0.0, 150.0)), "{:?}", page_pt(&s, id, 2)); s.undo().unwrap(); select_all(&mut s); - drag_with(&mut s, (100.0, 100.0), (110.0, 120.0), false, true); - let b = s.doc().unwrap().world_bounds(id).unwrap(); - assert_eq!((b.x0, b.y0, b.x1, b.y1), (-10.0, -20.0, 110.0, 120.0)); + drag_with(&mut s, (50.0, 50.0), (60.0, 70.0), false, true); + assert!( + near(page_pt(&s, id, 0), pt(-10.0, -20.0)) && near(page_pt(&s, id, 1), pt(60.0, -20.0)) && near(page_pt(&s, id, 2), pt(-10.0, 70.0)), + "{:?}", + page_pt(&s, id, 2) + ); +} + +#[test] +fn dragging_one_of_several_selected_points_moves_them_all_freely() { + let mut s = Session::new(); + let id = rect(&mut s); + let n = net(&s, id); + let (a, b) = (vertex_at(&n, 0.0, 0.0), vertex_at(&n, 100.0, 0.0)); + // Two points on one side: their box is a flat line, and the press is on a corner of it. + ex(&mut s, "vector.edit.select", json!({"vertices": [a, b]})); + drag(&mut s, (100.0, 0.0), (130.0, 40.0)); + assert!( + near(page_pt(&s, id, a), pt(30.0, 40.0)) && near(page_pt(&s, id, b), pt(130.0, 40.0)), + "{:?} {:?}", + page_pt(&s, id, a), + page_pt(&s, id, b) + ); + assert_eq!(ve(&s).vertices.len(), 2, "both stay selected"); } #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fe9a0344..e6dbc3c8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1039,8 +1039,10 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if width_tool { width_overlay(app, painter, &doc, &ve, zoom); } else if let Some(c) = app.session.vector_edit_box() { + // The box around several selected points draws no corner handles: they would read as + // points. Its corners and edges still scale the points when dragged. let label = format!("{} × {}", fmt((c[1] - c[0]).hypot()), fmt((c[3] - c[0]).hypot())); - size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 4, Some(label)); + size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 0, Some(label)); } } // Selection. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2355de89..5421cd68 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -82,7 +82,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape or Enter ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). A click on empty canvas only clears the points and starts a marquee (edit mode stays; a double-click or Escape leaves it), while a click on another layer leaves it and selects that layer. Not yet: the transform box around points of several layers; areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). A click on empty canvas only clears the points and starts a marquee (edit mode stays; a double-click or Escape leaves it), while a click on another layer leaves it and selects that layer. The box around several selected points draws its outline and size only, without corner squares (they would read as points); a press on any selected point, corners included, drags them all freely, while its corners and edges clear of points still scale by drag. Not yet: the transform box around points of several layers; areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | | Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | From 7cb4168e13dca45817b31c4ab26d892dd82d8612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Thu, 22 Oct 2026 09:03:17 +0200 Subject: [PATCH 38/87] M6: Sketch import: fixes from the PR review - --no-system-fonts holds for .sketch imports too: the CLI opts the process out, so the import's font matching doesn't scan. - Shared text styles get the installed family and platform tracking their linked layers get. - Each text run writes its letter spacing, line height and font axes, neutral when it has none, instead of taking the first run's. - The unsupported-drop hint and its catalogs list .sketch. - A test sends damaged .sketch data through file.open and file.import. - Stack items are matched to their source layers through a map. - Docs: .sketch is a convert input; the Carbon kit's Sketch 47 edition is refused (the 68 and 83 ones open). Co-Authored-By: Claude Opus 5.5 --- apps/figcraft-cli/src/main.rs | 3 ++ apps/figcraft-cli/tests/fonts.rs | 26 +++++++++++++ crates/engine/src/cmd/file.rs | 11 ++++++ crates/engine/src/sketch_import_tests.rs | 49 +++++++++++++++++++++++- crates/engine/src/tests.rs | 13 +++++++ crates/format/src/sketch/stack.rs | 11 ++++-- crates/format/src/sketch/text.rs | 12 +++--- crates/format/src/sketch/text_tests.rs | 21 ++++++++++ crates/text/src/fontdb.rs | 13 ++++++- crates/text/src/lib.rs | 4 +- crates/ui-egui/locales/bn.toml | 2 +- crates/ui-egui/locales/de.toml | 2 +- crates/ui-egui/locales/en.toml | 2 +- crates/ui-egui/locales/es.toml | 2 +- crates/ui-egui/locales/fi.toml | 2 +- crates/ui-egui/locales/fr.toml | 2 +- crates/ui-egui/locales/ka.toml | 2 +- crates/ui-egui/locales/nl.toml | 2 +- crates/ui-egui/locales/pl.toml | 2 +- crates/ui-egui/locales/pt-BR.toml | 2 +- crates/ui-egui/locales/ru.toml | 2 +- crates/ui-egui/locales/sv.toml | 2 +- crates/ui-egui/src/home.rs | 2 +- crates/ui-egui/src/home_tests.rs | 2 +- docs/cli.md | 2 +- docs/file-format-parity.md | 4 +- 26 files changed, 166 insertions(+), 31 deletions(-) diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index badc5bc3..fb96cd85 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -108,6 +108,9 @@ fn run(argv: &[String]) -> CliResult { // Text lays out with the installed fonts, like the desktop app, so headless renders and // auto layout sizes match what users see. figcraft_text::scan_system_fonts_on_demand(); + } else { + // Nothing scans them then, not even a `.sketch` import matching its fonts. + figcraft_text::opt_out_of_system_fonts(); } let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) }; match cmd.as_str() { diff --git a/apps/figcraft-cli/tests/fonts.rs b/apps/figcraft-cli/tests/fonts.rs index ef5c638f..e01dac10 100644 --- a/apps/figcraft-cli/tests/fonts.rs +++ b/apps/figcraft-cli/tests/fonts.rs @@ -67,3 +67,29 @@ fn info_lists_the_fonts_a_file_uses_that_are_missing() { let shown = String::from_utf8_lossy(&out.stdout); assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}"); } + +/// A synthetic `.sketch` file: one page with one Helvetica text. +const SKETCH: &str = "UEsDBBQAAAAIAOFFVl2B8d55RwAAAFcAAAANAAAAZG9jdW1lbnQuanNvbqtWik/OSSwuVrJSUErJTy7NTc0rUdJRUCpITE8FCUZXIynwDfYK9vdzy8xJDUpNSy1KzUtOBamNL0pNA0mD9egHKNXG1gIAUEsDBBQAAAAIAOFFVl0B3thgPQAAAEkAAAAJAAAAbWV0YS5qc29uq1ZKLChQslJQSs7P1UvKz0jNzUzMS85PycxL1yvOTi1JzjBW0lEAKQpLLSrOzM8DqTUzAYmVwQUMjUxrAVBLAwQUAAAACADhRVZdo5nc2bkAAABnAQAADAAAAHBhZ2VzL1AuanNvbm1PQQrCMBD8StmzB1voxZsgUg+CUG8iJU3XNhITaVZtLf27SWylFEPIzgyzs5sOMi6ZMbAK4M5KhEUAhc50fkVOu42TD05T7IaeDB7JWqxd16mbJBA29CfhOE3w5FJ/WQeNfZdWaYf6EgVVFseOVCjKiiwL495SRlSL/EFYpBao0geYEUIiYGoatpOaMxJaDfkSVekHRHNvB/t0PZuw1Yp+mvfMWsZfJSifSIIzt4IRbyeGUW/P2d0PUEsBAhQDFAAAAAgA4UVWXYHx3nlHAAAAVwAAAA0AAAAAAAAAAAAAAIABAAAAAGRvY3VtZW50Lmpzb25QSwECFAMUAAAACADhRVZdAd7YYD0AAABJAAAACQAAAAAAAAAAAAAAgAFyAAAAbWV0YS5qc29uUEsBAhQDFAAAAAgA4UVWXaOZ3Nm5AAAAZwEAAAwAAAAAAAAAAAAAAIAB1gAAAHBhZ2VzL1AuanNvblBLBQYAAAAAAwADAKwAAAC5AQAAAAA="; + +/// Opening a `.sketch` file (whose import matches fonts against the installed ones) keeps to +/// `--no-system-fonts` too: the run still knows only the built-in families. +#[test] +fn sketch_imports_keep_to_no_system_fonts() { + let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-sketch-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let script = d.join("open.json"); + let steps = json!([{"command": "file.open", "params": {"data": SKETCH, "name": "x.sketch"}}, {"command": "text.fonts"}]); + std::fs::write(&script, steps.to_string()).unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_figcraft-cli")) + .arg("run") + .arg(&script) + .arg("--no-system-fonts") + .env_remove("FIGCRAFT_NO_SYSTEM_FONTS") + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let lines: Vec = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines[0]["ok"], json!(true), "{lines:?}"); + let fonts: Vec = serde_json::from_value(lines[1]["result"].clone()).unwrap(); + assert_eq!(fonts, families(&["--no-system-fonts"], false), "the import didn't scan the system fonts"); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8f7da824..8bbdc735 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -633,6 +633,12 @@ fn match_font_families(doc: &mut Document) { } } } + // Shared text styles too, so they stay like the layers linked to them. + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + fix(style); + } + } } /// Sketch draws text with the tracking a font's `trak` table gives at its size (Apple's text engine @@ -668,6 +674,11 @@ fn platform_tracking(doc: &mut Document) { } } } + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + track(style); + } + } } /// Families Sketch (Apple's text system) gives a fifth more than their ascent and descent as their diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index 292cd019..7ecdd467 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -17,13 +17,22 @@ fn sketch(app_version: &str, pages: &[(&str, &str)]) -> Vec { /// [`sketch`] with `layers` on the first page. fn sketch_with(app_version: &str, pages: &[(&str, &str)], layers: &[Value]) -> Vec { + sketch_with_doc(app_version, pages, layers, json!({})) +} + +/// [`sketch_with`] with the fields of `extra` added to `document.json` (shared styles…). +fn sketch_with_doc(app_version: &str, pages: &[(&str, &str)], layers: &[Value], extra: Value) -> Vec { let refs: Vec = pages.iter().map(|(id, _)| json!({"_class": "MSJSONFileReference", "_ref": format!("pages/{id}")})).collect(); + let mut document = json!({"_class": "document", "pages": refs}); + for (k, v) in extra.as_object().into_iter().flatten() { + document[k] = v.clone(); + } let mut out = Vec::new(); { let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out)); let o = zip::write::SimpleFileOptions::default(); z.start_file("document.json", o).unwrap(); - z.write_all(json!({"_class": "document", "pages": refs}).to_string().as_bytes()).unwrap(); + z.write_all(document.to_string().as_bytes()).unwrap(); z.start_file("meta.json", o).unwrap(); // The format version that Sketch writes (103 for 50; 110 and up from 52). let version = if app_version.split('.').next().and_then(|v| v.parse::().ok()).is_some_and(|v| v >= 52) { 125 } else { 103 }; @@ -476,3 +485,41 @@ fn sketch_text_in_a_stack_stays_put() { let after = doc.world_bounds(label).unwrap(); assert!((before.y0 - after.y0).abs() < 1e-6, "{before:?} → {after:?}"); } + +/// Shared text styles get what their linked layers get on import: the installed family's name and +/// the platform's tracking (a layer and its style stay alike). +#[test] +fn sketch_shared_text_styles_match_their_layers() { + if !std::path::Path::new("/System/Library/Fonts/SFNS.ttf").exists() { + return; + } + figcraft_text::fontdb::scan_system_fonts(); + let attrs = json!({"MSAttributedStringFontAttribute": {"attributes": {"name": "SFUIText-Regular", "size": 13}}}); + let style = json!({"_class": "style", "textStyle": {"_class": "textStyle", "encodedAttributes": attrs.clone()}}); + let shared = json!({"_class": "sharedStyle", "do_objectID": "TS", "name": "Body", "value": style.clone()}); + let run = json!({"location": 0, "length": 2, "attributes": attrs}); + let mut linked = style; + linked["sharedObjectID"] = json!("TS"); + let t = shape( + "text", + "Label", + [0.0, 0.0, 100.0, 20.0], + json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0, "style": linked}), + ); + let extra = json!({"layerTextStyles": {"_class": "sharedTextStyleContainer", "objects": [shared]}}); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with_doc("64", &[("A", "Page")], &[t], extra)); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") }; + let st = doc + .styles + .iter() + .find_map(|st| match &st.kind { + figcraft_doc::StyleKind::Text { style } if st.name == "Body" => Some(style.clone()), + _ => None, + }) + .unwrap(); + assert_eq!(st.font_family, td.style.font_family); + assert_eq!(st.letter_spacing.resolve(13.0), td.style.letter_spacing.resolve(13.0), "{st:?}"); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index eeec9f04..6cf598a0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -633,3 +633,16 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } + +/// Damaged and too-old `.sketch` data sent to the file commands (which the junk sweep skips) fail +/// with an ordinary error, not a caught panic. +#[test] +fn damaged_sketch_data_fails_without_panicking() { + for data in ["UEsDBBQAAAAIAP////8=", "U1FMaXRlIGZvcm1hdCAzAA=="] { + for cmd in ["file.open", "file.import"] { + let mut s = sess(); + let r = s.execute(cmd, &json!({"data": data, "name": "x.sketch"})); + assert!(matches!(r, Err(ref e) if !matches!(e, EngineError::Internal { .. })), "{cmd}: {r:?}"); + } + } +} diff --git a/crates/format/src/sketch/stack.rs b/crates/format/src/sketch/stack.rs index a6b2ddb2..89f67ca8 100644 --- a/crates/format/src/sketch/stack.rs +++ b/crates/format/src/sketch/stack.rs @@ -62,12 +62,17 @@ pub(crate) fn apply(l: &Value, node: &mut JsonMap) { node.insert("primaryAxisSizingMode".into(), json!(mode(main))); node.insert("counterAxisSizingMode".into(), json!(mode(cross))); // Items: flow order, sizing, absolute ones. - let sources: Vec<&Value> = super::tree::layers(l).iter().collect(); + // Source layers by id (the first of a repeated id). + let mut sources: std::collections::BTreeMap<&str, &Value> = std::collections::BTreeMap::new(); + for src in super::tree::layers(l) { + if let Some(id) = src.get("do_objectID").and_then(Value::as_str) { + sources.entry(id).or_insert(src); + } + } if let Some(kids) = node.get_mut("children").and_then(Value::as_array_mut) { // Children were built one per source layer, skipped ones aside: match them by id. for kid in kids.iter_mut() { - let id = kid.get("id").and_then(Value::as_str).map(str::to_string); - let Some(src) = sources.iter().find(|s| id.is_some() && s.get("do_objectID").and_then(Value::as_str) == id.as_deref()) else { continue }; + let Some(src) = kid.get("id").and_then(Value::as_str).and_then(|id| sources.get(id).copied()) else { continue }; let Some(m) = kid.as_object_mut() else { continue }; item(src, m, row, stretch); } diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index 027de660..74cfa21c 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -129,18 +129,18 @@ pub(crate) fn type_style(cx: &mut Cx, a: &Value) -> Value { cx.warn("text without a font uses Helvetica"); } let mut s = json!({"fontFamily": family, "fontStyle": style, "fontSize": size.min(10_000.0)}); - if let Some(v) = font.and_then(|f| f.get("variation")).and_then(variations) { - s["fontVariations"] = v; - } + // Every field is written, neutral when the run has none: a run's style starts from the text's + // (its first run's), and an unwritten field would keep that one's value. + s["fontVariations"] = font.and_then(|f| f.get("variation")).and_then(variations).unwrap_or_else(|| json!({})); let p = a.get("paragraphStyle"); let line = |k: &str| p.and_then(|p| p.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); if let Some(lh) = line("maximumLineHeight").or_else(|| line("minimumLineHeight")) { s["lineHeightPx"] = json!(lh.min(1e4)); s["lineHeightUnit"] = json!("PIXELS"); + } else { + s["lineHeightUnit"] = json!("INTRINSIC_%"); } - if let Some(k) = a.get("kerning").and_then(Value::as_f64).filter(|k| k.is_finite()) { - s["letterSpacing"] = json!(k); - } + s["letterSpacing"] = json!(a.get("kerning").and_then(Value::as_f64).filter(|k| k.is_finite()).unwrap_or(0.0).clamp(-1e4, 1e4)); let set = |k: &str| a.get(k).and_then(Value::as_i64).is_some_and(|v| v != 0); s["textDecoration"] = json!(if set("underlineStyle") { "UNDERLINE" diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs index 8adef1ea..0e8a4a27 100644 --- a/crates/format/src/sketch/text_tests.rs +++ b/crates/format/src/sketch/text_tests.rs @@ -250,3 +250,24 @@ fn postscript_names_find_installed_fonts() { let (doc, _) = import(&z, "f").unwrap(); assert_eq!(data(&doc, "A").style.font_family, "Courier New PSMT"); } + +/// A run without kerning, a fixed line height or font axes has none: it doesn't take the first +/// run's (the text's base style) as its own. +#[test] +fn later_runs_dont_inherit_the_first_runs_settings() { + let mut t = text("Mixed", 0.0, 0.0, 200.0, 40.0, "AaBb", &[(0, 2, "Skia", 20.0, BLACK), (2, 2, "Helvetica", 14.0, BLACK)]); + let first = &mut t["attributedString"]["attributes"][0]["attributes"]; + first["kerning"] = json!(2); + first["paragraphStyle"]["minimumLineHeight"] = json!(24); + first["paragraphStyle"]["maximumLineHeight"] = json!(24); + first["MSAttributedStringFontAttribute"]["attributes"]["variation"] = json!({"2003265652": 3}); + let (doc, _) = open(vec![t]); + let td = data(&doc, "Mixed"); + let at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(&td.style, |r| &r.style); + assert_eq!(at(0).letter_spacing.resolve(20.0), 2.0); + let second = at(2); + assert_eq!(second.font_family, "Helvetica"); + assert_eq!(second.letter_spacing.resolve(14.0), 0.0, "{second:?}"); + assert_eq!(second.line_height, figcraft_doc::LineHeight::Auto, "{second:?}"); + assert!(second.variations.is_empty(), "{second:?}"); +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 34d76b12..b004d7e1 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -526,6 +526,8 @@ pub struct Rescan { static DB: OnceLock> = OnceLock::new(); /// Set by [`scan_system_fonts_on_demand`]. static ON_DEMAND: AtomicBool = AtomicBool::new(false); +/// Set by [`opt_out_of_system_fonts`]. +static OPTED_OUT: AtomicBool = AtomicBool::new(false); /// Done once per process: concurrent callers wait for the one scan. static SCANNED: OnceLock<()> = OnceLock::new(); /// Directory walks done (at most one). @@ -1111,10 +1113,17 @@ pub fn load_font_bytes(bytes: Vec) -> Vec { families } -/// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`): renders use only the embedded fonts. +/// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`), or the process opted out +/// ([`opt_out_of_system_fonts`]): renders use only the embedded fonts. #[cfg(not(target_arch = "wasm32"))] fn system_fonts_opted_out() -> bool { - std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0") + OPTED_OUT.load(Ordering::Relaxed) || std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0") +} + +/// Use only the embedded fonts for the rest of the process, as `FIGCRAFT_NO_SYSTEM_FONTS` does +/// (headless runs with `--no-system-fonts`): no call scans the system fonts after this. +pub fn opt_out_of_system_fonts() { + OPTED_OUT.store(true, Ordering::Relaxed); } /// Every font text can use is known: the system fonts were indexed, or there are none to index diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 50cd28dd..1663ab13 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -17,8 +17,8 @@ pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face, - has_family, installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, - system_fonts_scanned, + has_family, installed_fonts_known, load_font_bytes, no_coords, opt_out_of_system_fonts, rescan_system_fonts, scan_system_fonts, + scan_system_fonts_on_demand, system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index adedc983..eb3a88db 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -416,7 +416,7 @@ status = "draft — needs native review" "Draw" = "আঁকুন" "Draw a frame" = "একটি ফ্রেম আঁকুন" "Drawing tools" = "অঙ্কন টুলস" -"Drop a .figcraft, .fig, .json, .svg or image file." = "একটি .figcraft, .fig, .json, .svg বা ছবির ফাইল ড্রপ করুন।" +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "একটি .figcraft, .fig, .sketch, .json, .svg বা ছবির ফাইল ড্রপ করুন।" "Drop to import" = "ইম্পোর্ট করতে ড্রপ করুন" "Drop to open" = "খুলতে ড্রপ করুন" "Drop shadow" = "ড্রপ শ্যাডো" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index f61464b8..04d1af62 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -397,7 +397,7 @@ status = "draft — needs native review" "Drag the stroke to add a width point" = "Ziehen Sie an der Kontur, um einen Breitenpunkt hinzuzufügen" "Draw" = "Zeichnen" "Drawing tools" = "Zeichenwerkzeuge" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Legen Sie eine .figcraft-, .fig-, .json-, .svg- oder Bilddatei ab." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Legen Sie eine .figcraft-, .fig-, .sketch-, .json-, .svg- oder Bilddatei ab." "Drop to import" = "Zum Importieren ablegen" "Drop to open" = "Zum Öffnen ablegen" "Drop shadow" = "Schlagschatten" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ae566fc8..1180bf41 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -417,7 +417,7 @@ name = "English" "Draw" = "Draw" "Draw a frame" = "Draw a frame" "Drawing tools" = "Drawing tools" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Drop a .figcraft, .fig, .json, .svg or image file." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Drop a .figcraft, .fig, .sketch, .json, .svg or image file." "Drop to import" = "Drop to import" "Drop to open" = "Drop to open" "Drop shadow" = "Drop shadow" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..7ed208b0 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -418,7 +418,7 @@ status = "draft — needs native review" "Draw" = "Dibujar" "Draw a frame" = "Dibuja un marco" "Drawing tools" = "Herramientas de dibujo" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Suelta un archivo .figcraft, .fig, .json, .svg o de imagen." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Suelta un archivo .figcraft, .fig, .sketch, .json, .svg o de imagen." "Drop to import" = "Suelta para importar" "Drop to open" = "Suelta para abrir" "Drop shadow" = "Sombra paralela" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 205a9d29..3102fbf4 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -418,7 +418,7 @@ status = "draft — needs native review" "Draw" = "Piirrä" "Draw a frame" = "Piirrä kehys" "Drawing tools" = "Piirtotyökalut" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Pudota .figcraft-, .fig-, .json-, .svg- tai kuvatiedosto." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Pudota .figcraft-, .fig-, .sketch-, .json-, .svg- tai kuvatiedosto." "Drop shadow" = "Heittovarjo" "Drop to import" = "Tuo pudottamalla" "Drop to open" = "Avaa pudottamalla" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index b038b335..0cc87213 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -415,7 +415,7 @@ status = "draft — needs native review" "Draw" = "Dessin" "Draw a frame" = "Tracer un cadre" "Drawing tools" = "Outils de dessin" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Déposez un fichier .figcraft, .fig, .json, .svg ou une image." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Déposez un fichier .figcraft, .fig, .sketch, .json, .svg ou une image." "Drop shadow" = "Ombre portée" "Drop to import" = "Déposer pour importer" "Drop to open" = "Déposer pour ouvrir" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 5b73b4fb..20b4bce6 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -418,7 +418,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Draw" = "ხატვა" "Draw a frame" = "დახატეთ ჩარჩო" "Drawing tools" = "ხატვის ხელსაწყოები" -"Drop a .figcraft, .fig, .json, .svg or image file." = "ჩააგდეთ .figcraft, .fig, .json, .svg ან გამოსახულების ფაილი." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "ჩააგდეთ .figcraft, .fig, .sketch, .json, .svg ან გამოსახულების ფაილი." "Drop to import" = "ჩააგდეთ იმპორტისთვის" "Drop to open" = "ჩააგდეთ გასახსნელად" "Drop shadow" = "ჩრდილი" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 54851043..6d276911 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -418,7 +418,7 @@ status = "draft — needs native review" "Draw" = "Tekenen" "Draw a frame" = "Teken een frame" "Drawing tools" = "Tekengereedschap" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Laat een .figcraft-, .fig-, .json-, .svg- of afbeeldingsbestand los." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Laat een .figcraft-, .fig-, .sketch-, .json-, .svg- of afbeeldingsbestand los." "Drop shadow" = "Slagschaduw" "Drop to import" = "Loslaten om te importeren" "Drop to open" = "Loslaten om te openen" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 23f7dee8..0453ee7b 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -415,7 +415,7 @@ status = "draft — needs native review" "Draw" = "Rysuj" "Draw a frame" = "Narysuj ramkę" "Drawing tools" = "Narzędzia rysowania" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Upuść plik .figcraft, .fig, .json, .svg lub plik obrazu." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Upuść plik .figcraft, .fig, .sketch, .json, .svg lub plik obrazu." "Drop to import" = "Upuść, aby zaimportować" "Drop to open" = "Upuść, aby otworzyć" "Drop shadow" = "Cień" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d8f857e0..424a4337 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1662,7 +1662,7 @@ status = "draft — needs native review" "Dec" = "dez." "Design system starter" = "Ponto de partida para sistema de design" "Draw a frame" = "Desenhe um quadro" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Solte um arquivo .figcraft, .fig, .json, .svg ou uma imagem." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Solte um arquivo .figcraft, .fig, .sketch, .json, .svg ou uma imagem." "Drop to import" = "Solte para importar" "Drop to open" = "Solte para abrir" "Edited {when}" = "Editado {when}" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 6e173a92..2d5169a0 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -417,7 +417,7 @@ name = "Русский" "Draw" = "Рисование" "Draw a frame" = "Нарисовать Frame" "Drawing tools" = "Инструменты рисования" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Перетащите файл .figcraft, .fig, .json, .svg или изображение." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Перетащите файл .figcraft, .fig, .sketch, .json, .svg или изображение." "Drop shadow" = "Внешняя тень" "Drop to import" = "Отпустите, чтобы импортировать" "Drop to open" = "Отпустите, чтобы открыть" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d86779e0..2f8f3d95 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -412,7 +412,7 @@ name = "Svenska" "Draw" = "Rita" "Draw a frame" = "Rita en ram" "Drawing tools" = "Ritverktyg" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Släpp en .figcraft-, .fig-, .json- eller .svg-fil eller en bildfil." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Släpp en .figcraft-, .fig-, .sketch-, .json- eller .svg-fil eller en bildfil." "Drop to import" = "Släpp för att importera" "Drop to open" = "Släpp för att öppna" "Drop shadow" = "Släppskugga" diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index b532d9a7..664ca159 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -181,7 +181,7 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { let files = ctx.input(|i| i.raw.dropped_files.clone()); for f in files { if !crate::opens(f.path()) { - app.toast(tr("Drop a .figcraft, .fig, .json, .svg or image file.")); + app.toast(tr("Drop a .figcraft, .fig, .sketch, .json, .svg or image file.")); continue; } crate::pending_open::open_dropped(app, &f); diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index fc51bfb1..71225db4 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -898,7 +898,7 @@ fn dropping_an_unsupported_file_on_the_home_screen_opens_nothing() { drop_file(&mut u, &path); assert!(u.app.session.docs.is_empty()); assert!(u.app.ui.home); - assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Drop a .figcraft, .fig, .json, .svg or image file.")); + assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Drop a .figcraft, .fig, .sketch, .json, .svg or image file.")); } #[test] diff --git a/docs/cli.md b/docs/cli.md index bf783d11..84f4617f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -87,7 +87,7 @@ figcraft-cli export app.figcraft -o handoff.fig ### `convert ` -By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.fig` (best effort), `.svg` (an editable frame: groups, +By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.fig` (best effort), `.sketch` (Sketch 48 or newer, best effort), `.svg` (an editable frame: groups, shapes, text, gradients, masks and effects; `info` lists what it left out as `note:` lines), `.png/.jpg/.webp/.gif/.tif/.bmp` (an image layer). Outputs: `.figcraft`, `.json` (FigCraft JSON, pretty-printed), `.svg`, `.png/.jpg/.webp` (the current page rendered at 1x with its background), `.pdf` (the current page's diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 11867d88..5fb7532c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) | +| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -166,7 +166,7 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Sketch 2025 and newer: frames, graphics, stacks as auto layout, corners, pins and sizing, tints, per-side borders, blur lists. Checked on fixtures and locally on real Sketch 50, 64 and 2026 files and the IBM Carbon Design Kit (Sketch 47, 68, 83). Numbers unchanged | +| 2026-10-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Sketch 2025 and newer: frames, graphics, stacks as auto layout, corners, pins and sizing, tints, per-side borders, blur lists. Checked on fixtures and locally on real Sketch 50, 64 and 2026 files and the IBM Carbon Design Kit (Sketch 68 and 83 open; the Sketch 47 edition is refused). Numbers unchanged | | 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | From 785dc437fada6c3ebe127f7a8e31109543764360 Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 19:11:39 +0800 Subject: [PATCH 39/87] =?UTF-8?q?fix(vectoredit):=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E7=BB=93=E6=9D=9F=E6=97=B6=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=97=A0=E4=BB=BB=E4=BD=95=E7=BA=BF=E6=AE=B5=E7=9A=84=E7=A9=BA?= =?UTF-8?q?=E5=9B=BE=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增空图层检测函数,在结束矢量编辑模式时自动删除没有任何线段的图层,避免留下空图层或空框。同时更新相关测试用例和文档,确保该行为符合预期。 --- crates/engine/src/vectoredit.rs | 19 +++++++++++++++ crates/engine/src/vectoredit/tests.rs | 35 +++++++++++++++++++++++++++ docs/ui-parity.md | 3 ++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index a3fbf803..22755c4d 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -539,9 +539,28 @@ pub fn begin_many(s: &mut Session, ids: &[NodeId]) -> Result<()> { /// At most this many layers are edited at once. const MAX_EDITED: usize = 500; +/// The edited layers left without a single segment: they draw nothing (isolated points included), +/// so their layer would only leave an empty box behind. +fn empty_layers(doc: &Document, ids: &[NodeId]) -> Vec { + ids.iter().copied().filter(|id| network(doc, *id).is_some_and(|n| n.segments.is_empty())).collect() +} + +/// Leave edit mode. Layers whose every point was deleted go with it (the undo step that deleted +/// the last points brings them back). pub fn end(s: &mut Session) -> Result<()> { + let empty = { + let ds = s.ds()?; + ds.vector_edit.as_ref().map(|ve| empty_layers(&ds.doc, &ve.parts().iter().map(|p| p.id).collect::>())).unwrap_or_default() + }; + if !empty.is_empty() { + let doc = s.doc_mut()?; + for id in &empty { + let _ = doc.remove(*id); + } + } let ds = s.ds_mut()?; ds.vector_edit = None; + ds.selection.retain(|id| !empty.contains(id)); ds.revision += 1; s.tool.overlay = Default::default(); Ok(()) diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/vectoredit/tests.rs index ff3c4cfa..07ebd56f 100644 --- a/crates/engine/src/vectoredit/tests.rs +++ b/crates/engine/src/vectoredit/tests.rs @@ -310,6 +310,41 @@ fn a_point_dropped_on_another_merges_into_it() { assert_eq!((n.vertices.len(), n.segments.len()), (2, 1), "still a line: {n:?}"); } +#[test] +fn deleting_every_point_takes_the_layer_with_the_edit_mode() { + // Delete: no point is left at all. + let mut s = Session::new(); + let id = rect(&mut s); + ex(&mut s, "vector.edit.selectAll", json!({})); + ex(&mut s, "vector.edit.delete", json!({})); + assert!(net(&s, id).vertices.is_empty() && net(&s, id).segments.is_empty(), "no point left"); + assert!(s.doc().unwrap().node(id).is_some(), "the layer stays while editing"); + ex(&mut s, "vector.edit.end", json!({})); + assert!(s.doc().unwrap().node(id).is_none(), "it goes with edit mode instead of leaving an empty box"); + assert!(s.selection().is_empty()); + // The step that deleted the last points brings the shape back. + s.undo().unwrap(); + assert_eq!(net(&s, id).vertices.len(), 4); + // Shift+Delete (heal): the last point is left on its own with no segment, drawing nothing either. + let mut s = Session::new(); + let id = rect(&mut s); + ex(&mut s, "vector.edit.selectAll", json!({})); + ex(&mut s, "vector.deleteHeal", json!({})); + assert!(net(&s, id).segments.is_empty(), "nothing draws: {:?}", net(&s, id)); + ex(&mut s, "vector.edit.end", json!({})); + assert!(s.doc().unwrap().node(id).is_none(), "an unconnected point left behind doesn't keep the layer"); + // Escape ends edit mode the same way, for every layer edited along that is left empty. + let mut s = Session::new(); + let a = crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap(); + let b = crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": 200, "y": 0, "width": 50, "height": 50}))["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]})); + ex(&mut s, "vector.edit.selectAll", json!({})); + ex(&mut s, "vector.edit.delete", json!({})); + s.escape().unwrap(); + assert!(s.ds().unwrap().vector_edit.is_none()); + assert!(s.doc().unwrap().node(a).is_none() && s.doc().unwrap().node(b).is_none()); +} + #[test] fn undo_back_past_the_conversion_leaves_edit_mode() { let mut s = Session::new(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5421cd68..e2e2318a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -82,7 +82,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape or Enter ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). A click on empty canvas only clears the points and starts a marquee (edit mode stays; a double-click or Escape leaves it), while a click on another layer leaves it and selects that layer. The box around several selected points draws its outline and size only, without corner squares (they would read as points); a press on any selected point, corners included, drags them all freely, while its corners and edges clear of points still scale by drag. Not yet: the transform box around points of several layers; areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; a layer left with no point at all goes when edit mode ends (no empty layer or box behind); rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). A click on empty canvas only clears the points and starts a marquee (edit mode stays; a double-click or Escape leaves it), while a click on another layer leaves it and selects that layer. The box around several selected points draws its outline and size only, without corner squares (they would read as points); a press on any selected point, corners included, drags them all freely, while its corners and edges clear of points still scale by drag. Not yet: the transform box around points of several layers; areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | | Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | A layer whose every point was deleted goes when edit mode ends (Escape, Enter, a double-click or a click on another layer), instead of staying as an empty layer with the old box; the undo step that deleted the last points brings the shape back (`vectoredit::tests`) | | 2026-10-22 | minor | Vector edit mode stays on a click on empty canvas: with the Move tool it only clears the points and starts a marquee, and a double-click (or Escape) leaves it; a click on another layer still leaves it and selects that layer. A double-click on empty canvas leaves it even straight after the one that entered edit mode (the platform counts those clicks on, so the last one arrives as a triple; on empty canvas a triple means a double here, while triples on the vector keep their own meaning) (`vector_behaviour_tests`, `canvas_double_click_tests`). Checked in the app on Windows with real mouse clicks over the control channel | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | | 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | From 37921f48ceeb19466cf7fe656f8f553a36ec542c Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 19:51:25 +0800 Subject: [PATCH 40/87] =?UTF-8?q?feat(inspect):=20=E4=B8=BA=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E5=88=97=E8=A1=A8=E6=B7=BB=E5=8A=A0=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E4=B8=8B=E6=8B=89=E8=8F=9C=E5=8D=95=E5=92=8C?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E7=BC=A9=E7=95=A5=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/dev_mode_tests.rs | 25 +++++++ crates/ui-egui/src/right.rs | 104 +++++++++++++++++++++------ docs/dev-mode-parity.md | 5 +- 6 files changed, 115 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index aa9f97b3..f9fa6437 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -544,6 +544,7 @@ name = "English" "Font size" = "Font size" "Font style" = "Font style" "Forget on this computer" = "Forget on this computer" +"Format" = "Format" "Four screens of a trail app with components and a prototype flow" = "Four screens of a trail app with components and a prototype flow" "Fractions" = "Fractions" "Frame" = "Frame" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index f47ab965..fd36f60f 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -545,6 +545,7 @@ status = "draft — needs native review" "Font size" = "Tamaño de fuente" "Font style" = "Estilo de fuente" "Forget on this computer" = "Olvidar en este ordenador" +"Format" = "Formato" "Four screens of a trail app with components and a prototype flow" = "Cuatro pantallas de una app de senderos con componentes y un flujo de prototipo" "Fractions" = "Fracciones" "Frame" = "Marco" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index befa9685..ce1c6d65 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -517,6 +517,7 @@ status = "draft — needs native review" "Font size" = "字号" "Font style" = "字体样式" "Forget on this computer" = "在这台电脑上忘记" +"Format" = "格式" "Fractions" = "分数" "Frame" = "画框" "Frame selection" = "为所选项添加画框" diff --git a/crates/ui-egui/src/dev_mode_tests.rs b/crates/ui-egui/src/dev_mode_tests.rs index 941f1b2c..9c62f8c9 100644 --- a/crates/ui-egui/src/dev_mode_tests.rs +++ b/crates/ui-egui/src/dev_mode_tests.rs @@ -80,3 +80,28 @@ fn annotation_filter_and_categories_panel() { assert_eq!(CategoryFilter::parse(&doc, "none"), Some(CategoryFilter::Uncategorized)); assert_eq!(CategoryFilter::parse(&doc, "nope"), None); } + +#[test] +fn the_assets_list_shows_a_slice_with_a_preview_name_and_export_format() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 200, "name": "Login"})).unwrap()); + s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 80, "height": 40, "parent": f, "props": {"fill": "#FF0000"}})).unwrap(); + // A slice with one export setting: its own format is what the row offers first. + let slice = id(&s.execute("node.create", &json!({"type": "slice", "x": 10, "y": 10, "width": 100, "height": 60, "name": "Avatar"})).unwrap()); + s.execute("export.add", &json!({"ids": [slice], "setting": {"format": "webp", "size": "2x"}})).unwrap(); + s.execute("select.set", &json!({"ids": [slice]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.ctx.enable_accesskit(); + let out = u.frame_output(vec![]); + let nodes: Vec = + out.platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect(); + let find = |role: egui::accesskit::Role, label: &str| { + nodes.iter().find(|n| n.role() == role && n.label() == Some(label)).unwrap_or_else(|| panic!("no {role:?} named {label:?}")) + }; + // The row exports by a click, and the format under the name is a dropdown. + find(egui::accesskit::Role::Button, "Export Avatar"); + let fmt = find(egui::accesskit::Role::ComboBox, "Format"); + assert_eq!(fmt.value(), Some("WebP")); + assert_eq!(fmt.is_expanded(), Some(false)); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index b2babc56..60aade81 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -2251,9 +2251,23 @@ pub(crate) fn prop_row(app: &mut FigApp, ui: &mut Ui, p: &figcraft_engine::devin } } -/// The selection's assets (layers with export settings, icons, images): click one to export it, -/// or export them all. -fn inspect_assets(app: &mut FigApp, ui: &mut Ui, assets: &[figcraft_engine::devinspect::Asset]) { +/// The thumbnails and chosen export formats of the Inspect tab's assets, kept between frames and +/// dropped when the document changes. +#[derive(Clone, Default)] +struct AssetUi { + revision: u64, + thumbs: std::collections::HashMap>, + formats: std::collections::HashMap, +} + +/// An asset row's height and its preview's side. +const ASSET_ROW_H: f32 = 56.0; +const ASSET_THUMB: f32 = 40.0; + +/// The selection's assets (layers with export settings, icons, images) as a list of a preview, a +/// name and a format: click one to export it in that format, or export them all with their own +/// export settings. +fn inspect_assets(app: &mut FigApp, ui: &mut Ui, doc: &Document, assets: &[figcraft_engine::devinspect::Asset]) { let t = Tokens::get(ui.ctx()); widgets::separator(ui); let export_all = [(Icon::Export, tr("Export all"))]; @@ -2262,30 +2276,80 @@ fn inspect_assets(app: &mut FigApp, ui: &mut Ui, assets: &[figcraft_engine::devi widgets::note(ui, tr("No assets: no export settings, icons or images. Add export settings in the Design tab."), t.text_secondary); return; } - let mut export: Option> = all.then(|| assets.iter().map(|a| a.id).collect()); + let mut export_all_ids: Option> = all.then(|| assets.iter().map(|a| a.id).collect()); + let mut export_one: Option<(NodeId, ExportSetting, String)> = None; + let revision = app.session.ds().map(|d| d.revision).unwrap_or(0); + let cache_id = Id::new("inspect_asset_ui"); + let mut state: AssetUi = ui.data(|d| d.get_temp(cache_id)).unwrap_or_default(); + if state.revision != revision { + state.revision = revision; + state.thumbs.clear(); + state.formats.clear(); + } + let px = ASSET_THUMB * ui.ctx().pixels_per_point(); for a in assets { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ASSET_ROW_H), Sense::click()); let resp = resp.tip(trf("Export {name}", &[("name", &a.name)])); if resp.hovered() { - ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover); + ui.painter().rect_filled(r.shrink2(vec2(8.0, 1.0)), CornerRadius::same(6), t.hover); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - PAD - 6.0, r.center().y), vec2(12.0, 12.0)), Icon::Export, t.icon); } - let name_r = Rect::from_min_max(pos2(r.min.x + PAD, r.min.y), pos2(r.min.x + 96.0 - 4.0, r.max.y)); - ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x, r.center().y), Align2::LEFT_CENTER, &a.name, regular(11.0), t.text); - let fmt_r = Rect::from_min_max(pos2(r.min.x + 96.0, r.min.y), pos2(r.max.x - PAD - 16.0, r.max.y)); - ui.painter().with_clip_rect(fmt_r).text( - pos2(fmt_r.min.x, r.center().y), - Align2::LEFT_CENTER, - a.exports.join(", "), - regular(11.0), - t.text_secondary, - ); - if resp.clicked() { - export = Some(vec![a.id]); + // The preview, as exporting it paints it (a slice shows the page under it). + let thumb = Rect::from_min_size(pos2(r.min.x + PAD, r.center().y - ASSET_THUMB / 2.0), vec2(ASSET_THUMB, ASSET_THUMB)); + ui.painter().rect_filled(thumb, CornerRadius::same(4), t.field); + let tex = state.thumbs.entry(a.id).or_insert_with(|| crate::assets_panel::thumbnail(ui.ctx(), doc, a.id, px)).clone(); + if let Some(tex) = tex { + let size = tex.size_vec2(); + let inner = thumb.shrink(3.0); + // Never past 1:1 physical pixels, so small assets stay crisp. + let k = (inner.width() / size.x).min(inner.height() / size.y).min(1.0 / ui.ctx().pixels_per_point()).max(0.0); + let img = Rect::from_center_size(thumb.center(), size * k); + ui.painter().with_clip_rect(inner).image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + ui.painter().rect_stroke(thumb, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + // The name, and the format to export it as (its first export setting's, until chosen). + let x = thumb.max.x + 10.0; + let name_r = Rect::from_min_max(pos2(x, r.min.y + 5.0), pos2(r.max.x - PAD, r.min.y + 27.0)); + let name = widgets::elide(ui, &a.name, name_r.width()); + ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x, name_r.center().y), Align2::LEFT_CENTER, &name, regular(11.0), t.text); + let idx = state + .formats + .get(&a.id) + .copied() + .filter(|i| *i < ExportFormat::ALL.len()) + .unwrap_or_else(|| ExportFormat::ALL.iter().position(|f| Some(*f) == a.settings.first().map(|s| s.format)).unwrap_or(0)); + let fmt = ExportFormat::ALL[idx]; + let btn = Rect::from_min_size(pos2(x, r.min.y + 31.0), vec2(74.0, 20.0)); + let btn_resp = ui.interact(btn, Id::new(("inspect_asset_format", a.id)), Sense::click()); + a11y::combo(&btn_resp, tr("Format"), fmt.label(), a11y::menu_open(&btn_resp)); + if btn_resp.hovered() { + ui.painter().rect_filled(btn, CornerRadius::same(5), t.field); + ui.painter().rect_stroke(btn, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), egui::StrokeKind::Inside); + } else { + ui.painter().rect_filled(btn, CornerRadius::same(5), t.field); + } + ui.painter().text(pos2(btn.min.x + 8.0, btn.center().y), Align2::LEFT_CENTER, fmt.label(), regular(11.0), t.text); + icons::paint(ui.painter(), Rect::from_center_size(pos2(btn.max.x - 9.0, btn.center().y), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); + menu_popup(&btn_resp).show(|ui| { + for (i, f) in ExportFormat::ALL.iter().enumerate() { + if menu_item(ui, f.label(), "") { + state.formats.insert(a.id, i); + ui.close(); + } + } + }); + if resp.clicked() && !btn_resp.hovered() { + let mut setting = a.settings.first().cloned().unwrap_or_default(); + setting.format = fmt; + let file = format!("{}{}.{}", a.name.rsplit('/').next().unwrap_or(&a.name), setting.file_suffix(), fmt.ext()); + export_one = Some((a.id, setting, file)); } } - if let Some(ids) = export { + ui.data_mut(|d| d.insert_temp(cache_id, state)); + if let Some(ids) = export_all_ids.take() { crate::export_panel::export_layers(app, &ids, true); + } else if let Some((id, setting, file)) = export_one { + crate::export_panel::save_one(app, &[id], &setting, &file); } ui.add_space(4.0); } @@ -2329,7 +2393,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) { for s in figcraft_engine::devinspect::properties_with(&doc, first, fmt) { inspect_section(app, ui, &s); } - inspect_assets(app, ui, &figcraft_engine::devinspect::assets(&doc, &sel)); + inspect_assets(app, ui, &doc, &figcraft_engine::devinspect::assets(&doc, &sel)); use figcraft_engine::codegen::CodeLang; let lang_id = Id::new("inspect_lang"); let lang = ui.data(|d| d.get_temp::(lang_id)).and_then(|i| CodeLang::ALL.get(i).copied()).unwrap_or_default(); diff --git a/docs/dev-mode-parity.md b/docs/dev-mode-parity.md index cba5e81d..7d08f90a 100644 --- a/docs/dev-mode-parity.md +++ b/docs/dev-mode-parity.md @@ -1,6 +1,6 @@ # Dev Mode parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (dev resources links; inspect behaviour suite and fixes: colour formats, rem, blend, positions from the frame, CSS for rotation, gradients and text, icons and images as assets; code connect and Dev Mode plugins) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (the assets list shows a preview, the name and an export format per asset) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) **Breadth ~96%, ready ~72% (estimated).** The inspect surface is broad, components connect to the code components of a repository and layers carry dev resources links; left: import lines @@ -11,7 +11,7 @@ inside generated layer trees. Remaining: 0–1 h. | Dev Mode toggle (Shift+D), Inspect panel | done | | | Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) | | Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration | -| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours | +| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG) as a list of a preview, the name and a format to pick (its first export setting's until changed); a click exports the asset in that format, "Export all" uses each layer's own export settings; the icon rule is ours | | Annotations: pinned properties, categories, coloured callouts, filter | done | | | Ready for dev list, statuses, "Changed" after edits | done | | | Focus view (one frame, previous / next) | done | | @@ -25,6 +25,7 @@ inside generated layer trees. Remaining: 0–1 h. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | The Inspect tab's assets are a list of a thumbnail, the name and a format dropdown, instead of a name with a text summary: a slice shows the page content under it (as its export does), the format starts from the asset's first export setting and picking another exports that asset in it; "Export all" is unchanged (`dev_mode_tests::the_assets_list_shows_a_slice_with_a_preview_name_and_export_format`) | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | | 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}`). Dev Mode breadth 90% → 93%, ready 64% → 70%, hours unchanged (1–3, dev resources); feature breadth and ready for real work unchanged at this rounding | | 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–720 h | From 7e28d58b3fddcda543e6386d4abd3218e280c946 Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 20:36:45 +0800 Subject: [PATCH 41/87] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20ArkUI=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=94=9F=E6=88=90=E5=92=8C=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加 ArkUI 作为新的代码语言支持,包括: - 代码生成:支持生成 ArkTS 组件代码,包括 `@Component` 结构体、`@Prop` 参数、`@BuilderParam` 构建器等 - 代码连接:支持将组件连接到 ArkTS 代码,包括导入路径和模板配置 - 测试:添加 ArkUI 相关测试用例,验证代码生成和连接功能的正确性 - 更新文档:更新开发模式 parity 文档、文件格式一致性文档等,添加 ArkUI 相关说明 - 更新国际化:在所有支持的语言中添加 ArkUI 选项提示 --- crates/engine/src/code_connect.rs | 15 +- crates/engine/src/code_connect_tests.rs | 12 +- crates/engine/src/codegen.rs | 270 ++++++++++++++++++- crates/engine/src/component_codegen.rs | 126 +++++++-- crates/engine/src/component_codegen_tests.rs | 36 +++ crates/engine/src/playground.rs | 4 +- crates/ui-egui/locales/bn.toml | 2 +- crates/ui-egui/locales/cs.toml | 2 +- crates/ui-egui/locales/de.toml | 2 +- crates/ui-egui/locales/en.toml | 2 +- crates/ui-egui/locales/es.toml | 2 +- crates/ui-egui/locales/fi.toml | 2 +- crates/ui-egui/locales/fr.toml | 2 +- crates/ui-egui/locales/id.toml | 2 +- crates/ui-egui/locales/ja.toml | 2 +- crates/ui-egui/locales/ka.toml | 2 +- crates/ui-egui/locales/ko.toml | 2 +- crates/ui-egui/locales/nl.toml | 2 +- crates/ui-egui/locales/pl.toml | 2 +- crates/ui-egui/locales/pt-BR.toml | 2 +- crates/ui-egui/locales/ru.toml | 2 +- crates/ui-egui/locales/sv.toml | 2 +- crates/ui-egui/locales/tr.toml | 2 +- crates/ui-egui/locales/zh-Hans.toml | 2 +- crates/ui-egui/src/dev_mode.rs | 2 +- docs/dev-mode-parity.md | 7 +- docs/file-format-parity.md | 2 +- docs/gaps.md | 2 +- docs/mcp.md | 15 +- docs/target-app-parity.md | 2 +- 30 files changed, 464 insertions(+), 65 deletions(-) diff --git a/crates/engine/src/code_connect.rs b/crates/engine/src/code_connect.rs index 46340636..d63f4978 100644 --- a/crates/engine/src/code_connect.rs +++ b/crates/engine/src/code_connect.rs @@ -37,7 +37,7 @@ const MAX_WARNINGS: usize = 50; /// The languages a component can be connected in. pub fn language(s: &str) -> Option { - CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose)) + CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi)) } /// The layer `id`'s links are stored on: its component set, else its component (an instance's @@ -64,7 +64,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String { let path = &link.import_path; match lang { _ if path.is_empty() => String::new(), - CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)), + CodeLang::React | CodeLang::ArkUi => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)), CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()), _ => format!("import {path}"), } @@ -175,6 +175,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) -> (Some(l), CodeLang::React) => format!("<{} />", l.name), (Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name), (Some(l), CodeLang::SwiftUi) => format!("{}()", l.name), + (Some(l), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name), (Some(l), _) => format!("{{ {}() }}", l.name), (None, _) => value_expr(doc, c, p, v, lang), } @@ -222,6 +223,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String { format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::()) } CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")), + CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")), _ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::>().join(", ")), } } @@ -239,13 +241,14 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res } let path = &link.import_path; let path_ok = match lang { - CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'), + CodeLang::React | CodeLang::ArkUi => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'), CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'), _ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'), }; if path.chars().count() > MAX_IMPORT || !path_ok { return Err(match lang { CodeLang::React => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"), + CodeLang::ArkUi => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"), CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"), _ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"), }); @@ -282,7 +285,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result> { /// The `language` parameter (default React). fn lang_param(cmd: &str, p: &Value) -> Result { match text(cmd, p, "language")? { - Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose")), + Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose|arkui")), None => Ok(CodeLang::React), } } @@ -312,7 +315,7 @@ pub fn specs() -> Vec { spec!( "codeConnect.set", "Connect to code", - "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}", + "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}", true, |s, p| { let cmd = "codeConnect.set"; @@ -427,7 +430,7 @@ fn import(s: &mut Session, p: &Value) -> Result { let (Some(component), Some(lang), Some(name), Some(template)) = (field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str)) else { - warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose), name and template strings", i + 1)); + warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose|arkui), name and template strings", i + 1)); continue; }; let link = CodeLink { diff --git a/crates/engine/src/code_connect_tests.rs b/crates/engine/src/code_connect_tests.rs index 9d657d5f..6cdd8e67 100644 --- a/crates/engine/src/code_connect_tests.rs +++ b/crates/engine/src/code_connect_tests.rs @@ -72,6 +72,16 @@ fn connected_instances_show_the_real_snippet() { json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": ""}), ); assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n\n"); + // ArkUI: an ArkTS import and an object-literal call. + ex( + &mut s, + "codeConnect.set", + json!({"id": c, "language": "arkui", "importPath": "@acme/arkui", "template": "Card({ title: {title}, dotted: {Show dot} })"}), + ); + assert_eq!(code(&mut s, i, "arkui"), "import { Card } from \"@acme/arkui\";\n\nCard({ title: \"Say \\\"hi\\\"\", dotted: false })\n"); + let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "arkui"})); + assert_eq!(v["usage"], "Card({ title: \"Say \\\"hi\\\"\", dotted: false })\n"); + assert_eq!(v["connected"]["import"], "import { Card } from \"@acme/arkui\";"); assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path"); ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"})); // inspect.componentCode: the snippet as the usage, and the link. @@ -105,7 +115,7 @@ fn connected_instances_show_the_real_snippet() { assert!(code(&mut s, i, "swiftui").starts_with("import AcmeUI")); ex(&mut s, "edit.undo", json!({})); assert!(code(&mut s, i, "react").starts_with("import { Card }")); - assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 2); + assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 3); assert!(s.execute("codeConnect.remove", &json!({"id": c})).is_err(), "nothing left"); } diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 0871f886..efca85d4 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -1,9 +1,9 @@ //! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the -//! layer itself, HTML or React for its subtree, and SwiftUI or Jetpack Compose (stacks for auto -//! layout, absolute offsets otherwise). Components become components whose properties are -//! parameters and instances become calls passing their values (see [`crate::component_codegen`]); -//! variables bound to a layer become CSS custom properties. The output is a starting point, not a -//! pixel-exact translation. +//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose or ArkUI (HarmonyOS; +//! stacks for auto layout, absolute offsets otherwise). Components become components whose +//! properties are parameters and instances become calls passing their values (see +//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties. The +//! output is a starting point, not a pixel-exact translation. use figcraft_color::{Color, PaintKind}; use figcraft_doc::*; @@ -18,13 +18,13 @@ pub fn specs() -> Vec { vec![spec!( "inspect.code", "Code for layer", - "{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", + "{id?, language?: css|html|react|leptos|swiftui|compose|arkui (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", false, |s, p| { let cmd = "inspect.code"; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; let lang = match string(p, "language") { - Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose"))?, + Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose|arkui"))?, None => CodeLang::Css, }; let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() { @@ -63,10 +63,13 @@ pub enum CodeLang { Leptos, SwiftUi, Compose, + /// HarmonyOS ArkUI: an ArkTS struct (`@Component`, `build`). + ArkUi, } impl CodeLang { - pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose]; + pub const ALL: [CodeLang; 7] = + [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose, CodeLang::ArkUi]; pub fn id(self) -> &'static str { match self { CodeLang::Css => "css", @@ -75,6 +78,7 @@ impl CodeLang { CodeLang::Leptos => "leptos", CodeLang::SwiftUi => "swiftui", CodeLang::Compose => "compose", + CodeLang::ArkUi => "arkui", } } pub fn label(self) -> &'static str { @@ -85,6 +89,7 @@ impl CodeLang { CodeLang::Leptos => "Leptos", CodeLang::SwiftUi => "SwiftUI", CodeLang::Compose => "Compose", + CodeLang::ArkUi => "ArkUI", } } /// Languages whose lengths are CSS pixels (they can be shown in rem). @@ -95,6 +100,9 @@ impl CodeLang { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { return Some(CodeLang::React); } + if s.eq_ignore_ascii_case("arkts") { + return Some(CodeLang::ArkUi); + } CodeLang::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s)) } } @@ -194,7 +202,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String { match lang { CodeLang::Css => css(doc, id), CodeLang::Html => html(doc, id), - CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang), + CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi => crate::component_codegen::code(doc, id, lang), } } @@ -1135,6 +1143,218 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) { } } +// ------------------------------------------------------------------ ArkUI (ArkTS) + +/// A colour as ArkUI writes it: `'#RRGGBB'`, or `'#AARRGGBB'` with transparency. +fn ark_color(c: Color) -> String { + let [r, g, b, a] = c.to_rgba8(); + if a == 255 { format!("'#{r:02X}{g:02X}{b:02X}'") } else { format!("'#{a:02X}{r:02X}{g:02X}{b:02X}'") } +} + +/// A layer's corner radii as `.borderRadius(...)` (uniform or per corner). +fn ark_radius(n: &Node, mods: &mut Vec) { + if let Some(r) = n.corners.uniform() { + if r > 0.0 { + mods.push(format!(".borderRadius({})", num(r))); + } + } else if n.corners.radii.iter().any(|r| *r > 0.0) { + let [tl, tr, br, bl] = n.corners.radii; + mods.push(format!(".borderRadius({{ topLeft: {}, topRight: {}, bottomRight: {}, bottomLeft: {} }})", num(tl), num(tr), num(br), num(bl))); + } +} + +/// ArkUI for a layer and its subtree at `depth`; `extra` attributes go last (a free child's +/// position). A layer a boolean parameter shows sits in an `if`. +pub(crate) fn arkui(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra: &[String]) { + let Some(n) = ctx.doc.node(id) else { return }; + if id != ctx.root + && let Some(p) = ctx.param(n, "visible") + { + let pad = indent(depth); + out.push_str(&format!("{pad}if (this.{}) {{\n", p.ident)); + arkui_layer(ctx, n, depth + 1, out, extra); + out.push_str(&format!("{pad}}}\n")); + return; + } + arkui_layer(ctx, n, depth, out, extra); +} + +fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) { + let doc = ctx.doc; + let pad = indent(depth); + if ctx.too_deep(depth) { + out.push_str(&format!("{pad}// {} …\n", one_line(&n.name))); + return; + } + // Nested instances are calls; an instance swap parameter is a builder. + let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) { + Some(p) => Some(format!("{pad}this.{}()\n", p.ident)), + None => ctx.call(n, CodeLang::ArkUi, depth), + }; + if let Some(call) = call { + out.push_str(&call); + for m in extra { + out.push_str(&format!("{pad} {m}\n")); + } + return; + } + let mut mods: Vec = Vec::new(); + match &n.kind { + NodeKind::Text(t) => { + let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| format!("this.{}", p.ident)); + out.push_str(&format!("{pad}Text({text})\n")); + let s = &t.style; + mods.push(format!(".fontSize({})", num(s.font_size))); + mods.push(format!(".fontWeight({})", s.weight().clamp(100, 900))); + mods.push(format!(".fontFamily({})", literal(&s.font_family, false))); + if s.italic() { + mods.push(".fontStyle(FontStyle.Italic)".into()); + } + if let Some(c) = first_fill(n) { + mods.push(format!(".fontColor({})", ark_color(c))); + } + match s.line_height { + LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))), + LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))), + LineHeight::Auto => {} + } + match s.letter_spacing { + LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))), + LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))), + _ => {} + } + match t.align_h { + TextAlignH::Left => {} + TextAlignH::Center => mods.push(".textAlign(TextAlign.Center)".into()), + TextAlignH::Right => mods.push(".textAlign(TextAlign.End)".into()), + TextAlignH::Justified => mods.push(".textAlign(TextAlign.JUSTIFY)".into()), + } + match s.case { + TextCase::Upper => mods.push(".textCase(TextCase.UpperCase)".into()), + TextCase::Lower => mods.push(".textCase(TextCase.LowerCase)".into()), + _ => {} + } + match s.decoration { + TextDecoration::Underline => mods.push(".decoration({ type: TextDecorationType.Underline })".into()), + TextDecoration::Strikethrough => mods.push(".decoration({ type: TextDecorationType.LineThrough })".into()), + TextDecoration::None => {} + } + } + NodeKind::Ellipse { .. } => { + out.push_str(&format!("{pad}Ellipse({{ width: {}, height: {} }})\n", num(n.width), num(n.height))); + if let Some(c) = first_fill(n) { + mods.push(format!(".fill({})", ark_color(c))); + } + if let Some(c) = stroke_color(n) { + mods.push(format!(".stroke({})", ark_color(c))); + mods.push(format!(".strokeWidth({})", num(n.stroke.weight))); + } + } + NodeKind::Rectangle => { + out.push_str(&format!("{pad}Column()\n")); + if let Some(c) = first_fill(n) { + mods.push(format!(".backgroundColor({})", ark_color(c))); + } + ark_radius(n, &mut mods); + mods.push(format!(".width({})", num(n.width))); + mods.push(format!(".height({})", num(n.height))); + } + k if k.is_frame_like() || matches!(k, NodeKind::Group) => { + let open = match n.layout.as_ref().map(|l| (l.mode, l)) { + Some((LayoutMode::Vertical, l)) => format!("Column({{ space: {} }})", num(l.gap)), + Some((_, l)) => format!("Row({{ space: {} }})", num(l.gap)), + None => "Stack({ alignContent: Alignment.TopStart })".to_string(), + }; + out.push_str(&format!("{pad}{open} {{\n")); + match ctx.param(n, "slot") { + // A slot shows the instance's content, or else the main's (its default builder). + Some(p) => out.push_str(&format!("{pad} this.{}()\n", p.ident)), + None => { + for c in ctx.kids(n) { + // Free children sit at their position inside a Stack. + let pos: Vec = match doc.node(c).filter(|_| n.layout.is_none()) { + Some(cn) => { + let [_, _, _, _, x, y] = cn.transform.as_coeffs(); + vec![format!(".position({{ x: {}, y: {} }})", num(x), num(y))] + } + None => Vec::new(), + }; + arkui(ctx, c, depth + 1, out, &pos); + } + } + } + out.push_str(&format!("{pad}}}\n")); + if let Some(l) = &n.layout { + let [t, r, b, lft] = l.padding; + if [t, r, b, lft].iter().any(|v| *v > 0.0) { + mods.push(format!(".padding({{ top: {}, right: {}, bottom: {}, left: {} }})", num(t), num(r), num(b), num(lft))); + } + let justify = match l.main_align { + MainAlign::Min => None, + MainAlign::Center => Some("FlexAlign.Center"), + MainAlign::Max => Some("FlexAlign.End"), + MainAlign::SpaceBetween => Some("FlexAlign.SpaceBetween"), + MainAlign::SpaceAround => Some("FlexAlign.SpaceAround"), + MainAlign::SpaceEvenly => Some("FlexAlign.SpaceEvenly"), + }; + if let Some(j) = justify { + mods.push(format!(".justifyContent({j})")); + } + let cross = match l.cross_align { + CrossAlign::Min => None, + CrossAlign::Center => Some("Center"), + CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }), + CrossAlign::Baseline => None, + }; + if let Some(c) = cross { + let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" }; + mods.push(format!(".alignItems({ty}.{c})")); + } + } + mods.push(format!(".width({})", num(n.width))); + mods.push(format!(".height({})", num(n.height))); + if let Some(c) = first_fill(n) { + mods.push(format!(".backgroundColor({})", ark_color(c))); + } + if radius(n) > 0.0 { + mods.push(".clip(true)".into()); + } + ark_radius(n, &mut mods); + } + _ => { + out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image($r('app.media.asset'))\n", n.kind.label())); + mods.push(format!(".width({})", num(n.width))); + mods.push(format!(".height({})", num(n.height))); + } + } + // Ellipses take their stroke as a shape attribute (above). + if !matches!(n.kind, NodeKind::Ellipse { .. }) + && let Some(c) = stroke_color(n) + { + mods.push(format!(".border({{ width: {}, color: {} }})", num(n.stroke.weight), ark_color(c))); + } + if let Some(s) = drop_shadow(n) { + mods.push(format!( + ".shadow({{ radius: {}, color: {}, offsetX: {}, offsetY: {} }})", + num(s.radius / 2.0), + ark_color(s.color), + num(s.offset.0), + num(s.offset.1) + )); + } + if n.opacity < 1.0 { + mods.push(format!(".opacity({})", num(f64::from(n.opacity)))); + } + let rot = figcraft_geom::rotation_degrees(n.transform); + if rot.abs() > 0.01 && rot.is_finite() { + mods.push(format!(".rotate({{ angle: {} }})", num(-rot))); + } + mods.extend(extra.iter().cloned()); + for m in mods { + out.push_str(&format!("{pad} {m}\n")); + } +} + #[cfg(test)] mod tests { use super::*; @@ -1174,6 +1394,33 @@ mod tests { assert!(s.execute("inspect.code", &json!({"id": f, "language": "cobol"})).is_err()); } + #[test] + fn arkui_for_an_auto_layout_card() { + let mut s = Session::new(); + let f = card(&mut s); + let doc = s.doc().unwrap(); + let ark = code_for(doc, f, CodeLang::ArkUi); + for want in [ + "Row({ space: ", + "Text(\"Say \\\"hi\\\" $5\\nnow\")", + ".fontSize(", + ".fontWeight(", + "Ellipse({ width: 24, height: 24 })", + ".backgroundColor('#FFFFFF')", + ".borderRadius(12)", + ".width(", + ".height(100)", + ".clip(true)", + ] { + assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}"); + } + // Through the command, and "arkts" reads as ArkUI too. + let v = s.execute("inspect.code", &json!({"id": f, "language": "arkui"})).unwrap(); + assert_eq!(v["code"].as_str().unwrap(), ark); + assert_eq!(v["language"], "arkui"); + assert_eq!(s.execute("inspect.code", &json!({"id": f, "language": "arkts"})).unwrap()["language"], "arkui"); + } + #[test] fn free_children_get_offsets_and_deep_trees_are_capped() { let mut s = Session::new(); @@ -1189,6 +1436,9 @@ mod tests { assert!(sw.contains("…"), "depth capped"); let kt = code_for(doc, root, CodeLang::Compose); assert!(kt.contains("Modifier.offset(7.dp, 9.dp)")); + let ark = code_for(doc, root, CodeLang::ArkUi); + assert!(ark.contains(".position({ x: 7, y: 9 })"), "{ark}"); + assert!(ark.contains("…"), "depth capped"); assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty()); } @@ -1204,7 +1454,7 @@ mod tests { .unwrap(); } let doc = s.doc().unwrap(); - for lang in [CodeLang::SwiftUi, CodeLang::Compose] { + for lang in [CodeLang::SwiftUi, CodeLang::Compose, CodeLang::ArkUi] { let code = code_for(doc, root, lang); assert!(code.contains("// deep evil() more() …\n"), "{code}"); for line in code.lines() { diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index 1416b15f..c52693c5 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -1,16 +1,17 @@ -//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI or Jetpack -//! Compose component whose properties are its parameters (a variant property → a union or enum, boolean → -//! a flag showing the layers bound to it, text → a string, instance swap → a view parameter, slot → -//! optional content), and an instance becomes a call passing the values it sets. Variants of a set -//! share one definition (the set's default variant); in React and Leptos their differences become CSS -//! rules on `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component -//! connected to code (see [`crate::code_connect`]) shows its real snippet instead. +//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI, Jetpack +//! Compose or ArkUI component whose properties are its parameters (a variant property → a union or +//! enum, boolean → a flag showing the layers bound to it, text → a string, instance swap → a view +//! parameter, slot → optional content), and an instance becomes a call passing the values it sets. +//! Variants of a set share one definition (the set's default variant); in React and Leptos their +//! differences become CSS rules on `data-*` attributes. Returned by `inspect.componentCode` and +//! used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows its +//! real snippet instead. use figcraft_doc::*; use serde_json::{Value, json}; use crate::cmd::CommandSpec; -use crate::codegen::{CodeLang, Ctx, Markup, class_name, compose, indent, literal, swift}; +use crate::codegen::{CodeLang, Ctx, Markup, arkui, class_name, compose, indent, literal, swift}; use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set}; use crate::params::*; use crate::{bad, spec}; @@ -67,14 +68,14 @@ pub fn specs() -> Vec { vec![spec!( "inspect.componentCode", "Component code", - "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}", + "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}", false, |s, p| { let cmd = "inspect.componentCode"; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?; let lang = match string(p, "language").map(|l| CodeLang::from_id(&l)) { - Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))) => l, - Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose")), + Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))) => l, + Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose|arkui")), None => CodeLang::React, }; let doc = s.doc()?; @@ -107,9 +108,9 @@ pub fn specs() -> Vec { )] } -/// Code for any layer in React, Leptos, SwiftUI or Compose: an instance's call and its component's -/// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it -/// in a function component). +/// Code for any layer in React, Leptos, SwiftUI, Compose or ArkUI: an instance's call and its +/// component's definition, a component's (or set's) definition, or the layer tree (React and +/// Leptos wrap it in a function component). pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { let Some(n) = doc.node(id) else { return String::new() }; if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) { @@ -129,6 +130,11 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { swift(&Ctx::new(doc, id), id, 0, &mut out, &[]); out } + CodeLang::ArkUi => { + let mut out = String::new(); + arkui(&Ctx::new(doc, id), id, 0, &mut out, &[]); + out + } _ => { let mut out = String::new(); compose(&Ctx::new(doc, id), id, 0, &mut out); @@ -289,7 +295,7 @@ const RESERVED: &[&str] = &[ "yield", ]; -/// Types the generated SwiftUI and Compose code names: a variant enum may not shadow them. +/// Types the generated SwiftUI, Compose and ArkUI code names: a variant enum may not shadow them. const TYPES: &[&str] = &[ "Alignment", "AnyView", @@ -298,6 +304,7 @@ const TYPES: &[&str] = &[ "Boolean", "Box", "CaseIterable", + "Circle", "CircleShape", "Color", "Column", @@ -306,21 +313,30 @@ const TYPES: &[&str] = &[ "EdgeInsets", "Ellipse", "EmptyView", + "FlexAlign", "Font", + "FontStyle", "FontWeight", "Group", "HStack", + "HorizontalAlign", "Image", "Modifier", + "Rect", "RectangleShape", "RoundedCornerShape", "RoundedRectangle", "Row", "Shape", + "Stack", "String", "Text", + "TextAlign", + "TextCase", + "TextDecorationType", "Unit", "VStack", + "VerticalAlign", "View", "ZStack", ]; @@ -482,6 +498,9 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value (Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)), (None, CodeLang::Leptos) => "|| ()".into(), (Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"), + // ArkUI takes a builder: `@BuilderParam` is a `() => void`. + (Some(t), CodeLang::ArkUi) => format!("() => {{ {t}() }}"), + (None, CodeLang::ArkUi) => "() => {}".into(), (Some(t), _) => format!("{{ {t}() }}"), (None, CodeLang::React) => "null".into(), (None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(), @@ -561,6 +580,11 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option format!("{pad}<{t}{attrs} />\n") } CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", ")), + CodeLang::ArkUi => { + let list = args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", "); + // ArkTS calls take an object literal, or nothing at all. + if list.is_empty() { format!("{pad}{t}()\n") } else { format!("{pad}{t}({{ {list} }})\n") } + } _ => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n} = {e}")).collect::>().join(", ")), }) } @@ -593,6 +617,13 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S } format!("AnyView(Group {{\n{out}{}}})", indent(depth + 1)) } + CodeLang::ArkUi => { + let mut out = String::new(); + for k in kids { + arkui(&ctx, k, depth + 2, &mut out, &[]); + } + format!("() => {{\n{out}{}}}", indent(depth + 1)) + } _ => { let mut out = String::new(); for k in kids { @@ -611,6 +642,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String { CodeLang::SwiftUi => swift_definition(doc, c), CodeLang::Compose => compose_definition(doc, c), CodeLang::Leptos => leptos(doc, c), + CodeLang::ArkUi => arkui_definition(doc, c), _ => react(doc, c), } } @@ -807,3 +839,67 @@ fn compose_definition(doc: &Document, c: &Component) -> String { out.push_str("}\n"); out } + +/// An ArkUI (`@Component`) struct: variant enums, `@Prop` parameters, a default builder for each +/// instance swap and slot, and the `build` body. +fn arkui_definition(doc: &Document, c: &Component) -> String { + let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 1 }; + let mut out = String::new(); + for p in &c.params { + if let ParamKind::Variant(opts) = &p.kind { + out.push_str(&format!("enum {} {{ {} }}\n\n", enum_name(c, p, false), cases(opts, false).join(", "))); + } + } + // A default builder per instance swap or slot, named apart from every parameter. + let mut used: Vec = c.params.iter().map(|p| p.ident.clone()).collect(); + let mut builders: Vec<(String, String)> = Vec::new(); + for p in &c.params { + if !matches!(p.kind, ParamKind::InstanceSwap | ParamKind::Slot) { + continue; + } + let base = format!("default{}Builder", capitalized(&p.ident)); + let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}{k}") }).find(|n| !used.contains(n)).unwrap_or(base); + used.push(name.clone()); + builders.push((p.ident.clone(), name)); + } + out.push_str("@Component\nexport struct "); + out.push_str(&c.type_name); + out.push_str(" {\n"); + for p in &c.params { + match &p.kind { + ParamKind::Variant(_) => { + out.push_str(&format!(" @Prop {}: {} = {}\n", p.ident, enum_name(c, p, false), value_expr(doc, c, p, &p.default, CodeLang::ArkUi))) + } + ParamKind::Boolean => out.push_str(&format!(" @Prop {}: boolean = {}\n", p.ident, p.default.as_bool().unwrap_or(false))), + ParamKind::Text => out.push_str(&format!(" @Prop {}: string = {}\n", p.ident, value_expr(doc, c, p, &p.default, CodeLang::ArkUi))), + ParamKind::InstanceSwap | ParamKind::Slot => { + let name = builders.iter().find(|(ident, _)| *ident == p.ident).map_or("", |(_, name)| name.as_str()); + out.push_str(&format!(" @BuilderParam {}: () => void = this.{name}\n", p.ident)); + } + } + } + for (ident, name) in &builders { + let Some(p) = c.params.iter().find(|p| p.ident == *ident) else { continue }; + out.push_str(&format!("\n @Builder\n {name}() {{\n")); + match &p.kind { + ParamKind::InstanceSwap => { + if let Some(t) = id_of(&p.default).and_then(|i| component_type(doc, i)) { + out.push_str(&format!(" {t}()\n")); + } + } + ParamKind::Slot => { + let layer = slots_of(doc, c.comp).into_iter().find(|(_, slot)| slot == &p.prop).map(|(layer, _)| layer); + let kids = layer.and_then(|l| doc.node(l)).map(|n| ctx.kids(n)).unwrap_or_default(); + for k in kids { + arkui(&ctx, k, 2, &mut out, &[]); + } + } + _ => {} + } + out.push_str(" }\n"); + } + out.push_str("\n build() {\n"); + arkui(&ctx, c.comp, 2, &mut out, &[]); + out.push_str(" }\n}\n"); + out +} diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs index 4cd3d776..dc279724 100644 --- a/crates/engine/src/component_codegen_tests.rs +++ b/crates/engine/src/component_codegen_tests.rs @@ -71,6 +71,17 @@ fn components_take_their_properties_as_parameters() { for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] { assert!(kt.contains(want), "Compose has {want:?}:\n{kt}"); } + let ark = code(&mut s, c, "arkui"); + for want in [ + "@Component\nexport struct Card {", + " @Prop showDot: boolean = true", + " @Prop title: string = \"Hello\"", + " build() {", + " if (this.showDot) {", + "Text(this.title)", + ] { + assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}"); + } let leptos = code(&mut s, c, "leptos"); for want in [ "use leptos::prelude::*;\n\n#[component]\npub fn Card(\n", @@ -117,12 +128,14 @@ fn instances_are_calls_passing_the_values_they_set() { assert!(react.contains("export function Card({")); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n"); + assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Buy\" })\n"); assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n"); // Text that needs escaping is an expression in JSX and escaped per language. ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}})); assert_eq!(component_code(&mut s, i, "react")["usage"], "\n"); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n"); + assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n"); assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n"); } @@ -173,6 +186,9 @@ fn variant_sets_share_one_definition() { let kt = code(&mut s, set, "compose"); assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}"); assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}"); + let ark = code(&mut s, set, "arkui"); + assert!(ark.contains("enum ButtonState { Default, Hover }"), "{ark}"); + assert!(ark.contains(" @Prop state: ButtonState = ButtonState.Default"), "{ark}"); let leptos = code(&mut s, set, "leptos"); for want in [ "#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}", @@ -189,6 +205,7 @@ fn variant_sets_share_one_definition() { assert_eq!(component_code(&mut s, i, "react")["usage"], "