From 1e0641a0bdb6c50af90b66419cf5980c51f0350e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:06:36 +0000 Subject: [PATCH 01/14] Keep space-around and space-evenly alignment on FIG import Fixes #460 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/format/src/fig/export_tests.rs | 3 --- crates/format/src/fig/map.rs | 4 +++- crates/format/tests/distributed_alignment.rs | 24 ++++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 crates/format/tests/distributed_alignment.rs diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 8d66b72f..59d95a6a 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -619,9 +619,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(); diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 787ca6d4..250f7778 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -595,7 +595,9 @@ 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", + Some("SPACE_EVENLY") => "SPACE_EVENLY", + Some("SPACE_BETWEEN" | "SPACE_EVENLY_CSS") => "SPACE_BETWEEN", _ => "MIN", }; o.insert("primaryAxisAlignItems".into(), json!(main)); diff --git a/crates/format/tests/distributed_alignment.rs b/crates/format/tests/distributed_alignment.rs new file mode 100644 index 00000000..bcf8a52d --- /dev/null +++ b/crates/format/tests/distributed_alignment.rs @@ -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:?}"); +} From 9a69f79861fae1cb80e6f21fa45b73b91a4627ac Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 22:39:10 +0700 Subject: [PATCH 02/14] #552: write and read space evenly as SPACE_EVENLY_CSS The format's SPACE_EVENLY is its older name for equal gaps without edge space, so files keep reading it as space between; true space evenly is SPACE_EVENLY_CSS, which the export now writes. --- .../src/fig/auto_layout_sizing_tests.rs | 31 +++++++++++++++++-- crates/format/src/fig/export.rs | 4 +-- crates/format/src/fig/map.rs | 5 +-- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/crates/format/src/fig/auto_layout_sizing_tests.rs b/crates/format/src/fig/auto_layout_sizing_tests.rs index 99730f70..29580afb 100644 --- a/crates/format/src/fig/auto_layout_sizing_tests.rs +++ b/crates/format/src/fig/auto_layout_sizing_tests.rs @@ -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}"); + } +} diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index ca9e2682..cac29be3 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -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", diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index a113f902..2ebe1ee9 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1405,8 +1405,9 @@ impl<'a> Mapper<'a> { Some("CENTER") => "CENTER", Some("MAX") => "MAX", Some("SPACE_AROUND") => "SPACE_AROUND", - Some("SPACE_EVENLY") => "SPACE_EVENLY", - Some("SPACE_BETWEEN" | "SPACE_EVENLY_CSS") => "SPACE_BETWEEN", + // `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)); From 68b19422adf0dbf605a472c6c0af6f5a4d23067e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 22:44:09 +0700 Subject: [PATCH 03/14] M1: REST import reads grid track sizes (#465) gridColumnsSizing and gridRowsSizing are CSS grid-template strings: px tracks are fixed, fr tracks fill, auto/min-content/max-content hug, minmax(_, Nfr) fills and repeat(n, ...) expands; other tracks stay equal (1fr). Tracks past the axis's own count are left out. Files from .fig keep reading the tracks from their source. --- crates/format/src/rest/grid_track_tests.rs | 42 ++++++++++++ crates/format/src/rest/mod.rs | 2 + crates/format/src/rest/props.rs | 78 ++++++++++++++++++++-- docs/file-format-parity.md | 5 +- 4 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 crates/format/src/rest/grid_track_tests.rs diff --git a/crates/format/src/rest/grid_track_tests.rs b/crates/format/src/rest/grid_track_tests.rs new file mode 100644 index 00000000..a11780ca --- /dev/null +++ b/crates/format/src/rest/grid_track_tests.rs @@ -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, Vec) { + 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)); +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 953522e1..be5c7157 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -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; diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 1003682b..efb7710d 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -424,6 +424,7 @@ pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option { 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 { 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 { + 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 { + 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::().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::>()[..] { + [_, Some(max)] if max.sizing == Sizing::Fill => max, + [Some(min), Some(max)] if min == max => min, + _ => GridTrack::default(), + } +} + +fn css_size(t: &str) -> Option { + let n = |s: &str| s.parse::().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 { v.get(crate::rest::FIG_SOURCE_FIELD) .and_then(|source| source.get("gridTracks")) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 96293b34..b65f7729 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-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 | From 91c80a19cc22fedf5ff451a7b73ab7a0f2396a6c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 22:53:54 +0700 Subject: [PATCH 04/14] M7: find macOS's downloadable CJK system fonts (#406) Current macOS keeps PingFang and other on-demand fonts in its asset store, which the font scan never walked, so Chinese text showed as boxes. The scan now also walks the com_apple_MobileAsset_Font* folders of /System/Library/AssetsV2 (and the older /System/Library/Assets) with the same depth, size and file checks, and Hiragino Sans GB and Heiti SC (preinstalled) join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not run on a Mac. --- crates/text/src/fontdb.rs | 19 ++++++++++++++++- crates/text/src/fontdb_tests.rs | 38 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/cjk.rs | 2 ++ docs/localization-parity.md | 7 ++++-- 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index d5ff4190..a6d52d67 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -406,7 +406,7 @@ fn names(f: &skrifa::FontRef) -> (Option, Option) { } /// 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 { let mut v: Vec = 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 { v } +/// The font folders (`com_apple_MobileAsset_Font*`, each holding `.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 { + 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")))] diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index ea762be6..96ce7842 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -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 `.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() { diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index c7fb1f98..fd3ecf8c 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -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", diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 15d49be9..50fcd4ec 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 (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) | From 03bb164b5cc0fc6990681712e81563f2130f119f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 23:05:03 +0700 Subject: [PATCH 05/14] M4: Esc after a pen path stays on the same vector layer (#760) Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path joins the same layer; another Escape leaves edit mode. Entering edit mode on a layer that already is a vector no longer records an empty undo step. --- crates/engine/src/lib.rs | 2 + crates/engine/src/pen_end_tests.rs | 72 ++++++++++++++++++++++++++++++ crates/engine/src/tools.rs | 35 +++++++++++---- crates/engine/src/vectoredit.rs | 19 ++++---- crates/mcp/src/headless.rs | 2 +- crates/ui-egui/src/shortcuts.rs | 4 +- docs/ui-parity.md | 5 ++- 7 files changed, 115 insertions(+), 24 deletions(-) create mode 100644 crates/engine/src/pen_end_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2b09c5c3..82d495b4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1686,6 +1686,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; diff --git a/crates/engine/src/pen_end_tests.rs b/crates/engine/src/pen_end_tests.rs new file mode 100644 index 00000000..a5d24f61 --- /dev/null +++ b/crates/engine/src/pen_end_tests.rs @@ -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); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index dbda1894..a5b61ca2 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -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) -> 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; diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index f923de80..a3fbf803 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -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 { diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index f3610c86..fcdb21a3 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -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() => { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 09b2c57b..7a38bc79 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -287,9 +287,9 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, 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 diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cccf008..2261cfd9 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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); 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,7 +81,7 @@ 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 | | @@ -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-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 | 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). | From 42242e3c8b88e556282926e09c223bc809aaa1e7 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 18 Oct 2026 01:41:15 +0700 Subject: [PATCH 06/14] M1: IME composition in text layers (#747) (#755) - Text editing on the canvas now tells the platform it wants the IME on **every** frame of the edit. Before, it only did so on frames that brought input events. - The candidate window opens at the caret. Before, it opened at the canvas corner: the desktop integration places it by `IMEOutput.rect`, which was the whole canvas. - Keys that reach the editor while a composition is in progress belong to the IME. Enter, Escape, Backspace, arrows and shortcuts don't act on the text then, so Enter picking a candidate doesn't add a newline. - New test `crates/ui-egui/src/ime_tests.rs`. - Docs: the IME rows in `docs/ui-parity.md` and `ROADMAP.md` now say it hasn't been checked on a Mac. Fixes #747. --- ROADMAP.md | 2 +- crates/ui-egui/src/ime_tests.rs | 87 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/shortcuts.rs | 15 ++++-- docs/ui-parity.md | 5 +- 5 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/ime_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 3908e591..8a587286 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs new file mode 100644 index 00000000..a6db0cbd --- /dev/null +++ b/crates/ui-egui/src/ime_tests.rs @@ -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) -> 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 { + 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!"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e6a14ede..ceea7005 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 09b2c57b..f68a24f3 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -102,14 +102,16 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowe return; } let events: Vec = 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(_))); @@ -448,6 +450,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; @@ -532,7 +538,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, }) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cccf008..64bda29f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 (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: @@ -85,7 +85,7 @@ interaction feel not yet compared. | 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,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 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). | From 37419afe146f19134170887dd2b23b97e64f00ac Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 13:19:13 -0600 Subject: [PATCH 07/14] Open only web links and local libraries from a document A document carries things the app acts on for whoever opens it. Three of them were taken as given. Prototype Open link and Dev Mode dev resources both reached the platform's opener with any scheme. In the web build the opener is window.open, so a saved document could run script in the app's own origin; on the desktop any scheme went to the system's handler. Both now pass through the new engine module `doc_urls`, which allows http, https and mailto and nothing else. It is an allow-list on purpose: a list of known-bad schemes is outgrown by every handler a platform adds. A figcraft:// invite link joined the session at once. The system hands the app whatever such link was followed, which may come from a page or a message, so the Join dialog now opens with the link filled in and the person confirms. A library link stored in a document was loaded from wherever it pointed. Only a path on this computer is followed now, so opening someone else's file cannot make the app contact their server (which on Windows would also send credentials). Tests: each change has one that fails without it, verified by reverting. The prototype test fires through an afterDelay trigger, since no click is needed, and keeps a positive case so a refusal cannot pass vacuously. --- Cargo.lock | 6 ++ Cargo.toml | 3 + crates/engine/Cargo.toml | 1 + crates/engine/src/doc_urls.rs | 48 +++++++++++++ crates/engine/src/doc_urls_tests.rs | 87 +++++++++++++++++++++++ crates/engine/src/lib.rs | 5 ++ crates/engine/src/library_path_tests.rs | 30 ++++++++ crates/engine/src/library_server.rs | 7 ++ crates/ui-egui/src/dev_mode.rs | 5 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/live.rs | 12 ++-- crates/ui-egui/src/live_join_tests.rs | 11 +-- crates/ui-egui/src/present.rs | 4 +- crates/ui-egui/src/present_links_tests.rs | 71 ++++++++++++++++++ 14 files changed, 280 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/doc_urls.rs create mode 100644 crates/engine/src/doc_urls_tests.rs create mode 100644 crates/engine/src/library_path_tests.rs create mode 100644 crates/ui-egui/src/present_links_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 9baacd58..a61d87cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -1511,6 +1516,7 @@ name = "figcraft-engine" version = "0.6.0" dependencies = [ "base64 0.22.1", + "craft-docpath", "figcraft-collab", "figcraft-color", "figcraft-doc", diff --git a/Cargo.toml b/Cargo.toml index 8f125ac3..fe089328 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index f610c38b..545d872c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/doc_urls.rs b/crates/engine/src/doc_urls.rs new file mode 100644 index 00000000..3b8417cd --- /dev/null +++ b/crates/engine/src/doc_urls.rs @@ -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('/') +} diff --git a/crates/engine/src/doc_urls_tests.rs b/crates/engine/src/doc_urls_tests.rs new file mode 100644 index 00000000..d42029a0 --- /dev/null +++ b/crates/engine/src/doc_urls_tests.rs @@ -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,", + "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"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2b09c5c3..80de797e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/library_path_tests.rs b/crates/engine/src/library_path_tests.rs new file mode 100644 index 00000000..f15105dc --- /dev/null +++ b/crates/engine/src/library_path_tests.rs @@ -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}"); +} diff --git a/crates/engine/src/library_server.rs b/crates/engine/src/library_server.rs index dbd97a0b..99d5f29a 100644 --- a/crates/engine/src/library_server.rs +++ b/crates/engine/src/library_server.rs @@ -129,6 +129,13 @@ impl Session { /// document uses the library. pub fn fetch_library(&mut self, path: &str, fresh: bool) -> Result> { 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) { diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index 0fedccd7..f5c7e40f 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -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 { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e6a14ede..4dba320c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -171,6 +171,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; diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 35af5d14..513060ab 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -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 diff --git a/crates/ui-egui/src/live_join_tests.rs b/crates/ui-egui/src/live_join_tests.rs index d7452163..61567ce0 100644 --- a/crates/ui-egui/src/live_join_tests.rs +++ b/crates/ui-egui/src/live_join_tests.rs @@ -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); } diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 863b540f..cc78c86b 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -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 diff --git a/crates/ui-egui/src/present_links_tests.rs b/crates/ui-egui/src/present_links_tests.rs new file mode 100644 index 00000000..8223890c --- /dev/null +++ b/crates/ui-egui/src/present_links_tests.rs @@ -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>>); + +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 { + 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,", + // 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"); + } +} From f7188de93dfe820a1e434af58551b3eb23c1d97b Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 13:35:44 -0600 Subject: [PATCH 08/14] Drop a needless borrow in the dev-resource link gate clippy::needless_borrow; `url` is already a reference. Missed on review because a workspace clippy run aborts at the first failing crate, which was figcraft-format. --- crates/ui-egui/src/dev_mode.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index f5c7e40f..d3456088 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -637,7 +637,7 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: crate::a11y::link(&resp, name); // 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) { + 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 { From c7acd1a81d48f1bf49071a55877e012a395c5b6e Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 13:35:44 -0600 Subject: [PATCH 09/14] chore: clear the rustc 1.96 clippy lints that fail xtask ci Unrelated to the security changes in this branch; `main` already failed `clippy --workspace --all-targets -- -D warnings` without them. Eight clippy::nonminimal_bool sites across seven files, each taking clippy's own suggested rewrite. Every rewrite is pure boolean algebra with the negation kept outside, so the float comparisons and their NaN behaviour are untouched: !A && !B becomes !(A || B), and !opt.is_some_and(f) becomes opt.is_none_or(!f). Two sites needed explicit parentheses the suggestion omitted, where the rewritten term is the left operand of an && and || binds looser. designcraft cleared the same lint in bc840130; this is figcraft's equivalent. --- apps/figcraft-server/src/config.rs | 2 +- crates/collab-net/src/invite.rs | 2 +- crates/engine/src/cmd/component.rs | 4 +--- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/components.rs | 2 +- crates/format/src/fig/export.rs | 15 ++++++++------- crates/format/src/fig/map.rs | 2 +- 7 files changed, 14 insertions(+), 15 deletions(-) diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index 101c7815..d5f4f761 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -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 { diff --git a/crates/collab-net/src/invite.rs b/crates/collab-net/src/invite.rs index bfa2e606..617ad518 100644 --- a/crates/collab-net/src/invite.rs +++ b/crates/collab-net/src/invite.rs @@ -203,7 +203,7 @@ fn pairs(query: &str) -> Result, 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}") } diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index a8901f24..ff7a56df 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -691,9 +691,7 @@ fn push_changes(s: &mut Session, p: &Value) -> Result { let groups: BTreeSet = 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 { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 804c7819..a4769a35 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1356,7 +1356,7 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result { 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)) { diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index ff582b8a..56efc5c9 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -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; diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index ca9e2682..45234bd8 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -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; } diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 25540ff6..d9aba17e 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -2045,7 +2045,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; } From c846f157dcdced43444f536cef5d1d5fb32bac5b Mon Sep 17 00:00:00 2001 From: Nathanael Date: Sun, 18 Oct 2026 07:34:38 +0700 Subject: [PATCH 10/14] fix(ui): SVG and PDF export rows offer no size (#256) (#768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): SVG and PDF export rows offer no size (#256) The size picker (1x, 2x, 512w…) showed on every export row, but SVG and PDF are vector and their export ignores it (export_bytes_with). It now shows for PNG, JPG and WebP only, and the suffix field takes its width. Refs #256 ("exporting to SVG is offered in different resolutions, although this should not be the case"). Co-Authored-By: Claude Opus 5.5 (1M context) * fix(ui): export size field as an Option; test WebP too (#256) From review: the size field's width is an Option instead of a negative width, and the test checks WebP as well as JPG. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/export_panel.rs | 23 ++++++++++-------- crates/ui-egui/src/export_preview_tests.rs | 28 +++++++++++++++++++++- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index ade5df29..8617c35a 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -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 = - 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 = + 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 diff --git a/crates/ui-egui/src/export_preview_tests.rs b/crates/ui-egui/src/export_preview_tests.rs index 3d536a01..d57e294d 100644 --- a/crates/ui-egui/src/export_preview_tests.rs +++ b/crates/ui-egui/src/export_preview_tests.rs @@ -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"); + } +} From 84a86e1d841e8ac74df20a6077e02f733303aad6 Mon Sep 17 00:00:00 2001 From: Nathanael Date: Sun, 18 Oct 2026 07:35:47 +0700 Subject: [PATCH 11/14] fix(ui): Rotation gets its own icon, not Corner radius's (#256) (#769) * fix(ui): Rotation gets its own icon, not Corner radius's (#256) The Rotation field showed the corner radius icon, so the two fields looked alike. Rotation now shows an angle: two rays from a vertex with the arc between them, drawn in code like the rest of the icon set. Refs #256 ("The icon for rotating objects is the same as for rounding corners"). Co-Authored-By: Claude Opus 5.5 (1M context) * fix(ui): the angle icon is drawn unhinted and centred; its test sits with the icons (#256) From review: the rays and the arc are both unhinted, so the arc meets the rays at the 14 px field size; the glyph moves up half a unit to centre it like the other leads; the test moves from a11y_tests.rs to the icon tests. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/icons.rs | 45 +++++++++++++++++++++++++++++++++++++ crates/ui-egui/src/right.rs | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 867b0894..fa4fb66e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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::>(), 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) { + 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); + } } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 47f3e5d3..0483f022 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -657,7 +657,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")), From 47438dd4a2e8b49a7e6bb3dfd480262dadc37f12 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 17 Oct 2026 21:43:28 -0400 Subject: [PATCH 12/14] Release 0.7.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a61d87cc..86bccfa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1413,7 +1413,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.6.0" +version = "0.7.0" dependencies = [ "arboard", "eframe", @@ -1440,7 +1440,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1469,7 +1469,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1492,7 +1492,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.6.0" +version = "0.7.0" dependencies = [ "kurbo", "serde", @@ -1501,7 +1501,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1513,7 +1513,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1556,7 +1556,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.6.0" +version = "0.7.0" dependencies = [ "kurbo", "serde", @@ -1564,7 +1564,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1588,7 +1588,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1597,7 +1597,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1614,7 +1614,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-color", "serde", @@ -1627,7 +1627,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.6.0" +version = "0.7.0" dependencies = [ "crc32fast", "figcraft-color", @@ -1645,7 +1645,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.6.0" +version = "0.7.0" dependencies = [ "crc32fast", "ctrlc", @@ -1669,7 +1669,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1685,7 +1685,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1699,7 +1699,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.22.1", "egui", @@ -1724,7 +1724,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.6.0" +version = "0.7.0" dependencies = [ "eframe", "egui", @@ -5934,7 +5934,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.6.0" +version = "0.7.0" dependencies = [ "figcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index fe089328..932da46f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" From cf6bd4eeb29a925f3dced1f420f03a6c588d2a28 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 21:49:32 +0700 Subject: [PATCH 13/14] M7: list the fonts a file uses that aren't installed (#752) Text in a family that isn't installed was drawn in a fallback font without a word. Now: - `text.missingFonts` lists the families a document names that no installed or built-in font provides: text layers and their styled ranges, text styles, and string variables bound to a font family (every mode, through aliases), with the number of text layers. - The desktop app shows a Missing fonts window after the user opens a file that has any: once installed fonts are indexed (before that every one of them would count as missing) and after any import report is closed. Files named on the command line count. Programmatic opens and the browser build never open it by themselves. Text > Missing fonts... shows it for the file in front; Escape closes it. - `figcraft-cli info` prints `missing font` lines (`missingFonts` with --json). - file-format-parity: the row claimed REST import warnings that no code produced. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/figcraft-cli/src/files.rs | 6 ++ apps/figcraft-cli/tests/fonts.rs | 22 ++++ apps/figcraft/src/main.rs | 3 + crates/engine/src/catalog.rs | 4 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/fonts.rs | 100 ++++++++++++++++++ crates/engine/src/fonts_tests.rs | 123 ++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/text/src/fontdb.rs | 5 + crates/text/src/lib.rs | 4 +- crates/ui-egui/locales/en.toml | 5 + crates/ui-egui/locales/es.toml | 6 ++ crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/import_report_tests.rs | 2 +- crates/ui-egui/src/lib.rs | 11 ++ crates/ui-egui/src/missing_fonts.rs | 102 ++++++++++++++++++ crates/ui-egui/src/missing_fonts_tests.rs | 112 ++++++++++++++++++++ docs/cli.md | 4 +- docs/file-format-parity.md | 3 +- docs/mcp.md | 2 + 20 files changed, 513 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/fonts.rs create mode 100644 crates/engine/src/fonts_tests.rs create mode 100644 crates/ui-egui/src/missing_fonts.rs create mode 100644 crates/ui-egui/src/missing_fonts_tests.rs diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 3e08474a..65b19ee4 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -155,6 +155,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult { Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames})) }) .collect(); + let missing = figcraft_engine::fonts::missing(&doc); let v = json!({ "file": path, "name": doc.name, @@ -164,6 +165,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult { "variables": doc.variables.variables.len(), "images": doc.images.len(), "notes": notes, + "missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>(), }); if as_json { println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); @@ -187,6 +189,10 @@ pub fn info(path: &str, as_json: bool) -> CliResult { println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]); } } + for m in &missing { + let layers = if m.layers == 1 { "text layer" } else { "text layers" }; + println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers); + } Ok(ExitCode::SUCCESS) } diff --git a/apps/figcraft-cli/tests/fonts.rs b/apps/figcraft-cli/tests/fonts.rs index c5901ac5..ef5c638f 100644 --- a/apps/figcraft-cli/tests/fonts.rs +++ b/apps/figcraft-cli/tests/fonts.rs @@ -45,3 +45,25 @@ fn headless_runs_resolve_installed_families_unless_opted_out() { assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family"); assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back"); } + +#[test] +fn info_lists_the_fonts_a_file_uses_that_are_missing() { + let d = std::env::temp_dir().join(format!("figcraft-cli-missing-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let (script, file) = (d.join("make.json"), d.join("doc.figcraft")); + let text = + |family: &str| json!({"command": "text.create", "params": {"text": "Hamburgefonstiv", "x": 0, "y": 0, "props": {"fontFamily": family}}}); + std::fs::write(&script, json!([text("FigCraft Missing CLI"), text("FigCraft Missing CLI")]).to_string()).unwrap(); + let cli = || Command::new(env!("CARGO_BIN_EXE_figcraft-cli")); + let made = cli().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap(); + assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr)); + + let out = cli().arg("info").arg(&file).arg("--json").output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["missingFonts"], json!([{"family": "FigCraft Missing CLI", "layers": 2}])); + + let out = cli().arg("info").arg(&file).output().unwrap(); + let shown = String::from_utf8_lossy(&out.stdout); + assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}"); +} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 10147c2c..fe89d0a7 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -161,6 +161,7 @@ fn main() -> eframe::Result { log::error!("{f}: {e}"); } } + let opened: Vec = session.docs.iter().map(|d| d.uid).collect(); // Documents a crashed or killed run left unsaved come back; this run keeps its own copies. let recovery_root = services::config_dir().map(|d| d.join("Recovery")); // The preferences first: they hold the online files a recovered document may be one of. @@ -194,6 +195,8 @@ fn main() -> eframe::Result { let mut fig = FigApp::new(session, Box::new(svc)); fig.automation_roots = roots; fig.ui.home = false; + // Files named on the command line are the user's too: their missing fonts are listed. + fig.ui.font_checks = opened; fig.note_recovered(recovered.copies); if let Some(link) = &args.join { figcraft_ui_egui::live::open_invite(&mut fig, link); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index c8f34fc1..4b6cd3fc 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -213,6 +213,8 @@ pub static MENU: &[MenuItem] = &[ m!("Text/Case", "Uppercase", "text.caseUpper"), m!("Text/Case", "Lowercase", "text.caseLower"), m!("Text/Case", "Title case", "text.caseTitle"), + m!("Text", SEP, ""), + m!("Text", "Missing fonts…", "text.missingFonts"), // Arrange m!("Arrange", "Align left", "align.left"), m!("Arrange", "Align horizontal centers", "align.hcenter"), @@ -287,7 +289,7 @@ pub fn items() -> impl Iterator { } /// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones). -const OWN: [&str; 2] = ["file.newFromTemplate", "help.gettingStarted"]; +const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"]; /// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer. pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b942fc25..f011238d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -80,6 +80,7 @@ fn registry() -> &'static Registry { list.extend(page::specs()); list.extend(view::specs()); list.extend(text::specs()); + list.extend(crate::fonts::specs()); list.extend(styles::specs()); list.extend(image::specs()); list.extend(variables::specs()); diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs new file mode 100644 index 00000000..e20d4d40 --- /dev/null +++ b/crates/engine/src/fonts.rs @@ -0,0 +1,100 @@ +//! Fonts a document names that this machine doesn't have (#752): text layers, their styled +//! ranges, text styles and the string variables bound to a font family. Text in such a family +//! is drawn in a fallback font, so the user is told which ones are missing. + +use std::collections::BTreeMap; + +use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::{Result, Session, spec}; + +/// A family the document names that no installed or built-in font provides. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Missing { + /// As the document spells it. + pub family: String, + /// Text layers that use it (0: only a text style or a variable names it). + pub layers: usize, +} + +/// The families `doc` names that the font database doesn't have, by name. On the desktop the +/// installed fonts are indexed in the background: ask once that's done +/// (`figcraft_text::system_fonts_scanned`), or every installed family counts as missing. +pub fn missing(doc: &Document) -> Vec { + // Lower-case family → (spelling, layers). + let mut used: BTreeMap = BTreeMap::new(); + let mut add = |family: &str, layer: bool| { + let family = family.trim(); + if family.is_empty() { + return; + } + let e = used.entry(family.to_lowercase()).or_insert_with(|| (family.to_string(), 0)); + e.1 += usize::from(layer); + }; + for n in doc.nodes() { + if let NodeKind::Text(t) = &n.kind { + let mut families: Vec<&str> = t.spans().into_iter().map(|(_, _, st)| st.font_family.as_str()).collect(); + families.push(&t.style.font_family); + families.sort_by_key(|f| f.trim().to_lowercase()); + families.dedup_by_key(|f| f.trim().to_lowercase()); + for f in families { + add(f, true); + } + } + if let Some(var) = n.bound.get("fontFamily") { + for f in string_values(&doc.variables, var) { + add(&f, false); + } + } + } + for st in &doc.styles { + if let StyleKind::Text { style } = &st.kind { + add(&style.font_family, false); + } + if let Some(var) = st.bound.get("fontFamily") { + for f in string_values(&doc.variables, var) { + add(&f, false); + } + } + } + used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect() +} + +/// Every string value of variable `id` in any mode, following aliases (at most 32 deep). +fn string_values(vars: &Variables, id: &str) -> Vec { + let mut out = Vec::new(); + let mut todo = vec![(id.to_string(), 0)]; + while let Some((id, depth)) = todo.pop() { + let Some(var) = vars.get(&id) else { continue }; + for v in var.values.values() { + match v { + VariableValue::String(s) => out.push(s.clone()), + VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)), + _ => {} + } + } + } + out +} + +fn missing_fonts(s: &mut Session, _: &Value) -> Result { + let families: Vec = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect(); + s.request_ui("dialog.missingFonts", json!({})); + Ok(json!({"families": families})) +} + +pub fn specs() -> Vec { + vec![spec!( + "text.missingFonts", + "Missing fonts", + "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", + false, + missing_fonts + )] +} + +#[cfg(test)] +#[path = "fonts_tests.rs"] +mod tests; diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs new file mode 100644 index 00000000..6ba925b3 --- /dev/null +++ b/crates/engine/src/fonts_tests.rs @@ -0,0 +1,123 @@ +use serde_json::json; + +use super::*; +use crate::params::id_of; +use figcraft_doc::{Style, StyleRun, TextStyle}; + +fn text(s: &mut Session, family: &str) -> figcraft_doc::NodeId { + let id = id_of(&s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"]).unwrap(); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind { + t.style.font_family = family.into(); + } + id +} + +fn families(s: &mut Session) -> Vec<(String, u64)> { + let r = s.execute("text.missingFonts", &json!({})).unwrap(); + r["families"].as_array().unwrap().iter().map(|f| (f["family"].as_str().unwrap().to_string(), f["layers"].as_u64().unwrap())).collect() +} + +/// An installed family (built in, or found by the system scan), if this machine has any. +fn installed() -> Option { + figcraft_text::scan_system_fonts(); + figcraft_text::family_names().into_iter().next() +} + +#[test] +fn lists_families_of_layers_ranges_styles_and_variables_once_each() { + let mut s = Session::new(); + text(&mut s, "FigCraft Missing A"); + let b = text(&mut s, "figcraft missing a"); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(b).unwrap().kind { + let style = TextStyle { font_family: "FigCraft Missing B".into(), ..t.style.clone() }; + t.runs.push(StyleRun { start: 0, end: 3, style }); + } + let doc = s.doc_mut().unwrap(); + doc.styles.push(Style { + id: "S:1".into(), + name: "Heading".into(), + description: String::new(), + kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } }, + bound: Default::default(), + }); + s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap(); + s.execute("variable.create", &json!({"collection": "Type", "name": "Family", "type": "string", "value": "FigCraft Missing D"})).unwrap(); + s.execute("variable.set", &json!({"variable": "Family", "mode": "Dark", "value": "FigCraft Missing H"})).unwrap(); + let var = s.doc().unwrap().variables.get("Family").unwrap().id.clone(); + s.doc_mut().unwrap().get_mut(b).unwrap().bound.insert("fontFamily".into(), var); + + assert_eq!( + families(&mut s), + vec![ + ("FigCraft Missing A".to_string(), 2), + ("FigCraft Missing B".to_string(), 1), + ("FigCraft Missing C".to_string(), 0), + ("FigCraft Missing D".to_string(), 0), + ("FigCraft Missing H".to_string(), 0), + ] + ); + assert!(s.ui_requests.iter().any(|r| r.kind == "dialog.missingFonts")); +} + +#[test] +fn installed_families_are_not_listed() { + let Some(family) = installed() else { + eprintln!("skipped: no fonts on this machine"); + return; + }; + let mut s = Session::new(); + text(&mut s, &family); + text(&mut s, &family.to_uppercase()); + text(&mut s, "FigCraft Missing E"); + assert_eq!(families(&mut s), vec![("FigCraft Missing E".to_string(), 1)]); +} + +#[test] +fn a_family_found_only_by_the_system_scan_is_not_missing() { + figcraft_text::scan_system_fonts(); + let embedded: Vec = figcraft_text::CRAFT_FONTS.iter().map(|f| f.family.to_lowercase()).collect(); + let Some(family) = figcraft_text::family_names().into_iter().find(|f| !embedded.contains(&f.to_lowercase())) else { + eprintln!("skipped: no system fonts on this machine"); + return; + }; + let mut s = Session::new(); + text(&mut s, &family); + assert!(families(&mut s).is_empty(), "{family} is installed"); +} + +#[test] +fn blank_families_and_aliases_are_handled() { + let mut s = Session::new(); + text(&mut s, " "); + s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap(); + s.execute("variable.create", &json!({"collection": "Type", "name": "Base", "type": "string", "value": "FigCraft Missing F"})).unwrap(); + let base = s.doc().unwrap().variables.get("Base").unwrap().id.clone(); + let t = text(&mut s, "FigCraft Missing G"); + let doc = s.doc_mut().unwrap(); + // A variable that only aliases another, and one aliasing itself. + let mut alias = doc.variables.get(&base).unwrap().clone(); + alias.id = "V:alias".into(); + alias.name = "Alias".into(); + for v in alias.values.values_mut() { + *v = VariableValue::Alias(base.clone()); + } + let mut cycle = alias.clone(); + cycle.id = "V:cycle".into(); + cycle.name = "Cycle".into(); + for v in cycle.values.values_mut() { + *v = VariableValue::Alias("V:cycle".into()); + } + doc.variables.variables.push(alias); + doc.variables.variables.push(cycle); + doc.get_mut(t).unwrap().bound.insert("fontFamily".into(), "V:alias".into()); + let other = text(&mut s, "FigCraft Missing G"); + s.doc_mut().unwrap().get_mut(other).unwrap().bound.insert("fontFamily".into(), "V:cycle".into()); + // The bound layer takes the value its alias resolves to; the cycle resolves to nothing. + assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]); +} + +#[test] +fn needs_an_open_document() { + let mut s = Session::empty(); + assert!(s.execute("text.missingFonts", &json!({})).is_err()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2254269e..508e70f9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -29,6 +29,7 @@ pub mod distance; pub mod doc_urls; pub mod drop; pub mod file_access; +pub mod fonts; pub mod guard; pub mod guides; pub mod inspect; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index a6d52d67..8d1840b1 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -852,6 +852,11 @@ pub fn family_names() -> Vec { db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families() } +/// The global db has `family` (embedded, loaded or indexed; any case). +pub fn has_family(family: &str) -> bool { + db().read().unwrap_or_else(std::sync::PoisonError::into_inner).has_family(family) +} + /// Style names of a family in the global db. pub fn family_styles(family: &str) -> Vec { db().write().unwrap_or_else(std::sync::PoisonError::into_inner).styles(family) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 27456d82..721471ad 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -16,8 +16,8 @@ mod onpath; pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ - Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords, - scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, + Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family, + no_coords, 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/en.toml b/crates/ui-egui/locales/en.toml index 59b25d94..4003891b 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -91,6 +91,7 @@ name = "English" "All components" = "All components" "All fonts" = "All fonts" "All supported properties" = "All supported properties" +"All the fonts this file uses are installed" = "All the fonts this file uses are installed" "All variables" = "All variables" "Alpha" = "Alpha" "Alphabetical" = "Alphabetical" @@ -771,6 +772,7 @@ name = "English" "Mirror angle and length" = "Mirror angle and length" "Mirroring" = "Mirroring" "Missing" = "Missing" +"Missing fonts" = "Missing fonts" "Missing layer" = "Missing layer" "Missing style" = "Missing style" "Missing variable" = "Missing variable" @@ -1374,6 +1376,7 @@ name = "English" "This condition is edited as an expression" = "This condition is edited as an expression" "This file" = "This file" "This file has unsaved changes" = "This file has unsaved changes" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code." "This frame is new on the branch." = "This frame is new on the branch." "This frame isn't on the branch." = "This frame isn't on the branch." @@ -1593,6 +1596,7 @@ name = "English" "nobody else here yet" = "nobody else here yet" "non-default slot settings are not represented and were skipped" = "non-default slot settings are not represented and were skipped" "now" = "now" +"only in a text style or variable" = "only in a text style or variable" "or" = "or" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "preferred slot component keys cannot be resolved to file nodes and were skipped" @@ -1700,6 +1704,7 @@ name = "English" "{n} replies" = { one = "{n} reply", other = "{n} replies" } "{n} rows" = { one = "{n} row", other = "{n} rows" } "{n} styles" = { one = "{n} style", other = "{n} styles" } +"{n} text layers" = { one = "{n} text layer", other = "{n} text layers" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } "{n} weeks ago" = { one = "{n} week ago", other = "{n} weeks ago" } "{n} years ago" = { one = "{n} year ago", other = "{n} years ago" } diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 01c5813b..b442d6d3 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -93,6 +93,7 @@ status = "draft — needs native review" "All comments" = "Todos los comentarios" "All components" = "Todos los componentes" "All fonts" = "Todas las fuentes" +"All the fonts this file uses are installed" = "Todas las fuentes que usa este archivo están instaladas" "All supported properties" = "Todas las propiedades compatibles" "All variables" = "Todas las variables" "Alpha" = "Alfa" @@ -774,6 +775,7 @@ status = "draft — needs native review" "Missing" = "No encontrado" "Missing layer" = "Capa no encontrada" "Missing style" = "Estilo no encontrado" +"Missing fonts" = "Fuentes que faltan" "Missing variable" = "Variable no encontrada" "Miter" = "Inglete" "Miter angle" = "Ángulo de inglete" @@ -1375,6 +1377,7 @@ status = "draft — needs native review" "This condition is edited as an expression" = "Esta condición se edita como expresión" "This file" = "Este archivo" "This file has unsaved changes" = "Este archivo tiene cambios sin guardar" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo." "This frame is new on the branch." = "Este marco es nuevo en la rama." "This frame isn't on the branch." = "Este marco no está en la rama." @@ -1594,6 +1597,7 @@ status = "draft — needs native review" "nobody else here yet" = "aún no hay nadie más" "non-default slot settings are not represented and were skipped" = "los ajustes de ranura no predeterminados no se representan y se omitieron" "now" = "ahora" +"only in a text style or variable" = "solo en un estilo de texto o una variable" "or" = "o" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "se conservaron las uniones de trazo de los puntos; los trazos discontinuos o de ancho variable se dibujan con la unión de la capa" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "las claves de componentes preferidos de las ranuras no corresponden a capas del archivo y se omitieron" @@ -1701,6 +1705,7 @@ status = "draft — needs native review" "{n} replies" = { one = "{n} respuesta", other = "{n} respuestas" } "{n} rows" = { one = "{n} fila", other = "{n} filas" } "{n} styles" = { one = "{n} estilo", other = "{n} estilos" } +"{n} text layers" = { one = "{n} capa de texto", other = "{n} capas de texto" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } "{n} weeks ago" = { one = "hace {n} semana", other = "hace {n} semanas" } "{n} years ago" = { one = "hace {n} año", other = "hace {n} años" } @@ -2126,6 +2131,7 @@ status = "draft — needs native review" "text.listOutdent" = "Reducir sangría de la lista" "text.listStyle" = "Estilo de lista" "text.maxLines" = "Máximo de líneas" +"text.missingFonts" = "Fuentes que faltan" "text.numberList" = "Lista numerada" "text.onPath" = "Texto en trazado" "text.paragraphIndent" = "Sangría de párrafo" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 39001b56..448501ab 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -151,6 +151,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { || u.find.is_some() || u.batch_rename.is_some() || u.import_report.is_some() + || u.missing_fonts.is_some() || u.confirm_close || u.export_preview || u.shortcuts_panel @@ -164,6 +165,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { || u.find.take().is_some() || u.batch_rename.take().is_some() || u.import_report.take().is_some() + || u.missing_fonts.take().is_some() { return; } @@ -178,6 +180,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { // Dropped files waiting to open: their card, then (once it has shown) the files. crate::pending_open::pending_opens(app, ctx); crate::import_report::show(app, ctx); + crate::missing_fonts::show(app, ctx); let t = Tokens::get(ctx); if app.ui.about { let mut open = true; diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index 8742ccd3..a2411582 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -108,7 +108,7 @@ fn escape_closes_the_import_report_like_other_simple_dialogs() { assert!(u.app.ui.import_report.is_none(), "Escape closes the report"); } -fn shown_texts(shapes: Vec) -> Vec { +pub(crate) fn shown_texts(shapes: Vec) -> Vec { fn walk(shape: &egui::Shape, out: &mut Vec) { match shape { egui::Shape::Text(t) => out.push(t.galley.text().to_string()), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5d1be479..b3a705ca 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -40,6 +40,7 @@ pub mod live; pub mod live_text; pub mod measure_overlay; pub mod menus; +pub mod missing_fonts; pub mod online; pub mod pending_open; pub mod plugins_ui; @@ -149,6 +150,8 @@ mod menu_aim_tests; #[cfg(test)] mod menu_input_tests; #[cfg(test)] +mod missing_fonts_tests; +#[cfg(test)] mod online_tests; #[cfg(test)] mod open_issues_tests; @@ -506,6 +509,9 @@ pub struct UiState { pub pending_open_frames: u8, /// The latest successful file import's summary and compatibility notes. pub import_report: Option, + /// Files the user opened whose fonts are yet to be checked (by document uid). + pub font_checks: Vec, + pub missing_fonts: Option, pub screenshot_requests: Vec<(u64, String)>, pub frame_ms: f64, pub render_ms: f64, @@ -633,6 +639,8 @@ impl Default for UiState { pending_opens: Vec::new(), pending_open_frames: 0, import_report: None, + font_checks: Vec::new(), + missing_fonts: None, screenshot_requests: Vec::new(), frame_ms: 0.0, render_ms: 0.0, @@ -761,6 +769,7 @@ impl FigApp { if let Some(result) = &result { import_report::capture(self, id, ¶ms, result); } + missing_fonts::check_later(self, id, result.as_ref()); result } @@ -875,6 +884,7 @@ impl FigApp { self.ui.about_tab = dialogs::AboutTab::About; } "dialog.preferences" => self.ui.preferences = true, + "dialog.missingFonts" => missing_fonts::request(self), "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, "palette.actions" => menus::toggle_palette(&mut self.ui), "panel.design" => self.ui.right_tab = RightTab::Design, @@ -1661,6 +1671,7 @@ impl FigApp { "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), }); state["pendingOpens"] = json!(self.ui.pending_opens.len()); + state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); state } } diff --git a/crates/ui-egui/src/missing_fonts.rs b/crates/ui-egui/src/missing_fonts.rs new file mode 100644 index 00000000..8236ca90 --- /dev/null +++ b/crates/ui-egui/src/missing_fonts.rs @@ -0,0 +1,102 @@ +//! The Missing fonts window (#752): the fonts a file uses that aren't installed, whose text is +//! drawn in a fallback font. It opens by itself after the user opens a file that has any (once +//! the installed fonts are indexed and any import report is closed), and from Text ▸ Missing fonts. + +use egui::RichText; +use figcraft_engine::fonts::{Missing, missing}; +use serde_json::{Value, json}; + +use crate::FigApp; +use crate::i18n::{tr, trn}; +use crate::theme::{Tokens, regular}; + +#[derive(Clone, Debug)] +pub struct Shown { + /// The document it lists: the window goes when that document closes. + pub uid: u64, + pub families: Vec, +} + +impl Shown { + pub fn inspect(&self) -> Value { + json!({"families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>()}) + } +} + +/// After a file the user opened (or imported into) is in front: look for missing fonts in it +/// once that can be told ([`show`]). +pub fn check_later(app: &mut FigApp, command: &str, result: Option<&Value>) { + if result.is_some() + && matches!(command, "file.open" | "file.openRecent" | "file.import") + && let Ok(ds) = app.session.ds() + { + app.ui.font_checks.push(ds.uid); + } +} + +/// The window for document `uid`, listing what it misses (even nothing, when asked for). +fn list(app: &mut FigApp, uid: u64) -> Option> { + let ds = app.session.docs.iter().find(|d| d.uid == uid)?; + let families = missing(&ds.doc); + app.ui.missing_fonts = Some(Shown { uid, families: families.clone() }); + Some(families) +} + +/// Text ▸ Missing fonts (`dialog.missingFonts`): the window for the file in front. +pub fn request(app: &mut FigApp) { + if let Ok(uid) = app.session.ds().map(|d| d.uid) { + list(app, uid); + } +} + +/// Installed fonts are indexed: before that, every one of them would count as missing. The +/// browser has only its built-in fonts, so files there don't open the window by themselves. +fn fonts_known() -> bool { + !cfg!(target_arch = "wasm32") && figcraft_text::system_fonts_scanned() +} + +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() && fonts_known() { + let uid = app.ui.font_checks.remove(0); + if list(app, uid).is_some_and(|f| f.is_empty()) { + app.ui.missing_fonts = None; + } + } + if app.ui.missing_fonts.as_ref().is_some_and(|m| !app.session.docs.iter().any(|d| d.uid == m.uid)) { + app.ui.missing_fonts = None; + } + let Some(shown) = &app.ui.missing_fonts else { return }; + let t = Tokens::get(ctx); + let mut open = true; + let mut close_clicked = false; + crate::dialogs::window(tr("Missing fonts"), ctx).open(&mut open).show(ctx, |ui| { + ui.set_width(380.0); + if shown.families.is_empty() { + ui.label(tr("All the fonts this file uses are installed")); + } else { + ui.label( + RichText::new(tr( + "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them.", + )) + .color(t.text_secondary) + .font(regular(11.0)), + ); + ui.add_space(6.0); + egui::ScrollArea::vertical().id_salt("missing_fonts").max_height(260.0).show(ui, |ui| { + for m in &shown.families { + ui.horizontal(|ui| { + ui.label(RichText::new(&m.family).strong()); + let used = + if m.layers == 0 { tr("only in a text style or variable").to_string() } else { trn("{n} text layers", m.layers, &[]) }; + ui.label(RichText::new(used).color(t.text_secondary)); + }); + } + }); + } + ui.add_space(12.0); + close_clicked = crate::widgets::button(ui, tr("Close"), true, true).clicked(); + }); + if !open || close_clicked { + app.ui.missing_fonts = None; + } +} diff --git a/crates/ui-egui/src/missing_fonts_tests.rs b/crates/ui-egui/src/missing_fonts_tests.rs new file mode 100644 index 00000000..ba86cc7e --- /dev/null +++ b/crates/ui-egui/src/missing_fonts_tests.rs @@ -0,0 +1,112 @@ +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::import_report::Report; +use crate::layers_drag_tests::Ui; + +/// A saved file with text in `families` (the first two layers share the first family). +fn saved(name: &str, families: &[&str]) -> String { + let mut s = Session::new(); + for (i, f) in families.iter().chain(families.first()).enumerate() { + s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 40 * i, "props": {"fontFamily": f}})).unwrap(); + } + let path = std::env::temp_dir().join(format!("figcraft-missing-fonts-{name}-{}.figcraft", std::process::id())); + let path = path.to_string_lossy().into_owned(); + s.execute("file.saveAs", &json!({"path": path})).unwrap(); + path +} + +/// The installed fonts are indexed, as the desktop app does at startup; false when this machine +/// opted out (nothing would ever count as known). +fn fonts_indexed() -> bool { + figcraft_text::scan_system_fonts(); + figcraft_text::system_fonts_scanned() +} + +fn listed(u: &Ui) -> Value { + u.app.inspect()["missingFonts"].clone() +} + +#[test] +fn opening_a_file_lists_its_missing_fonts_and_not_the_installed_ones() { + if !fonts_indexed() { + eprintln!("skipped: system fonts opted out"); + return; + } + let installed = figcraft_text::family_names().into_iter().next(); + let mut families = vec!["FigCraft Missing UI"]; + families.extend(installed.as_deref()); + let path = saved("open", &families); + let mut u = Ui::new(Session::new()); + // Programmatic opens (control channel, MCP, scripts) never open the window. + u.app.run("file.open", json!({"path": path})); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null); + + u.app.run_user("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + u.frame(vec![]); + assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing UI", "layers": 2}]})); + u.frame(vec![]); + let shown = u.frame_output(vec![]).shapes; + assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "2 text layers")); + + // It goes with its file. + u.app.run("file.close", json!({})); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null); +} + +#[test] +fn a_file_without_missing_fonts_opens_no_window() { + if !fonts_indexed() { + eprintln!("skipped: system fonts opted out"); + return; + } + let Some(installed) = figcraft_text::family_names().into_iter().next() else { + eprintln!("skipped: no fonts on this machine"); + return; + }; + let path = saved("clean", &[&installed]); + let mut u = Ui::new(Session::new()); + u.app.run_user("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null); + assert!(u.app.ui.font_checks.is_empty(), "the check ran"); +} + +#[test] +fn it_waits_for_the_import_report_to_close() { + if !fonts_indexed() { + eprintln!("skipped: system fonts opted out"); + return; + } + let path = saved("report", &["FigCraft Missing Report"]); + let mut u = Ui::new(Session::new()); + u.app.run_user("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + let mut report = + Report::from_result("file.open", &json!({}), &json!({"warnings": ["Synthetic note"], "summary": {"name": "Synthetic"}})).unwrap(); + report.uid = u.app.session.ds().unwrap().uid; + u.app.ui.import_report = Some(report); + u.frame(vec![]); + assert_eq!(listed(&u), Value::Null, "the import report comes first"); + u.app.ui.import_report = None; + u.frame(vec![]); + assert_eq!(listed(&u)["families"][0]["family"], "FigCraft Missing Report"); +} + +#[test] +fn text_menu_shows_the_window_for_the_file_in_front_even_with_nothing_missing() { + let mut u = Ui::new(Session::new()); + assert!(u.app.run("text.missingFonts", json!({})).is_some()); + assert_eq!(listed(&u), json!({"families": []})); + u.frame(vec![]); + let shown = u.frame_output(vec![]).shapes; + assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "All the fonts this file uses are installed")); + let escape = + vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }]; + u.frame(escape); + assert_eq!(listed(&u), Value::Null, "Escape closes it"); +} diff --git a/docs/cli.md b/docs/cli.md index 7b35c2d5..7af73fd6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -29,7 +29,9 @@ be open to the public: [server.md](server.md#running-a-public-server)). See [ser ### `info [--json]` Pages, node counts, top-level frames (id, type, name, size), style/variable/image counts. Repairs -of a damaged file and import warnings go to stderr as `note:` lines (`notes` with `--json`). +of a damaged file and import warnings go to stderr as `note:` lines (`notes` with `--json`). Fonts the +file names that aren't installed are listed as `missing font` lines (`missingFonts: [{family, +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. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b65f7729..54e2dd25 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -159,7 +159,7 @@ each. | Local fonts | yes (font helper / desktop app) | yes (system scan) | | Bundled/shared fonts | Google Fonts in the cloud | optional `CRAFT_FONTS_DIR` build input (storytold/craft-fonts) | | Variable fonts | yes | yes (axes, named instances, per-range axis values) | -| Missing-font replacement on open | yes | warnings only (REST import) | +| Missing fonts on open | listed, with replacement | listed (Missing fonts window after an open, Text ▸ Missing fonts…, `text.missingFonts`, `figcraft-cli info`); no replacement; text draws in a fallback font | ## Revision history @@ -167,6 +167,7 @@ each. |---|---|---| | 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 | Missing fonts (#752): the fonts a file names that aren't installed (text layers, styled ranges, text styles, font family variables in any mode) are listed when the user opens it (once installed fonts are indexed and any import report is closed), from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. The row claimed REST import warnings no code produced. | | 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 | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index e63283bd..a84bf05b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -233,6 +233,8 @@ Or in a project's `.mcp.json`: user units, e.g. `{"wght": 550, "wdth": 85}` (`null` = back to what the style name implies); `text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}` lists a font's axes and named instances (named instances are also valid `fontStyle` values). + `text.missingFonts` lists the families the document names that aren't installed + (`{families: [{family, layers}]}`); their text draws in a fallback font. OpenType features: `features {"liga": false, "tnum": true}`. Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and `textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline From db43dc5a4a25fc0b908286da451d6ee413ea5243 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 22:06:03 +0700 Subject: [PATCH 14/14] M7: missing fonts window only after a file opens; wait for the font scan (#752) From review: - The check ran after any file.open/openRecent/import result, so a pasted or dropped image, reopening a file that was already open, or Clear recent brought the window back. It now runs only for results with a `summary` (a file opened, or pages imported), and never in the browser. - Text > Missing fonts... before the startup scan finished listed installed fonts as missing. It now says it is checking the installed fonts and lists them once that is done (`figcraft_text::installed_fonts_known`, also true when system fonts are opted out, where checks used to wait forever). - ROADMAP progress log and file-format-parity status line. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 2 + crates/engine/src/fonts.rs | 2 +- crates/engine/src/fonts_tests.rs | 5 ++- crates/text/src/fontdb.rs | 10 +++++ crates/text/src/lib.rs | 2 +- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/missing_fonts.rs | 38 +++++++++++------- crates/ui-egui/src/missing_fonts_tests.rs | 47 ++++++++++++++++++++--- docs/file-format-parity.md | 2 +- 10 files changed, 87 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..7cef08a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-17** · Missing fonts (M7, #752): fonts a file names that aren't installed (text layers, styled ranges, text styles, font family variables) are listed in a Missing fonts window after the user opens the file (once installed fonts are indexed and any import report is closed), from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. Their text still draws in a fallback font. 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. @@ -317,6 +318,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 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). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | +| 2026-10-17 | minor | Missing fonts (M7, #752): fonts a file names that aren't installed are listed after the user opens it, from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. Numbers unchanged. | | 2026-10-17 | minor | 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 | 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-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. | diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index e20d4d40..3548124d 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -21,7 +21,7 @@ pub struct Missing { /// The families `doc` names that the font database doesn't have, by name. On the desktop the /// installed fonts are indexed in the background: ask once that's done -/// (`figcraft_text::system_fonts_scanned`), or every installed family counts as missing. +/// (`figcraft_text::installed_fonts_known`), or every installed family counts as missing. pub fn missing(doc: &Document) -> Vec { // Lower-case family → (spelling, layers). let mut used: BTreeMap = BTreeMap::new(); diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index 6ba925b3..a6e2d213 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -112,7 +112,10 @@ fn blank_families_and_aliases_are_handled() { doc.get_mut(t).unwrap().bound.insert("fontFamily".into(), "V:alias".into()); let other = text(&mut s, "FigCraft Missing G"); s.doc_mut().unwrap().get_mut(other).unwrap().bound.insert("fontFamily".into(), "V:cycle".into()); - // The bound layer takes the value its alias resolves to; the cycle resolves to nothing. + // The bound layer takes the value its alias resolves to (bindings are applied after the next + // command); the cycle resolves to nothing. + let NodeKind::Text(bound) = &s.doc().unwrap().node(t).unwrap().kind else { panic!() }; + assert_eq!(bound.style.font_family, "FigCraft Missing F"); assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]); } diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 8d1840b1..f9eda2c6 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -893,6 +893,16 @@ fn system_fonts_opted_out() -> bool { std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0") } +/// Every font text can use is known: the system fonts were indexed, or there are none to index +/// (opted out, or the web). Until then a family the scan would find counts as missing. +pub fn installed_fonts_known() -> bool { + #[cfg(not(target_arch = "wasm32"))] + if !system_fonts_opted_out() { + return system_fonts_scanned(); + } + true +} + /// Index the system fonts the first time the font database is used (headless apps: a run that /// lays out no text never pays for the scan). The desktop app scans in the background instead. pub fn scan_system_fonts_on_demand() { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 721471ad..683350f6 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -17,7 +17,7 @@ pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family, - no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, + installed_fonts_known, no_coords, 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/en.toml b/crates/ui-egui/locales/en.toml index 4003891b..41c5b9d6 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -198,6 +198,7 @@ name = "English" "C or ⇧C" = "C or ⇧C" "CMYK colour" = "CMYK colour" "Can view" = "Can view" +"Checking the fonts installed on this computer…" = "Checking the fonts installed on this computer…" "Checking the server…" = "Checking the server…" "Clear from this list" = "Clear from this list" "Code or invite link" = "Code or invite link" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index b442d6d3..db4fba3e 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -201,6 +201,7 @@ status = "draft — needs native review" "C or ⇧C" = "C o ⇧C" "CMYK colour" = "Color CMYK" "Can view" = "Puede ver" +"Checking the fonts installed on this computer…" = "Comprobando las fuentes instaladas en este equipo…" "Checking the server…" = "Comprobando el servidor…" "Clear from this list" = "Borrar de esta lista" "Code or invite link" = "Código o enlace de invitación" diff --git a/crates/ui-egui/src/missing_fonts.rs b/crates/ui-egui/src/missing_fonts.rs index 8236ca90..6c07068d 100644 --- a/crates/ui-egui/src/missing_fonts.rs +++ b/crates/ui-egui/src/missing_fonts.rs @@ -15,19 +15,26 @@ pub struct Shown { /// The document it lists: the window goes when that document closes. pub uid: u64, pub families: Vec, + /// Asked for before the installed fonts were indexed: the list comes once they are. + pub indexing: bool, } impl Shown { pub fn inspect(&self) -> Value { - json!({"families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>()}) + json!({ + "families": self.families.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::>(), + "indexing": self.indexing, + }) } } -/// After a file the user opened (or imported into) is in front: look for missing fonts in it -/// once that can be told ([`show`]). +/// After the user opened a file, or imported pages into one (results with a `summary`; not a +/// placed image or SVG, a file that was open already, or Clear recent): look for missing fonts in +/// it once that can be told ([`show`]). Not in the browser, which has only its built-in fonts. pub fn check_later(app: &mut FigApp, command: &str, result: Option<&Value>) { - if result.is_some() + if !cfg!(target_arch = "wasm32") && matches!(command, "file.open" | "file.openRecent" | "file.import") + && result.is_some_and(|r| r.get("summary").is_some()) && let Ok(ds) = app.session.ds() { app.ui.font_checks.push(ds.uid); @@ -38,25 +45,26 @@ pub fn check_later(app: &mut FigApp, command: &str, result: Option<&Value>) { fn list(app: &mut FigApp, uid: u64) -> Option> { let ds = app.session.docs.iter().find(|d| d.uid == uid)?; let families = missing(&ds.doc); - app.ui.missing_fonts = Some(Shown { uid, families: families.clone() }); + app.ui.missing_fonts = Some(Shown { uid, families: families.clone(), indexing: false }); Some(families) } /// Text ▸ Missing fonts (`dialog.missingFonts`): the window for the file in front. pub fn request(app: &mut FigApp) { - if let Ok(uid) = app.session.ds().map(|d| d.uid) { + let Ok(uid) = app.session.ds().map(|d| d.uid) else { return }; + if figcraft_text::installed_fonts_known() { list(app, uid); + } else { + app.ui.missing_fonts = Some(Shown { uid, families: Vec::new(), indexing: true }); } } -/// Installed fonts are indexed: before that, every one of them would count as missing. The -/// browser has only its built-in fonts, so files there don't open the window by themselves. -fn fonts_known() -> bool { - !cfg!(target_arch = "wasm32") && figcraft_text::system_fonts_scanned() -} - pub fn show(app: &mut FigApp, ctx: &egui::Context) { - if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() && fonts_known() { + let known = figcraft_text::installed_fonts_known(); + if let Some(uid) = app.ui.missing_fonts.as_ref().filter(|m| m.indexing && known).map(|m| m.uid) { + list(app, uid); + } + if !app.ui.font_checks.is_empty() && app.ui.import_report.is_none() && app.ui.missing_fonts.is_none() && known { let uid = app.ui.font_checks.remove(0); if list(app, uid).is_some_and(|f| f.is_empty()) { app.ui.missing_fonts = None; @@ -71,7 +79,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut close_clicked = false; crate::dialogs::window(tr("Missing fonts"), ctx).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); - if shown.families.is_empty() { + if shown.indexing { + ui.label(tr("Checking the fonts installed on this computer…")); + } else if shown.families.is_empty() { ui.label(tr("All the fonts this file uses are installed")); } else { ui.label( diff --git a/crates/ui-egui/src/missing_fonts_tests.rs b/crates/ui-egui/src/missing_fonts_tests.rs index ba86cc7e..2b11260a 100644 --- a/crates/ui-egui/src/missing_fonts_tests.rs +++ b/crates/ui-egui/src/missing_fonts_tests.rs @@ -16,11 +16,10 @@ fn saved(name: &str, families: &[&str]) -> String { path } -/// The installed fonts are indexed, as the desktop app does at startup; false when this machine -/// opted out (nothing would ever count as known). +/// The installed fonts are indexed, as the desktop app does at startup. fn fonts_indexed() -> bool { figcraft_text::scan_system_fonts(); - figcraft_text::system_fonts_scanned() + figcraft_text::installed_fonts_known() } fn listed(u: &Ui) -> Value { @@ -42,11 +41,12 @@ fn opening_a_file_lists_its_missing_fonts_and_not_the_installed_ones() { u.app.run("file.open", json!({"path": path})); u.frame(vec![]); assert_eq!(listed(&u), Value::Null); + u.app.run("file.close", json!({})); u.app.run_user("file.open", json!({"path": path})); std::fs::remove_file(&path).unwrap(); u.frame(vec![]); - assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing UI", "layers": 2}]})); + assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing UI", "layers": 2}], "indexing": false})); u.frame(vec![]); let shown = u.frame_output(vec![]).shapes; assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "2 text layers")); @@ -99,9 +99,10 @@ fn it_waits_for_the_import_report_to_close() { #[test] fn text_menu_shows_the_window_for_the_file_in_front_even_with_nothing_missing() { + assert!(fonts_indexed()); let mut u = Ui::new(Session::new()); assert!(u.app.run("text.missingFonts", json!({})).is_some()); - assert_eq!(listed(&u), json!({"families": []})); + assert_eq!(listed(&u), json!({"families": [], "indexing": false})); u.frame(vec![]); let shown = u.frame_output(vec![]).shapes; assert!(crate::import_report_tests::shown_texts(shown).iter().any(|t| t == "All the fonts this file uses are installed")); @@ -110,3 +111,39 @@ fn text_menu_shows_the_window_for_the_file_in_front_even_with_nothing_missing() u.frame(escape); assert_eq!(listed(&u), Value::Null, "Escape closes it"); } + +#[test] +fn asked_for_before_the_fonts_are_indexed_it_lists_them_once_they_are() { + assert!(fonts_indexed()); + let path = saved("indexing", &["FigCraft Missing Indexing"]); + let mut u = Ui::new(Session::new()); + u.app.run("file.open", json!({"path": path})); + std::fs::remove_file(&path).unwrap(); + // As Text ▸ Missing fonts leaves it while the startup scan is still running. + let uid = u.app.session.ds().unwrap().uid; + u.app.ui.missing_fonts = Some(crate::missing_fonts::Shown { uid, families: Vec::new(), indexing: true }); + u.frame(vec![]); + assert_eq!(listed(&u), json!({"families": [{"family": "FigCraft Missing Indexing", "layers": 2}], "indexing": false})); +} + +#[test] +fn placing_images_or_clearing_recents_does_not_bring_it_back() { + assert!(fonts_indexed()); + let path = saved("place", &["FigCraft Missing Place"]); + let svg = std::env::temp_dir().join(format!("figcraft-missing-fonts-place-{}.svg", std::process::id())); + std::fs::write(&svg, r#""#).unwrap(); + let mut u = Ui::new(Session::new()); + u.app.run_user("file.open", json!({"path": path})); + u.frame(vec![]); + assert_eq!(listed(&u)["families"][0]["family"], "FigCraft Missing Place"); + u.app.ui.missing_fonts = None; + // A placed SVG, the same file opened again (it only comes to the front), Clear recent. + assert!(u.app.run_user("file.import", json!({"path": svg.to_string_lossy()})).is_some()); + assert!(u.app.run_user("file.open", json!({"path": path})).is_some()); + u.app.run_user("file.openRecent", json!({"clear": true})); + std::fs::remove_file(&path).unwrap(); + std::fs::remove_file(&svg).unwrap(); + u.frame(vec![]); + assert!(u.app.ui.font_checks.is_empty()); + assert_eq!(listed(&u), Value::Null); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 54e2dd25..900559df 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-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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (missing fonts listed on open (#752); 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