Merge main into #776

This commit is contained in:
bflatastic
2026-10-18 11:34:21 +07:00
44 changed files with 862 additions and 109 deletions
Generated
+27 -21
View File
@@ -856,6 +856,11 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1408,7 +1413,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"arboard",
"eframe",
@@ -1435,7 +1440,7 @@ dependencies = [
[[package]]
name = "figcraft-cli"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-engine",
@@ -1449,7 +1454,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1464,7 +1469,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1487,7 +1492,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo",
"serde",
@@ -1496,7 +1501,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1508,9 +1513,10 @@ dependencies = [
[[package]]
name = "figcraft-engine"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"craft-docpath",
"figcraft-collab",
"figcraft-color",
"figcraft-doc",
@@ -1535,7 +1541,7 @@ dependencies = [
[[package]]
name = "figcraft-format"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
@@ -1550,7 +1556,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo",
"serde",
@@ -1558,7 +1564,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1566,7 +1572,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1582,7 +1588,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1591,7 +1597,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1608,7 +1614,7 @@ dependencies = [
[[package]]
name = "figcraft-plugin"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-color",
"serde",
@@ -1621,7 +1627,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -1639,7 +1645,7 @@ dependencies = [
[[package]]
name = "figcraft-server"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"crc32fast",
"ctrlc",
@@ -1663,7 +1669,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1679,7 +1685,7 @@ dependencies = [
[[package]]
name = "figcraft-text"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1693,7 +1699,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"egui",
@@ -1718,7 +1724,7 @@ dependencies = [
[[package]]
name = "figcraft-web"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
@@ -5928,7 +5934,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-engine",
"serde_json",
+4 -1
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.6.0"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -32,6 +32,9 @@ figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
figcraft-plugin = { path = "crates/plugin" }
# Which paths stored in a document may be followed (craft-libs).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
thiserror = "2"
+1 -1
View File
@@ -117,7 +117,7 @@ French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and I
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,
IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
## Upcoming
+1 -1
View File
@@ -182,7 +182,7 @@ impl Config {
return Err("`idle_days` must be from 0 to 3650".into());
}
let terms = self.terms_url.trim();
if !terms.is_empty() && !(terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
if !(terms.is_empty() || terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
return Err("`terms_url` must be a web address (https://…)".into());
}
if self.operator_contact.chars().count() > 120 || self.operator_contact.chars().any(char::is_control) || terms.len() > 500 {
+1 -1
View File
@@ -203,7 +203,7 @@ fn pairs(query: &str) -> Result<Vec<(String, String)>, String> {
fn with_port(authority: &str, default: u16) -> String {
let has_port = match authority.rsplit_once(':') {
// `[v6]` alone has colons inside the brackets only.
Some((h, p)) => !(h.starts_with('[') && !h.ends_with(']')) && !p.contains(']'),
Some((h, p)) => (!h.starts_with('[') || h.ends_with(']')) && !p.contains(']'),
None => false,
};
if has_port { authority.to_string() } else { format!("{authority}:{default}") }
+1
View File
@@ -20,6 +20,7 @@ figcraft-render = { workspace = true }
figcraft-format = { workspace = true }
figcraft-svg = { workspace = true }
figcraft-pdf = { workspace = true }
craft-docpath = { workspace = true }
figcraft-plugin = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+1 -3
View File
@@ -691,9 +691,7 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
let groups: BTreeSet<String> = n
.overrides
.iter()
.filter(|g| {
!matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name")
})
.filter(|g| !(matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) || d == inst && g.as_str() == "name"))
.cloned()
.collect();
for g in &groups {
+1 -1
View File
@@ -1356,7 +1356,7 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "fill.pattern";
let src = req_id(CMD, p, "source")?;
let doc = s.doc()?;
if !doc.node(src).is_some_and(|n| !n.is_page()) {
if doc.node(src).is_none_or(|n| n.is_page()) {
return Err(bad(CMD, "`source` must be a layer"));
}
if req_targets(CMD, s, p)?.iter().any(|t| doc.is_ancestor(*t, src)) {
+1 -1
View File
@@ -1498,7 +1498,7 @@ fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bo
let gs: Vec<&'static str> = groups
.iter()
.copied()
.filter(|g| !cn.overrides.contains(*g) && !(root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions")))
.filter(|g| !(cn.overrides.contains(*g) || root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions")))
.collect();
if gs.is_empty() {
continue;
+48
View File
@@ -0,0 +1,48 @@
//! Which URLs stored in a document may be handed to the browser or the system opener.
//!
//! A document can carry URLs that the app opens for whoever views it: a prototype's Open link
//! action ([`crate::proto_run`]) and Dev Mode's dev resources ([`crate::dev_resources`]). Both end
//! up at the platform's opener, so a document that someone else made chooses what that opener is
//! asked to do. Only the schemes a browser would follow on a plain link are allowed:
//!
//! - `javascript:` runs script. In the web build the opener is `window.open`, which executes it in
//! the app's own origin, so a saved document becomes stored XSS.
//! - `file:` and `smb:` reach the file system; on Windows a UNC host also sends credentials.
//! - Windows hands `ms-…:` schemes to application handlers, and macOS and Linux hand unknown
//! schemes to whichever application registered them.
//!
//! The check is lexical and does no I/O. It is deliberately an allow-list: a deny-list of known
//! dangerous schemes is outgrown by every new handler a platform adds.
/// The schemes a URL stored in a document may use.
const WEB_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
/// Whether `url`, stored in a document, may be opened.
///
/// Requires a scheme from [`WEB_SCHEMES`], compared ignoring ASCII case. A URL with no scheme is
/// refused: the opener would resolve it against the app's own page on the web. Control characters
/// are refused anywhere, because browsers ignore some of them inside a scheme (`java\tscript:`
/// reaches a `javascript:` handler), and `http`/`https` must name a host.
pub fn openable(url: &str) -> bool {
let url = url.trim();
if url.is_empty() || url.chars().any(char::is_control) {
return false;
}
let Some((scheme, rest)) = url.split_once(':') else { return false };
// An RFC 3986 scheme, so a stray colon in text cannot look like one.
let mut chars = scheme.chars();
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
return false;
}
if !WEB_SCHEMES.iter().any(|s| scheme.eq_ignore_ascii_case(s)) {
return false;
}
if scheme.eq_ignore_ascii_case("mailto") {
// `mailto:` names an address.
return !rest.is_empty();
}
// `http` and `https` name a host, so the authority is required: `http:x` is not a web link,
// and `http:///p` has no host.
let Some(after) = rest.strip_prefix("//") else { return false };
!after.is_empty() && !after.starts_with('/')
}
+87
View File
@@ -0,0 +1,87 @@
//! A URL stored in a document reaches the browser or the system opener, so only the schemes a
//! browser follows on a plain link are allowed ([`crate::doc_urls`]).
use crate::doc_urls::openable;
#[test]
fn web_links_are_opened() {
for url in [
"http://example.com",
"https://example.com",
"https://example.com/a/b?c=d#e",
"HTTPS://EXAMPLE.COM",
"HtTp://example.com",
"mailto:a@b.c",
"mailto:a@b.c?subject=hi",
" https://example.com ",
"https://user:pw@example.com:8443/p",
] {
assert!(openable(url), "{url:?} should open");
}
}
#[test]
fn script_and_handler_schemes_are_refused() {
for url in [
// Runs in the app's own origin on the web build: a saved document would be stored XSS.
"javascript:alert(1)",
"JavaScript:alert(1)",
"jAvAsCrIpT:alert(document.cookie)",
"vbscript:msgbox(1)",
"data:text/html,<script>alert(1)</script>",
"blob:https://example.com/x",
// Reach the file system; a UNC host also sends Windows credentials.
"file:///etc/passwd",
"file://attacker.example/share/x",
"smb://attacker.example/share/x",
"webdav://attacker.example/x",
// Windows application handlers.
"ms-msdt:/id PCWDiagnostic",
"ms-officecmd:x",
"search-ms:query=x",
"shell:startup",
// Anything else a platform may have registered.
"tel:+1234",
"sms:+1234",
"ssh://host",
"ftp://host/x",
"figcraft://join?host=127.0.0.1",
"zoommtg://zoom.us/join?x",
"view-source:https://example.com",
"jar:https://example.com/x!/y",
] {
assert!(!openable(url), "{url:?} must be refused");
}
}
#[test]
fn a_scheme_is_required_and_control_characters_are_refused() {
for url in [
// No scheme: the web opener would resolve these against the app's own page.
"",
" ",
"example.com",
"/etc/passwd",
"//example.com",
"\\\\host\\share\\x",
"about",
// Browsers ignore some control characters inside a scheme, so a smuggled one must not
// leave `javascript` looking like an unknown scheme that we then refuse by luck.
"java\tscript:alert(1)",
"java\nscript:alert(1)",
"java\rscript:alert(1)",
"javascript\u{0}:alert(1)",
"https://example.com/\u{0}",
// A colon in ordinary text is not a scheme.
"see page 2: the diagram",
"1https://example.com",
// The allowed schemes still need their own part.
"mailto:",
"http:",
"http:example.com",
"http:///p",
"https://",
] {
assert!(!openable(url), "{url:?} must be refused");
}
}
+7
View File
@@ -26,6 +26,7 @@ pub mod dev_resources;
pub mod devcompare;
pub mod devinspect;
pub mod distance;
pub mod doc_urls;
pub mod drop;
pub mod file_access;
pub mod guard;
@@ -1576,6 +1577,8 @@ mod devfocus_tests;
#[cfg(test)]
mod devinspect_tests;
#[cfg(test)]
mod doc_urls_tests;
#[cfg(test)]
mod double_click_tests;
#[cfg(test)]
mod draw_tools_tests;
@@ -1636,6 +1639,8 @@ mod invite_links_tests;
#[cfg(test)]
mod keymap_tests;
#[cfg(test)]
mod library_path_tests;
#[cfg(test)]
mod library_range_styles_tests;
#[cfg(test)]
mod library_variables_tests;
@@ -1686,6 +1691,8 @@ mod panels_behaviour_tests;
#[cfg(test)]
mod pdf_print_tests;
#[cfg(test)]
mod pen_end_tests;
#[cfg(test)]
mod pen_overlay_tests;
#[cfg(test)]
mod pixel_preview_tests;
+30
View File
@@ -0,0 +1,30 @@
//! A library link is stored in the document, so it is followed only on this computer
//! ([`crate::library_server`]): a path on another server would make opening someone else's file
//! contact that server, and on Windows send the credentials of whoever opened it.
use crate::Session;
#[test]
fn a_library_is_opened_only_from_this_computer() {
let mut s = Session::new();
for path in [
r"\\attacker.example\share\lib.figcraft",
"//attacker.example/share/lib.figcraft",
"file://attacker.example/share/lib.figcraft",
r"\\?\UNC\attacker.example\share\lib.figcraft",
r"\\.\pipe\lib",
"https://attacker.example/lib.figcraft",
] {
let e = s.fetch_library(path, false).expect_err(&format!("{path} must be refused"));
assert!(e.to_string().contains("only from this computer"), "{path}: {e}");
}
}
#[test]
fn a_local_library_path_is_still_followed() {
let mut s = Session::new();
// Refused for being absent, not for where it is: the gate let it through.
let missing = std::env::temp_dir().join("figcraft-no-such-library.figcraft");
let e = s.fetch_library(&missing.to_string_lossy(), false).expect_err("the file does not exist");
assert!(!e.to_string().contains("only from this computer"), "a local path must not be refused as remote: {e}");
}
+7
View File
@@ -129,6 +129,13 @@ impl Session {
/// document uses the library.
pub fn fetch_library(&mut self, path: &str, fresh: bool) -> Result<Arc<PublishedLibrary>> {
if !is_server_link(path) {
// A library link is stored in the document, so opening someone else's file would
// otherwise reach whatever path it names. Only a path on this computer is followed: a
// network share would make opening a document contact that server, and on Windows
// send the credentials of whoever opened it.
if !craft_docpath::is_local(path) {
return Err(EngineError::Other(format!("{path}: a library is opened only from this computer")));
}
return self.libraries.load(path);
}
let have = match self.libraries.cached(path) {
+72
View File
@@ -0,0 +1,72 @@
//! Ending a pen path with Escape or Enter (#760): the path's vector stays in edit mode with the pen,
//! so the next path is drawn into the same layer; another Escape leaves edit mode.
use crate::Session;
use crate::tools::{PointerEvent, PointerKind, ToolId};
use crate::vectoredit::VTool;
use figcraft_doc::NodeKind;
fn click(s: &mut Session, x: f64, y: f64) {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
}
}
fn layers(s: &Session) -> usize {
s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len()
}
/// (points, segments) of vector `id`.
fn shape(s: &Session, id: figcraft_doc::NodeId) -> (usize, usize) {
match &s.doc().unwrap().node(id).unwrap().kind {
NodeKind::Vector { network } => (network.vertices.len(), network.segments.len()),
k => panic!("not a vector: {k:?}"),
}
}
/// A two-point pen path, ended with `end`.
fn drawn(end: fn(&mut Session)) -> (Session, figcraft_doc::NodeId) {
let mut s = Session::new();
s.set_tool(ToolId::Pen);
click(&mut s, 100.0, 100.0);
click(&mut s, 200.0, 100.0);
let id = s.tool.pen.as_ref().and_then(|p| p.id).unwrap();
let undo = s.undo_depth().0;
end(&mut s);
assert!(s.tool.pen.is_none(), "the path ended");
assert_eq!(s.undo_depth().0, undo + 1, "the path is one undo step");
let ve = s.ds().unwrap().vector_edit.clone().expect("still editing the vector");
assert_eq!((ve.id, ve.tool, ve.anchor), (id, VTool::Pen, None), "the pen, with no open end");
(s, id)
}
#[test]
fn escape_and_enter_keep_the_vector_in_edit_mode_with_the_pen() {
// Escape, and Enter (its key runs `pen_end`).
let ends: [fn(&mut Session); 2] = [|s| s.escape().unwrap(), |s| s.pen_end().unwrap()];
for end in ends {
let (mut s, id) = drawn(end);
// The next path goes into the same layer, unconnected to the first.
click(&mut s, 100.0, 300.0);
click(&mut s, 200.0, 300.0);
assert_eq!(layers(&s), 1, "no new layer");
assert_eq!(shape(&s, id), (4, 2), "two separate paths in one vector network");
// Escape ends that path, the next one leaves edit mode.
s.escape().unwrap();
assert!(s.ds().unwrap().vector_edit.is_some());
s.escape().unwrap();
assert!(s.ds().unwrap().vector_edit.is_none(), "the second Escape leaves edit mode");
assert_eq!(s.ds().unwrap().selection, vec![id]);
}
}
#[test]
fn escape_before_a_segment_leaves_nothing_to_edit() {
let mut s = Session::new();
s.set_tool(ToolId::Pen);
click(&mut s, 100.0, 100.0);
s.escape().unwrap();
assert!(s.tool.pen.is_none());
assert!(s.ds().unwrap().vector_edit.is_none());
assert_eq!(layers(&s), 0);
}
+26 -9
View File
@@ -2051,13 +2051,7 @@ impl Session {
if self.tool.pen.is_none()
&& let Some((id, v)) = selected_end_at(&doc, &self.selection(), ev.p(), 8.0 / ev.zoom.max(1e-6))
{
self.set_tool(ToolId::Move);
crate::vectoredit::begin(self, id)?;
crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?;
return crate::vectoredit::set_state(self, |e| {
e.anchor = Some(v);
e.vertices = vec![v];
});
return self.pen_edit(id, Some(v));
}
let pen = self.tool.pen.get_or_insert_with(PenPath::default);
// Clicking the first anchor closes the path.
@@ -2175,7 +2169,30 @@ impl Session {
Ok(())
}
/// Finish the pen path (Enter/Escape/double-click).
/// Edit `id` as a vector with the pen, going on from point `anchor` (or, without one, starting
/// a new path in the same layer at the next click).
fn pen_edit(&mut self, id: NodeId, anchor: Option<usize>) -> Result<()> {
self.set_tool(ToolId::Move);
crate::vectoredit::begin(self, id)?;
crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?;
crate::vectoredit::set_state(self, |e| {
e.anchor = anchor;
e.vertices = anchor.into_iter().collect();
})
}
/// Enter/Escape while the pen draws: the path ends but its layer stays in edit mode with the
/// pen, so the next path joins the same vector (another Escape leaves edit mode).
pub fn pen_end(&mut self) -> Result<()> {
let id = self.tool.pen.as_ref().and_then(|p| p.id);
self.pen_finish()?;
match id {
Some(id) if self.doc()?.contains(id) => self.pen_edit(id, None),
_ => Ok(()),
}
}
/// Finish the pen path (and keep the pen tool: double-click, Delete, a tool switch).
pub fn pen_finish(&mut self) -> Result<()> {
let pen = self.tool.pen.take();
self.tool.overlay.pen.clear();
@@ -2320,7 +2337,7 @@ impl Session {
return Ok(());
}
if self.tool.pen.is_some() {
return self.pen_finish();
return self.pen_end();
}
if self.tool.busy() {
self.tool.gesture = Gesture::None;
+9 -10
View File
@@ -508,16 +508,15 @@ pub fn begin_many(s: &mut Session, ids: &[NodeId]) -> Result<()> {
let before = s.ds()?.doc.clone();
let mut edited = Vec::new();
let mut first_err = None;
{
let doc = s.doc_mut()?;
for id in ids.iter().take(MAX_EDITED) {
if edited.contains(id) {
continue;
}
match ensure_vector(doc, *id) {
Ok(()) => edited.push(*id),
Err(e) => first_err = first_err.or(Some(e)),
}
for id in ids.iter().take(MAX_EDITED) {
if edited.contains(id) {
continue;
}
// Vectors are left untouched, so editing only them records no undo step.
let is_vector = before.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Vector { .. }));
match if is_vector { Ok(()) } else { ensure_vector(s.doc_mut()?, *id) } {
Ok(()) => edited.push(*id),
Err(e) => first_err = first_err.or(Some(e)),
}
}
let Some((&id, rest)) = edited.split_first() else {
@@ -1,8 +1,8 @@
//! How auto layout frames and text size on import (hug or fixed), on encoded `.fig` files built in
//! code and REST file JSON: the sizing a file records, an auto layout frame that records none, and
//! text that hugs by its resize mode.
//! text that hugs by its resize mode. Also how distributed main-axis alignment reads.
use figcraft_doc::{Document, Sizing};
use figcraft_doc::{Document, MainAlign, Sizing};
use serde_json::{Value, json};
use super::kiwi::{Kv, Schema};
@@ -11,6 +11,7 @@ const SCHEMA: &str = "
enum NodeType NONE DOCUMENT CANVAS FRAME TEXT SYMBOL INSTANCE
enum StackMode NONE HORIZONTAL VERTICAL
enum StackSize FIXED RESIZE_TO_FIT RESIZE_TO_FIT_WITH_IMPLICIT_SIZE
enum StackJustify MIN CENTER MAX SPACE_EVENLY SPACE_BETWEEN SPACE_AROUND SPACE_EVENLY_CSS
enum TextAutoResize NONE WIDTH_AND_HEIGHT HEIGHT
struct GUID uint sessionID, uint localID
struct Vector float x, float y
@@ -18,7 +19,7 @@ struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
message ParentIndex GUID guid, string position
message TextData string characters
message SymbolData GUID symbolID
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, StackMode stackMode, StackSize stackPrimarySizing, StackSize stackCounterSizing, TextAutoResize textAutoResize, float fontSize, TextData textData, SymbolData symbolData
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, StackMode stackMode, StackSize stackPrimarySizing, StackSize stackCounterSizing, StackJustify stackPrimaryAlignItems, TextAutoResize textAutoResize, float fontSize, TextData textData, SymbolData symbolData
message Message NodeChange[] nodeChanges
";
@@ -191,3 +192,27 @@ fn rest_files_read_left_out_sizing_modes_as_hug_and_text_by_its_resize_mode() {
assert_eq!(sizing(&doc, name), want, "{name}");
}
}
#[test]
fn distributed_alignment_reads_the_older_evenly_name_as_space_between() {
let rows = [
("Between", "SPACE_BETWEEN", MainAlign::SpaceBetween),
("Older evenly", "SPACE_EVENLY", MainAlign::SpaceBetween),
("Around", "SPACE_AROUND", MainAlign::SpaceAround),
("Evenly", "SPACE_EVENLY_CSS", MainAlign::SpaceEvenly),
];
let nodes = rows
.iter()
.enumerate()
.flat_map(|(i, (name, align, _))| {
let mut fields = stack("HORIZONTAL", None, None);
fields.push(("stackPrimaryAlignItems", Kv::Enum(align)));
component(10 + 2 * i as u64, 50.0 * i as f64, name, fields, None)
})
.collect();
let doc = import(nodes);
for (name, _, want) in rows {
let n = doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}"));
assert_eq!(n.layout.as_ref().map(|l| l.main_align), Some(want), "{name}");
}
}
+10 -9
View File
@@ -73,7 +73,7 @@ enum StrokeJoin MITER=0 BEVEL=1 ROUND=2
enum ConstraintType MIN=0 CENTER=1 MAX=2 STRETCH=3 SCALE=4
enum StackMode NONE=0 HORIZONTAL=1 VERTICAL=2 GRID=3
enum StackAlign MIN=0 CENTER=1 MAX=2 BASELINE=3
enum StackJustify MIN=0 CENTER=1 MAX=2 SPACE_EVENLY=3 SPACE_BETWEEN=4 SPACE_AROUND=5
enum StackJustify MIN=0 CENTER=1 MAX=2 SPACE_EVENLY=3 SPACE_BETWEEN=4 SPACE_AROUND=5 SPACE_EVENLY_CSS=6
enum StackCounterAlign MIN=0 CENTER=1 MAX=2 STRETCH=3 AUTO=4 BASELINE=5
enum StackSize FIXED=0 RESIZE_TO_FIT=1
enum StackWrap NO_WRAP=0 WRAP=1
@@ -1371,7 +1371,7 @@ impl<'d> Exporter<'d> {
MainAlign::Max => "MAX",
MainAlign::SpaceBetween => "SPACE_BETWEEN",
MainAlign::SpaceAround => "SPACE_AROUND",
MainAlign::SpaceEvenly => "SPACE_EVENLY",
MainAlign::SpaceEvenly => "SPACE_EVENLY_CSS",
};
let cross = match l.cross_align {
CrossAlign::Min => "MIN",
@@ -1736,7 +1736,7 @@ impl<'d> Exporter<'d> {
let sp = &n.stroke;
let (w, h, weight) = (n.width, n.height, sp.weight);
let plain = n.strokes.iter().any(|p| p.visible) && sp.dashes.is_empty() && sp.sides.is_none() && sp.profile().is_none() && sp.brush.is_none();
if !plain || !(weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) {
if !(plain && weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) {
return;
}
let whole = matches!(n.kind, NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && sweep.abs() >= std::f64::consts::TAU - 1e-9);
@@ -2217,12 +2217,13 @@ fn outlined(n: &Node) -> Option<(Node, String)> {
NodeKind::Line | NodeKind::Vector { .. } | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Rectangle
);
let shown = |paints: &[Paint]| paints.iter().any(|p| p.visible);
if !shape
|| sp.brush.is_some()
|| !sp.profile().is_some_and(one_sided)
|| !(sp.weight.is_finite() && sp.weight > 0.0)
|| !shown(&n.strokes)
|| shown(&n.fills)
if !(shape
&& sp.brush.is_none()
&& sp.profile().is_some_and(one_sided)
&& sp.weight.is_finite()
&& sp.weight > 0.0
&& shown(&n.strokes)
&& !shown(&n.fills))
{
return None;
}
-3
View File
@@ -630,9 +630,6 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) {
}
}
if let Some(l) = &mut n.layout {
if matches!(l.main_align, MainAlign::SpaceAround | MainAlign::SpaceEvenly) {
l.main_align = MainAlign::SpaceBetween;
}
// Track counts only matter (and are only written) for grids.
if l.mode != LayoutMode::Grid {
let d = AutoLayout::default();
+5 -2
View File
@@ -1404,7 +1404,10 @@ impl<'a> Mapper<'a> {
let main = match v.str("stackPrimaryAlignItems").or_else(|| v.str("stackJustify")) {
Some("CENTER") => "CENTER",
Some("MAX") => "MAX",
Some("SPACE_BETWEEN" | "SPACE_EVENLY" | "SPACE_AROUND" | "SPACE_EVENLY_CSS") => "SPACE_BETWEEN",
Some("SPACE_AROUND") => "SPACE_AROUND",
// `SPACE_EVENLY` is the format's older name for equal gaps without edge space.
Some("SPACE_EVENLY_CSS") => "SPACE_EVENLY",
Some("SPACE_BETWEEN" | "SPACE_EVENLY") => "SPACE_BETWEEN",
_ => "MIN",
};
o.insert("primaryAxisAlignItems".into(), json!(main));
@@ -2045,7 +2048,7 @@ impl<'a> Mapper<'a> {
self.warn("slot content assignment uses the empty GUID sentinel and was skipped");
continue;
}
if !self.nodes.get(&target).is_some_and(|n| n.get("isSlotContent").and_then(Kv::bool) == Some(true)) {
if self.nodes.get(&target).is_none_or(|n| n.get("isSlotContent").and_then(Kv::bool) != Some(true)) {
self.warn("slot content assignment target is missing or is not slot content");
continue;
}
@@ -0,0 +1,42 @@
//! REST import of grid track sizes (`gridColumnsSizing`, `gridRowsSizing`: CSS
//! `grid-template-columns`/`-rows` strings). Fixtures are synthetic, written by hand.
use super::*;
use figcraft_doc::GridTrack;
use serde_json::json;
fn tracks(columns: Value, rows: Value) -> (Vec<GridTrack>, Vec<GridTrack>) {
let file = json!({"name": "Fixture", "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
{"id": "1", "name": "Grid", "type": "FRAME", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 400, "y": 200},
"layoutMode": "GRID", "gridColumnCount": 7, "gridRowCount": 2, "gridColumnsSizing": columns, "gridRowsSizing": rows}
]}
]}});
let (doc, _) = import(&file, &no_images).unwrap();
let layout = doc.nodes().find(|n| n.name == "Grid").unwrap().layout.clone().unwrap();
(layout.column_tracks, layout.row_tracks)
}
#[test]
fn css_track_strings_set_the_track_sizes() {
let (cols, rows) = tracks(json!("120px 2fr auto"), json!("repeat(2, minmax(0, 1fr))"));
assert_eq!(cols, [GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()]);
assert_eq!(rows, [GridTrack::fill(1.0); 2]);
let (cols, rows) = tracks(json!(" repeat(2, 40px 1.5fr) minmax(64px, 64px) "), json!("min-content max-content"));
assert_eq!(cols, [GridTrack::fixed(40.0), GridTrack::fill(1.5), GridTrack::fixed(40.0), GridTrack::fill(1.5), GridTrack::fixed(64.0)]);
assert_eq!(rows, [GridTrack::hug(); 2]);
}
#[test]
fn unsupported_or_junk_tracks_are_equal_tracks() {
// Ranges the grid can't express, units it doesn't have and junk keep their place as 1fr.
let (cols, rows) = tracks(json!("minmax(10px, 80px) 5em -3px 0fr NaNpx repeat(x, 1px) 10px"), json!(7));
assert_eq!(cols, [[GridTrack::default(); 6].as_slice(), &[GridTrack::fixed(10.0)]].concat());
assert!(rows.is_empty(), "a non-string is ignored");
// Tracks past the grid's own count are left out; huge repeats and deep nesting stay bounded.
let (cols, rows) = tracks(json!(format!("repeat(999999999999, 1px) {}", "minmax(".repeat(10_000))), json!("1px 2px 3px"));
assert_eq!(cols, [GridTrack::fixed(1.0); 7]);
assert_eq!(rows, [GridTrack::fixed(1.0), GridTrack::fixed(2.0)]);
let (cols, rows) = tracks(json!("1e9px"), json!("( ) )) repeat(2,)"));
assert_eq!((cols, rows.len()), (vec![GridTrack::default()], 2));
}
+2
View File
@@ -29,6 +29,8 @@ mod fig_sources;
#[cfg(test)]
mod gamepad_tests;
#[cfg(test)]
mod grid_track_tests;
#[cfg(test)]
mod import_regression_tests;
pub(crate) mod props;
pub(crate) mod proto;
+74 -4
View File
@@ -424,6 +424,7 @@ pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option<AutoLayout> {
let pad = |k: &str| len(v, k).unwrap_or(0.0);
let count = |k: &str, d: u32| num(v, k).filter(|n| *n >= 1.0).map_or(d, |n| n.min(1000.0) as u32);
let grid = mode == LayoutMode::Grid;
let (columns, rows) = (count("gridColumnCount", 2), count("gridRowCount", 2));
Some(AutoLayout {
mode,
wrap: text(v, "layoutWrap") == Some("WRAP"),
@@ -448,15 +449,84 @@ pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option<AutoLayout> {
Some("SPACE_BETWEEN") => ContentAlign::SpaceBetween,
_ => ContentAlign::Auto,
},
grid_columns: count("gridColumnCount", 2),
grid_rows: count("gridRowCount", 2),
grid_columns: columns,
grid_rows: rows,
strokes_included: flag(v, "strokesIncludedInLayout").unwrap_or(false),
reverse_z: flag(v, "itemReverseZIndex").unwrap_or(false),
column_tracks: if fig_tracks && grid { fig_grid_tracks(v, "columnTracks") } else { Vec::new() },
row_tracks: if fig_tracks && grid { fig_grid_tracks(v, "rowTracks") } else { Vec::new() },
column_tracks: grid_tracks(v, grid, fig_tracks, "columnTracks", "gridColumnsSizing", columns),
row_tracks: grid_tracks(v, grid, fig_tracks, "rowTracks", "gridRowsSizing", rows),
})
}
/// A grid axis's track sizes: from the `.fig` source when there is one, else the API's CSS
/// `grid-template-*` string (at most `count` tracks, the axis's own).
fn grid_tracks(v: &Value, grid: bool, fig_tracks: bool, fig_axis: &str, css_key: &str, count: u32) -> Vec<GridTrack> {
match (grid, fig_tracks) {
(false, _) => Vec::new(),
(true, true) => fig_grid_tracks(v, fig_axis),
(true, false) => text(v, css_key).map(|css| css_grid_tracks(css, count as usize)).unwrap_or_default(),
}
}
/// The first `max` tracks of a CSS `grid-template-columns`/`-rows` value: `120px`, `2fr`, `auto`
/// (hug), `minmax(0, 1fr)` and `repeat(3, …)`; any other track is an equal (1fr) one.
fn css_grid_tracks(css: &str, max: usize) -> Vec<GridTrack> {
let mut out = Vec::new();
for item in css_split(css, char::is_whitespace) {
let repeated = item.strip_prefix("repeat(").and_then(|r| r.strip_suffix(')')).and_then(|r| {
let (count, tracks) = r.split_once(',')?;
Some((count.trim().parse::<usize>().ok()?, css_split(tracks, char::is_whitespace)))
});
let (count, tracks) = repeated.unwrap_or((1, vec![item]));
for t in tracks.iter().cycle().take(count.saturating_mul(tracks.len())) {
if out.len() >= max {
return out;
}
out.push(css_track(t));
}
}
out
}
fn css_track(t: &str) -> GridTrack {
let Some(args) = t.strip_prefix("minmax(").and_then(|r| r.strip_suffix(')')) else { return css_size(t).unwrap_or_default() };
// A flexible maximum is the track's share whatever its minimum; other ranges only when both ends agree.
match css_split(args, |c| c == ',').iter().map(|a| css_size(a.trim())).collect::<Vec<_>>()[..] {
[_, Some(max)] if max.sizing == Sizing::Fill => max,
[Some(min), Some(max)] if min == max => min,
_ => GridTrack::default(),
}
}
fn css_size(t: &str) -> Option<GridTrack> {
let n = |s: &str| s.parse::<f64>().ok().filter(|n| n.is_finite() && (0.0..=MAX_SIZE).contains(n));
if let Some(px) = t.strip_suffix("px").and_then(n) {
Some(GridTrack::fixed(px))
} else if let Some(fr) = t.strip_suffix("fr").and_then(n) {
(fr > 0.0).then(|| GridTrack::fill(fr))
} else {
matches!(t, "auto" | "min-content" | "max-content").then(GridTrack::hug)
}
}
/// `s` split at the `sep` characters outside parentheses, empty parts dropped.
fn css_split(s: &str, sep: fn(char) -> bool) -> Vec<&str> {
let (mut parts, mut depth, mut start) = (Vec::new(), 0usize, 0);
for (i, c) in s.char_indices() {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
c if depth == 0 && sep(c) => {
parts.extend(s.get(start..i).filter(|p| !p.trim().is_empty()));
start = i + c.len_utf8();
}
_ => {}
}
}
parts.extend(s.get(start..).filter(|p| !p.trim().is_empty()));
parts
}
fn fig_grid_tracks(v: &Value, axis: &str) -> Vec<GridTrack> {
v.get(crate::rest::FIG_SOURCE_FIELD)
.and_then(|source| source.get("gridTracks"))
@@ -0,0 +1,24 @@
//! FIG export + import keeps all three distributed main-axis alignments distinct.
use figcraft_doc::{AutoLayout, Document, MainAlign, NodeKind};
use figcraft_format::fig;
#[test]
fn distributed_alignment_round_trip() {
let mut results = Vec::new();
for expected in [MainAlign::SpaceBetween, MainAlign::SpaceAround, MainAlign::SpaceEvenly] {
let mut doc = Document::new("Alignment");
let page = doc.first_page().unwrap();
let frame = doc.create(page, NodeKind::Frame, "Frame", 0.0, 0.0, 200.0, 40.0).unwrap();
doc.node_mut(frame).unwrap().layout = Some(AutoLayout { main_align: expected, ..Default::default() });
let (native, _) = figcraft_format::load_bytes(&figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
assert_eq!(native.node(frame).unwrap().layout.as_ref().unwrap().main_align, expected);
let (bytes, export_warnings) = fig::export(&doc, None).unwrap();
let (imported, import_warnings) = fig::import(&bytes, "Alignment").unwrap();
let actual = imported.nodes().find(|n| n.name == "Frame").unwrap().layout.as_ref().unwrap().main_align;
assert!(export_warnings.is_empty());
assert!(import_warnings.is_empty());
results.push((expected, actual));
}
assert!(results.iter().all(|(expected, actual)| expected == actual), "{results:?}");
}
+1 -1
View File
@@ -160,7 +160,7 @@ impl Headless {
self.execute("text.edit.begin", &json!({"id": self.single_text()}))?;
}
"select.children" if self.session.tool.pen.is_some() => {
let _ = self.session.pen_finish();
let _ = self.session.pen_end();
}
// Enter on shapes edits them as vectors (several at once).
"select.children" if self.vector_targets().is_some() => {
+18 -1
View File
@@ -406,7 +406,7 @@ fn names(f: &skrifa::FontRef) -> (Option<String>, Option<String>) {
}
/// Families tried, in order, for characters a text's own font lacks.
const FALLBACK_FAMILIES: [&str; 18] = [
const FALLBACK_FAMILIES: [&str; 20] = [
"Inter",
"Helvetica Neue",
"Arial",
@@ -416,6 +416,8 @@ const FALLBACK_FAMILIES: [&str; 18] = [
"BIZ UDPGothic",
"Hiragino Sans",
"PingFang SC",
"Hiragino Sans GB",
"Heiti SC",
"Noto Sans CJK SC",
"Microsoft YaHei",
"Apple SD Gothic Neo",
@@ -691,11 +693,17 @@ fn system_font_dirs() -> Vec<std::path::PathBuf> {
let mut v: Vec<std::path::PathBuf> = Vec::new();
#[cfg(target_os = "macos")]
{
// Subfolders (`Supplemental`) are walked too.
v.push("/System/Library/Fonts".into());
v.push("/Library/Fonts".into());
if let Some(h) = std::env::var_os("HOME") {
v.push(std::path::PathBuf::from(h).join("Library/Fonts"));
}
// Fonts the system downloads (PingFang and other CJK faces on current versions) live in
// its asset store; `Assets` is the older versions' store.
for store in ["/System/Library/AssetsV2", "/System/Library/Assets"] {
v.extend(font_asset_dirs(std::path::Path::new(store)));
}
}
#[cfg(target_os = "windows")]
{
@@ -714,6 +722,15 @@ fn system_font_dirs() -> Vec<std::path::PathBuf> {
v
}
/// The font folders (`com_apple_MobileAsset_Font*`, each holding `<id>.asset/AssetData/*.ttc`) in
/// the macOS asset store `store`: only these are walked, not the store's voices, dictionaries and
/// models.
#[cfg(any(target_os = "macos", test))]
fn font_asset_dirs(store: &std::path::Path) -> Vec<std::path::PathBuf> {
let Ok(rd) = std::fs::read_dir(store) else { return Vec::new() };
rd.flatten().filter(|e| e.file_name().to_str().is_some_and(|n| n.starts_with("com_apple_MobileAsset_Font"))).map(|e| e.path()).collect()
}
// Fontconfig's default configuration uses $XDG_DATA_HOME/fonts for user fonts.
// Ignore relative XDG paths, as required by the XDG Base Directory Specification.
#[cfg(all(unix, not(target_os = "macos")))]
+38
View File
@@ -70,6 +70,44 @@ fn the_scan_reads_names_like_a_whole_file_read() {
}
}
#[test]
fn macos_font_assets_are_found_in_the_asset_store() {
// A store laid out like macOS's: fonts downloaded on demand sit in `<id>.asset/AssetData`
// folders under `com_apple_MobileAsset_Font*`; other assets are never walked. Untested on a Mac.
struct TempDir(std::path::PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let tmp = TempDir(std::env::temp_dir().join(format!("figcraft-font-assets-{}", std::process::id())));
let store = tmp.0.join("AssetsV2");
let fonts = store.join("com_apple_MobileAsset_Font7");
let data = |kind: &std::path::Path| kind.join("0f1e2d.asset").join("AssetData");
for dir in [data(&fonts), data(&store.join("com_apple_MobileAsset_VoiceServices"))] {
std::fs::create_dir_all(dir).unwrap();
}
// A file under a font-like name is listed but walks to nothing.
std::fs::write(store.join("com_apple_MobileAsset_Font7.plist"), b"not a folder").unwrap();
let found = font_asset_dirs(&store);
assert!(found.contains(&fonts) && !found.iter().any(|d| d.ends_with("com_apple_MobileAsset_VoiceServices")), "{found:?}");
assert!(font_asset_dirs(&tmp.0.join("missing")).is_empty());
// With an installed font to copy in (none on CI), the scan finds it only in the font folder.
let installed = system_font_dirs()
.into_iter()
.find_map(|d| std::fs::read_dir(d).into_iter().flatten().flatten().map(|e| e.path()).find(|p| p.extension().is_some_and(|x| x == "ttf")));
let Some(font) = installed else { return };
for kind in ["com_apple_MobileAsset_Font7", "com_apple_MobileAsset_VoiceServices"] {
std::fs::copy(&font, data(&store.join(kind)).join("Font.ttf")).unwrap();
}
let mut pending = BTreeMap::new();
for dir in found {
scan_dir(&dir, 0, &mut pending);
}
let paths: Vec<_> = pending.values().flat_map(|(_, p)| p.iter().map(|p| p.path.clone())).collect();
assert!(!paths.is_empty() && paths.iter().all(|p| p.starts_with(&fonts)), "{paths:?}");
}
#[cfg(unix)]
#[test]
fn the_scan_skips_named_pipes() {
+2
View File
@@ -37,6 +37,8 @@ const CHINESE: &[&str] = &[
"Microsoft YaHei UI",
"Microsoft YaHei",
"PingFang SC",
"Hiragino Sans GB",
"Heiti SC",
"Noto Sans SC",
"Source Han Sans SC",
"WenQuanYi Micro Hei",
+3 -2
View File
@@ -635,8 +635,9 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id:
};
ui.painter().text(pos2(x, r.center().y + 7.0), Align2::LEFT_CENTER, widgets::elide(ui, &below, text_w), regular(10.0), t.text_secondary);
crate::a11y::link(&resp, name);
// Opened only on a click, never fetched.
if resp.on_hover_text(url.as_str()).clicked() {
// Opened only on a click, never fetched, and a dev resource's URL comes from the
// document, so only a web link opens.
if resp.on_hover_text(url.as_str()).clicked() && figcraft_engine::doc_urls::openable(url) {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
if let Some(i) = item.index {
+13 -10
View File
@@ -60,20 +60,23 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
}
}
/// One export setting: size (typed or picked), suffix, format, advanced options and remove; a
/// PDF's print settings under it.
/// One export setting: size (typed or picked; images only, SVG and PDF are vector), suffix,
/// format, advanced options and remove; a PDF's print settings under it.
fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting) {
row(ui, 32.0, |ui| {
// Tighter spacing so the advanced options and remove buttons fit beside the fields.
ui.spacing_mut().item_spacing.x = 4.0;
let (size_w, format_w, button, gap) = (52.0, 54.0, 22.0, ui.spacing().item_spacing.x);
let suffix_w = (ui.available_width() - size_w - format_w - button * 2.0 - gap * 4.0).max(32.0);
let presets: Vec<String> =
ExportSize::SCALES.iter().map(|value| ExportSize::Scale { value: *value }.label()).chain(["512w".into(), "512h".into()]).collect();
if let Some(s) = widgets::combo_field(ui, Id::new(("export_size", i)), &e.size.label(), size_w, &presets)
&& ExportSize::parse(&s) != Some(e.size)
{
app.run("export.update", json!({"ids": ids, "index": i, "size": s}));
let (format_w, button, gap) = (54.0, 22.0, ui.spacing().item_spacing.x);
let size_w = e.format.is_raster().then_some(52.0);
let suffix_w = (ui.available_width() - size_w.map_or(0.0, |w| w + gap) - format_w - button * 2.0 - gap * 3.0).max(32.0);
if let Some(size_w) = size_w {
let presets: Vec<String> =
ExportSize::SCALES.iter().map(|value| ExportSize::Scale { value: *value }.label()).chain(["512w".into(), "512h".into()]).collect();
if let Some(s) = widgets::combo_field(ui, Id::new(("export_size", i)), &e.size.label(), size_w, &presets)
&& ExportSize::parse(&s) != Some(e.size)
{
app.run("export.update", json!({"ids": ids, "index": i, "size": s}));
}
}
if let Some(sfx) = widgets::text_field(ui, Id::new(("export_suffix", i)), &e.suffix, suffix_w, tr("Suffix"))
&& sfx != e.suffix
+27 -1
View File
@@ -1,6 +1,7 @@
//! The Export section's Preview row: a slice paints nothing itself, so its preview must show the
//! page content under it — the same thing exporting the slice writes — and not a blank
//! transparency checkerboard next to a correct export.
//! transparency checkerboard next to a correct export. And its setting rows: a size only for
//! images.
use figcraft_doc::{Document, NodeId};
use figcraft_engine::Session;
@@ -50,3 +51,28 @@ fn other_layers_preview_themselves() {
assert_eq!([pix.width(), pix.height()], [200, 100], "the layer's own area");
assert!(paints(&pix));
}
/// SVG and PDF are vector: their export rows offer no size, which their export ignores (#256).
#[test]
fn export_rows_offer_a_size_for_images_only() {
let mut s = Session::new();
let r = crate::present_tests::id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap());
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("export.add", &json!({"ids": [r]})).unwrap();
let mut u = crate::layers_drag_tests::Ui::new(s);
let size_menu = |u: &crate::layers_drag_tests::Ui| u.ctx.read_response(egui::Id::new(("export_size", 0)).with("menu")).is_some();
u.frame(vec![]);
assert!(size_menu(&u), "PNG has sizes");
for format in ["svg", "pdf"] {
u.app.run("export.update", json!({"ids": [r], "index": 0, "format": format}));
u.frame(vec![]);
u.frame(vec![]);
assert!(!size_menu(&u), "{format} is vector: no size");
}
for format in ["jpg", "webp"] {
u.app.run("export.update", json!({"ids": [r], "index": 0, "format": format}));
u.frame(vec![]);
u.frame(vec![]);
assert!(size_menu(&u), "{format} has sizes again");
}
}
+45
View File
@@ -83,6 +83,7 @@ icons! {
FlipH,
FlipV,
CornerRadius,
Angle,
IndependentCorners,
LinkOn,
LinkOff,
@@ -694,6 +695,12 @@ fn draw(p: &P, icon: Icon) {
p.fill(&[(3.0, 9.0), (13.0, 9.0), (13.0, 14.0)]);
}
CornerRadius => p.corner(2.5, 2.5, 1.0, 1.0, 11.5, 6.0),
Angle => {
// Two rays from a vertex, the arc between them marking the angle (all unhinted, so
// the arc meets the rays at every size).
p.path(&[(13.5, 13.0), (2.5, 13.0), (10.5, 3.0)], false);
p.path(&arc_pts(2.5, 13.0, 6.0, (-10.0f32).atan2(8.0), 0.0).collect::<Vec<_>>(), false);
}
IndependentCorners => {
for (x, y, dx, dy) in [(2.5, 2.5, 1.0, 1.0), (13.5, 2.5, -1.0, 1.0), (2.5, 13.5, 1.0, -1.0), (13.5, 13.5, -1.0, -1.0)] {
p.corner(x, y, dx, dy, 4.0, 2.0);
@@ -1181,4 +1188,42 @@ mod tests {
assert_eq!(triangulate(&rev).len(), (arrow.len() - 2) * 3);
assert!(triangulate(&arrow[..2]).is_empty());
}
/// The points painted inside `area`, relative to the top left of what's painted there (in
/// quarter points), so the same icon gives the same marks wherever its field puts it.
fn marks(shapes: &[egui::epaint::ClippedShape], area: Rect) -> Vec<(i32, i32)> {
fn walk(shape: &Shape, out: &mut Vec<Pos2>) {
match shape {
Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
Shape::LineSegment { points, .. } => out.extend(points),
Shape::Path(p) => out.extend(&p.points),
Shape::Circle(c) => out.push(c.center),
_ => {}
}
}
let mut pts = Vec::new();
shapes.iter().for_each(|c| walk(&c.shape, &mut pts));
pts.retain(|p| area.contains(*p));
let x0 = pts.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
let y0 = pts.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
let mut out: Vec<(i32, i32)> = pts.into_iter().map(|p| (((p.x - x0) * 4.0).round() as i32, ((p.y - y0) * 4.0).round() as i32)).collect();
out.sort_unstable();
out.dedup();
out
}
/// The Design panel's Rotation field shows an icon of its own, not Corner radius's (#256).
#[test]
fn rotation_and_corner_radius_fields_show_different_icons() {
let mut s = figcraft_engine::Session::new();
let r = s.execute("shape.rectangle", &serde_json::json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap()["id"].clone();
s.execute("select.set", &serde_json::json!({"ids": [r]})).unwrap();
let mut u = crate::layers_drag_tests::Ui::new(s);
u.frame(vec![]);
let shapes = u.frame_output(vec![]).shapes;
let lead = |name: &str| u.ctx.read_response(egui::Id::new(name).with("lead")).map(|r| r.rect).unwrap_or_else(|| panic!("{name} is shown"));
let (rotation, radius) = (marks(&shapes, lead("pos_rot")), marks(&shapes, lead("ap_rad")));
assert!(!rotation.is_empty() && !radius.is_empty(), "both draw an icon");
assert_ne!(rotation, radius);
}
}
+87
View File
@@ -0,0 +1,87 @@
//! IME composition in a text layer, driven through egui headless with the events macOS sends for
//! Pinyin ("nihao", then a candidate picked): the platform keeps the IME on every frame of the
//! edit (an idle frame too) with the candidate window at the caret, the composition shows without
//! entering the document, keys pressed while composing are the IME's, and the commit lands once.
use egui::{Event, ImeEvent, Key, Modifiers, RawInput, Rect};
use figcraft_doc::{NodeId, NodeKind};
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
fn key(key: Key, pressed: bool) -> Event {
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }
}
fn preedit(text: &str) -> Event {
let n = text.chars().count();
Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: (n > 0).then_some(n..n) })
}
#[expect(deprecated, reason = "egui still delivers these, and the editor must ignore them")]
fn enabled_disabled() -> (Event, Event) {
(Event::Ime(ImeEvent::Enabled), Event::Ime(ImeEvent::Disabled))
}
fn text(u: &Ui, id: NodeId) -> String {
match &u.app.session.doc().unwrap().node(id).unwrap().kind {
NodeKind::Text(t) => t.characters.clone(),
_ => panic!("not text"),
}
}
/// One frame; the IME output it leaves for the platform (`None` turns the IME off).
fn ime(u: &mut Ui, events: Vec<Event>) -> Rect {
let ime = u.frame_raw(RawInput { events, ..Default::default() }).platform_output.ime.expect("the IME stays on while editing");
assert_eq!(ime.rect, ime.cursor_rect, "the desktop places candidates by `rect`: the caret");
ime.cursor_rect
}
fn composing(u: &Ui) -> Option<String> {
u.app.session.ds().unwrap().text_edit.as_ref().expect("still editing").preedit.as_ref().map(|p| p.text.clone())
}
#[test]
fn pinyin_composes_inline_and_commits_once_with_the_ime_on_every_frame() {
// The text is measured with a font: without craft-fonts a system one, scanned here rather than
// by whichever test happens to run first (#333).
figcraft_text::scan_system_fonts();
let mut s = Session::new();
let t = NodeId(s.execute("text.create", &json!({"text": "Hi", "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap());
// On a machine without any font nothing lays out and every caret sits at the start.
let fonts = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).is_some_and(|(l, _)| !l.glyphs.is_empty());
let mut u = Ui::new(s);
figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap();
// An idle frame (the caret blinking) keeps the IME on, or macOS drops the composition.
let caret = ime(&mut u, vec![]);
let ds = u.app.session.ds().unwrap();
let b = ds.doc.world_bounds(t).unwrap();
let layer = Rect::from_two_pos(u.app.to_screen(b.x0, b.y0), u.app.to_screen(b.x1, b.y1)).expand(1.0);
assert!(layer.contains(caret.center()), "candidates open at the caret ({caret:?} in {layer:?})");
let (enabled, disabled) = enabled_disabled();
ime(&mut u, vec![enabled, preedit("n")]);
ime(&mut u, vec![]);
let mid = ime(&mut u, vec![preedit("ni")]);
assert!(!fonts || mid.min.x > caret.min.x, "the IME cursor follows the composition");
// A key the platform leaks while composing (Backspace) belongs to the IME, not the text.
ime(&mut u, vec![key(Key::Backspace, true), preedit("nih"), key(Key::Backspace, false)]);
ime(&mut u, vec![preedit("nihao")]);
assert_eq!(composing(&u).as_deref(), Some("nihao"));
assert_eq!(text(&u, t), "Hi", "the composition isn't in the document yet");
// Enter confirms the candidate: no newline, editing goes on.
let after = ime(&mut u, vec![key(Key::Enter, true), preedit(""), Event::Ime(ImeEvent::Commit("你好".into())), disabled, key(Key::Enter, false)]);
assert_eq!(text(&u, t), "Hi你好");
assert_eq!(composing(&u), None);
let te = u.app.session.ds().unwrap().text_edit.clone().unwrap();
assert_eq!((te.caret, te.anchor), (4, 4));
assert!(!fonts || after.min.x > caret.min.x, "the caret moved past the committed text");
ime(&mut u, vec![]);
assert_eq!(text(&u, t), "Hi你好", "committed once");
// Keys are the editor's again once the composition is over.
ime(&mut u, vec![key(Key::Enter, true), key(Key::Enter, false), Event::Text("!".into())]);
assert_eq!(text(&u, t), "Hi你好\n!");
}
+4
View File
@@ -117,6 +117,8 @@ mod home_tests;
#[cfg(test)]
mod idle_tests;
#[cfg(test)]
mod ime_tests;
#[cfg(test)]
mod import_report_tests;
#[cfg(test)]
mod layers_behaviour_tests;
@@ -171,6 +173,8 @@ mod plugins_browse_tests;
#[cfg(test)]
mod popup_width_tests;
#[cfg(test)]
mod present_links_tests;
#[cfg(test)]
mod present_tests;
#[cfg(test)]
mod present_view_tests;
+7 -5
View File
@@ -1047,12 +1047,14 @@ pub fn open_join(app: &mut FigApp, link: &str) {
app.ui.live.focus_join = true;
}
/// An invite link the app was opened with (`figcraft figcraft://join?…`): join at once; a link
/// that can't be used shows in the Join dialog with the reason.
/// An invite link the app was opened with (`figcraft figcraft://join?…`): the Join dialog opens
/// with the link filled in, so joining is the person's own choice.
///
/// The system hands the app any `figcraft://` link that is followed, which may be a page, a
/// message or a document rather than something the person meant to act on, so the link is shown
/// and confirmed rather than acted on.
pub fn open_invite(app: &mut FigApp, link: &str) {
if app.run("collab.join", json!({"link": link.trim()})).is_none() {
open_join(app, link.trim());
}
open_join(app, link.trim());
}
/// Escape closes the Join or Publish dialog (before the canvas's shortcuts see it: it doesn't
+7 -4
View File
@@ -47,12 +47,14 @@ fn escape_closes_the_join_dialog_and_keeps_the_selection() {
}
#[test]
fn opening_the_app_with_an_invite_link_joins() {
fn opening_the_app_with_an_invite_link_asks_before_joining() {
let (mut h, _) = Harness::new();
let link = "figcraft://join?host=127.0.0.1:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
crate::live::open_invite(&mut h.app, &format!(" {link} "));
assert_eq!(h.app.session.live().status, Status::Connecting);
assert!(h.app.ui.live.join.is_none(), "no dialog: it joins at once");
// The system hands the app whatever `figcraft://` link was followed, which may come from a
// page or a message, so the link is shown and confirmed rather than acted on.
assert_eq!(h.app.ui.live.join.as_deref(), Some(link), "the dialog opens with the link filled in");
assert_eq!(h.app.session.live().status, Status::Off, "nothing is joined until the person confirms");
}
/// The browser's link: joins, can't host; joining fails here to show the dialog path.
@@ -82,7 +84,8 @@ fn a_link_that_cant_be_used_opens_the_dialog_with_it() {
h.app.session.live.install(Box::new(Browser));
crate::live::open_invite(&mut h.app, "figcraft://nope");
assert_eq!(h.app.ui.live.join.as_deref(), Some("figcraft://nope"), "the link stays in the field to fix");
assert!(h.app.ui.last_error.as_deref().is_some_and(|e| e.contains("not a live session invite")));
// The link is not tried until the person presses Join, so its reason shows then, not now.
assert!(h.app.ui.last_error.is_none());
assert_eq!(h.app.session.live().status, Status::Off);
}
+3 -1
View File
@@ -865,7 +865,9 @@ impl Presenter {
}
Action::CloseOverlay => self.close_overlay(now),
Action::OpenUrl { url } => {
if !url.trim().is_empty() {
// The URL comes from the document and this action fires from whatever trigger the
// prototype sets, including a delay, so no click is implied: only a web link opens.
if figcraft_engine::doc_urls::openable(url) {
self.urls.push(url.clone());
}
true
+71
View File
@@ -0,0 +1,71 @@
//! A prototype's Open link action carries a URL from the document and fires from whatever trigger
//! the prototype sets, including a delay, so the player hands the opener only a web link
//! ([`figcraft_engine::doc_urls`]). Without that, a saved document opens `javascript:` in the web
//! build's own origin (stored XSS) and `smb:`/`ms-…:` through the desktop's handlers.
use std::sync::{Arc, Mutex};
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::Services;
use crate::present_tests::{Ui as Harness, id};
/// Records every URL the player asks the platform to open.
#[derive(Default)]
struct Opened(Arc<Mutex<Vec<String>>>);
impl Services for Opened {
fn open_url(&mut self, url: &str) {
if let Ok(mut v) = self.0.lock() {
v.push(url.to_string());
}
}
}
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
/// A presented frame whose layer opens `url` 100ms after the screen appears — no click anywhere.
fn opens_after_a_delay(url: &str) -> Vec<String> {
let mut s = Session::new();
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": f})));
ex(
&mut s,
"proto.interaction.add",
json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": {"type": "openUrl", "url": url}, "transition": {"type": "instant"}}),
);
let seen = Arc::new(Mutex::new(Vec::new()));
let mut u = Harness::new(s);
u.app.services = Box::new(Opened(seen.clone()));
u.run("proto.present", json!({"start": f}));
// Well past the delay.
u.frames(40, Vec::new);
seen.lock().map(|v| v.clone()).unwrap_or_default()
}
#[test]
fn a_prototype_opens_web_links() {
assert_eq!(opens_after_a_delay("https://example.com/a"), ["https://example.com/a"]);
assert_eq!(opens_after_a_delay("mailto:a@b.c"), ["mailto:a@b.c"]);
}
#[test]
fn a_prototype_cannot_open_a_script_or_handler_url() {
for url in [
// In the web build the opener is `window.open`, which runs this in the app's own origin.
"javascript:alert(document.cookie)",
"JavaScript:alert(1)",
"data:text/html,<script>alert(1)</script>",
// Reaches the file system; a UNC host also sends Windows credentials.
"file:///etc/passwd",
"smb://attacker.example/share/x",
// Handed to an application handler by the desktop.
"ms-msdt:/id PCWDiagnostic",
"figcraft://join?host=127.0.0.1:47900&file=f&token=t",
] {
assert!(opens_after_a_delay(url).is_empty(), "{url:?} must never reach the opener");
}
}
+1 -1
View File
@@ -660,7 +660,7 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
});
widgets::sublabels(ui, tr("Rotation"), None);
row(ui, 32.0, |ui| {
let e = widgets::number_field_mixed(ui, Id::new("pos_rot"), Lead::Icon(Icon::CornerRadius, tr("Rotation")), rot, "°", col, 1.0);
let e = widgets::number_field_mixed(ui, Id::new("pos_rot"), Lead::Icon(Icon::Angle, tr("Rotation")), rot, "°", col, 1.0);
app.field_of(&ids, "rotation", e, rot_of, |v| json!(v));
let items = [
(Icon::Rotate, tr("Rotate 90° right")),
+13 -6
View File
@@ -102,14 +102,16 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowe
return;
}
let events: Vec<Event> = ctx.input(|i| i.events.clone());
if events.is_empty() {
return;
}
// Every frame while editing, even one without input: a frame without the IME output turns the
// platform's IME off, which drops the composition (macOS then types plain letters).
let editing = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false);
if editing {
text_keys(app, ctx, &events);
return;
}
if events.is_empty() {
return;
}
// An image-only clipboard brings no paste event: paste from the key then. The browser sends
// the press; the desktop shell swallows it and sends only the release.
let paste_event = events.iter().any(|e| matches!(e, Event::Paste(_)));
@@ -307,9 +309,9 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
if m.is_none() && !repeat && opacity_digit(app, key, now) {
return true;
}
// Enter while the pen is drawing finishes it.
// Enter while the pen is drawing ends the path as Escape does.
if key == Key::Enter && app.session.tool.pen.is_some() {
let _ = app.session.pen_finish();
let _ = app.session.pen_end();
return true;
}
false
@@ -468,6 +470,10 @@ fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event]) {
}
}
Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => {
// Keys during a composition are the IME's (Enter picks a candidate, Escape cancels).
if app.session.ds().is_ok_and(|d| d.text_edit.as_ref().is_some_and(|te| te.preedit.is_some())) {
continue;
}
if (m.command || m.alt || m.ctrl) && text_shortcut(app, *key, *physical_key, *m) {
skip_text = true;
continue;
@@ -552,7 +558,8 @@ fn ime_output(app: &FigApp, ctx: &Context) {
ctx.output_mut(|o| {
o.ime = Some(egui::output::IMEOutput {
purpose: egui::IMEPurpose::Normal,
rect: app.canvas.rect,
// The desktop places the candidate window by `rect` (the web by `cursor_rect`).
rect: cursor_rect,
cursor_rect,
should_interrupt_composition: false,
})
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (`.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-17 · **Change:** minor (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
@@ -19,7 +19,7 @@ and prototype interactions are still unchecked there.
|---|---|---|---|---|---|
| `.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 |
| `.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), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, 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` | — |
| 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, 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`) | — |
| Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) |
@@ -165,6 +165,7 @@ each.
| Date | Change | Summary |
|---|---|---|
| 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 |
| 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged |
| 2026-10-17 | minor | Instance layers' own size and resizing (#731): a `.fig` import keeps the sizes an instance gives its layers and their hug/fill/fixed and min/max as overrides, a REST import reads the resizing fields of `overrides`, and a `.fig` export writes a layer's own size as an override (it went with the derived layout, which a relayout replaces). Numbers unchanged |
+5 -2
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (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-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)
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
@@ -68,7 +68,9 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
- **CJK UI fonts:** egui's fonts and Inter have no CJK glyphs. Once the UI language, the open
file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han,
the desktop app adds one installed face per script to the UI's fallbacks (Windows: Yu Gothic UI,
Meiryo, Malgun Gothic, Microsoft YaHei; macOS: Hiragino Sans, Apple SD Gothic Neo, PingFang SC;
Meiryo, Malgun Gothic, Microsoft YaHei; macOS: Hiragino Sans, Apple SD Gothic Neo, PingFang SC,
Hiragino Sans GB, Heiti SC, also from the system's downloaded font assets
(`/System/Library/AssetsV2/com_apple_MobileAsset_Font*`, where current versions keep PingFang);
Linux and BSD: Noto Sans CJK, Source Han Sans, Nanum, WenQuanYi; the craft-fonts build's BIZ
UDPGothic first), the UI language's script first; Han without kana outside a Japanese or Korean UI
reads as Chinese, and a Chinese UI puts its Chinese face (Microsoft YaHei, PingFang SC, Noto Sans
@@ -118,6 +120,7 @@ Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; it
| Date | Change | Summary |
|---|---|---|
| 2026-10-17 | minor | Chinese showed as boxes on macOS (#406): the font scan also walks the system's downloaded font assets (`/System/Library/AssetsV2` and the older `/System/Library/Assets`, only their `com_apple_MobileAsset_Font*` folders), where current versions keep PingFang SC, and Hiragino Sans GB and Heiti SC join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not yet run on a Mac |
| 2026-10-17 | minor | String counts re-measured from the catalogs: 1,680 UI keys, 561 command labels and 31 menu wordings (2,272 entries); Spanish complete, the other drafts at 95.3–99.2% (strings added since 2026-10-12 show English), es-419 101 entries. Indonesian has a draft (98.8%; it was listed as none); Russian (98.8%, reviewed), Swedish (99.6%, command labels and menu wordings from #599), Turkish (98.7%) and Czech (98.8%) listed with counts; the language picker's list updated; a paragraph repeated by a merge removed. Grade and hours unchanged (Indonesian's draft is offset by the drafts' new gaps) |
| 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog |
| 2026-10-12 | minor | Drafts catch up again, and the counts with them: French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese translate the 71 UI strings and 5 command labels they lacked since sharing with a join code and open hosting (the Share popover, join codes, owner tokens, reports, the .fig export notes), and with Spanish the 26 strings and 3 command labels of your online files (Home ▸ Online, its menu and dialogs, the marks on tabs); machine-drafted in each file's conventions, still drafts. The Latin American overlay adds computadora and agrega where the new Spanish says ordenador and añade (98 entries). 2,076 entries per catalog (1,503 UI strings, 544 command labels, 29 menu wordings) |
+5 -3
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -81,11 +81,11 @@ interaction feel not yet compared.
| Frame (presets panel, swap orientation), section, slice | done | |
| 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`) |
| 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 |
| 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; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
| 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 |
| Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) |
| Comment (C), Shift+C to show/hide | done | local only |
| Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only |
@@ -172,6 +172,8 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged |
| 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME |
| 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged |
| 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker |
| 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). |