diff --git a/ROADMAP.md b/ROADMAP.md index 9cc118eaa..b153e1ca1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). | | 2026-10-30 | minor | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged | | 2026-10-30 | minor | M8, #1076: bound total `.fig` ZIP image reads to 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, keeping the 256 MiB member cap. Members that do not fit are skipped before allocation and counted in the import warnings; failed reads still consume their bytes, and repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | | 2026-10-30 | minor | M8, #1114 (the rest): `file.open` and `file.import` (and recovery copies), `image.place {path}`, the image fill's file picker and `figcraft-cli run`'s script read the path they were given whole, so a device (`/dev/zero`), a pipe or an absurd file could fill memory or wait forever. Every whole-file read of a path now goes through `file_access::read_capped`, which looks at the path before opening it and refuses a folder, a device or pipe, or a file over its cap, with a message saying which, and reads at most the cap even if the file grows; the uncapped `file_access::read` is gone. Caps: documents 16 GiB (a `.figcraft` keeps its images as they are: #1313's file is 7.4 GB on disk), placed images 256 MiB (what a document takes from outside, `ImageData::MAX_BYTES`), scripts 1 GiB; fonts (256 MiB), plugin files and variables files already had theirs. Numbers unchanged. Tests: `path_read_caps_tests`, `file_access` tests, the CLI's `a_script_that_isnt_a_regular_file_is_refused`. | diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index b0c3d7eaf..0ba0aeeca 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -379,6 +379,13 @@ impl TextStyle { /// Weight from a style name ("Semi Bold Italic" → 600). pub fn style_weight(style: &str) -> u16 { let s = style.to_ascii_lowercase().replace([' ', '-'], ""); + // A weight number ("600", "600 Italic"), as some apps name styles. + let digits: String = s.chars().take_while(char::is_ascii_digit).collect(); + if let Ok(n) = digits.parse::() + && (1..=1000).contains(&n) + { + return n; + } let table: [(&str, u16); 10] = [ ("thin", 100), ("extralight", 200), diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 1ed6dafe3..a5c4167b8 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -140,6 +140,7 @@ fn load_any( if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; link_instances(&mut doc); + installed_fonts_first(); return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); } let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?; @@ -381,11 +382,19 @@ pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option Option> { let r = figcraft_format::fig::try_import(bytes, name)?; Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { link_instances(&mut doc); + installed_fonts_first(); figcraft_format::Loaded { issues: warnings, ..doc.into() } })) } @@ -562,7 +571,8 @@ fn font_report_with_families(doc: &Document, installed: &[String]) -> Value { } else if known.is_empty() { None } else { - Some(known.contains(&lower)) + // Also under the other name its fonts give the family ("Economia Medium"). + Some(known.contains(&lower) || figcraft_text::has_family(&family)) }; json!({"family":family,"available":available,"layers":layers.into_iter().collect::>()}) }) diff --git a/crates/engine/src/fig_font_names_tests.rs b/crates/engine/src/fig_font_names_tests.rs new file mode 100644 index 000000000..a445624d2 --- /dev/null +++ b/crates/engine/src/fig_font_names_tests.rs @@ -0,0 +1,38 @@ +//! A `.fig` file whose text names an installed family another way than the font list does (#1292) +//! isn't reported missing and draws in that family: by the legacy name of one of its weights +//! (" Bold", style Regular) or spelt with other case and spaces. Through the real import +//! (a file the app exported), with an embedded family standing in for an installed one. + +use base64::Engine as _; +use figcraft_doc::NodeKind; +use serde_json::json; + +use crate::Session; + +fn text(s: &mut Session, family: &str) { + let id = s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"].as_u64().unwrap(); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(figcraft_doc::NodeId(id)).unwrap().kind { + t.style.font_family = family.into(); + } +} + +#[test] +fn an_imported_file_finds_installed_families_by_their_other_names() { + let Some(family) = figcraft_text::family_names().into_iter().find(|f| figcraft_text::family_styles(f).iter().any(|s| s == "Bold")) else { + return; + }; + let legacy = format!("{family} Bold"); + let squashed = family.to_uppercase().replace(' ', ""); + let mut s = Session::new(); + text(&mut s, &legacy); + text(&mut s, &squashed); + text(&mut s, "FigCraft Surely Missing"); + let r = s.execute("file.export", &json!({"format": "FIG"})).unwrap(); + let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap(); + let (doc, _) = crate::cmd::file::load_bytes(&bytes, "names.fig").unwrap(); + let missing: Vec = crate::fonts::missing(&doc).into_iter().map(|m| m.family).collect(); + assert_eq!(missing, ["FigCraft Surely Missing"], "{family}"); + let bold = figcraft_text::find_face(&legacy, "Regular").unwrap(); + assert_eq!((bold.family.to_lowercase(), bold.weight), (family.to_lowercase(), 700)); + assert_eq!(figcraft_text::find_face(&squashed, "Bold").unwrap().family.to_lowercase(), family.to_lowercase()); +} diff --git a/crates/engine/src/group_panic_tests.rs b/crates/engine/src/group_panic_tests.rs new file mode 100644 index 000000000..48754e389 --- /dev/null +++ b/crates/engine/src/group_panic_tests.rs @@ -0,0 +1,149 @@ +//! A panic in a grouped closure, outside any command, ends the group like a failure (#1085): the +//! documents, selection, undo and redo, history limit and journal are as they were, and the panic +//! goes on to the caller. + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::Arc; + +use serde_json::json; + +use crate::{HistoryEntry, NodeId, Session}; + +fn rect(s: &mut Session, x: i64) -> NodeId { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().map(NodeId).unwrap() +} + +fn labels(entries: &[HistoryEntry]) -> Vec { + entries.iter().map(|e| e.label.clone()).collect() +} + +#[test] +fn a_grouped_closure_that_panics_leaves_the_session_as_before() { + let mut s = Session::new(); + let first = rect(&mut s, 0); + let second = rect(&mut s, 20); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [first]})).unwrap(); + let ds = s.ds().unwrap(); + let (doc, selection, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + let (undo, redo, limit) = (labels(&ds.history.undo), labels(&ds.history.redo), ds.history.limit); + assert_eq!(redo.len(), 1); + let journal = s.journal.clone(); + let depth = s.undo_depth(); + + let unwound = catch_unwind(AssertUnwindSafe(|| { + s.grouped("Edit then panic", |s| -> crate::Result<()> { + s.execute("node.set", &json!({"id": first, "props": {"name": "Renamed"}}))?; + rect(s, 40); + s.execute("select.set", &json!({"ids": []}))?; + panic!("a bug in the closure"); + }) + })); + + let payload = unwound.unwrap_err(); + assert_eq!(payload.downcast_ref::<&str>(), Some(&"a bug in the closure"), "the original panic goes on to the caller"); + let ds = s.ds().unwrap(); + assert!(Arc::ptr_eq(&ds.doc, &doc)); + assert_eq!(*ds.doc, *doc); + assert_eq!(ds.selection, selection); + assert_eq!(ds.page, page); + assert_eq!(labels(&ds.history.undo), undo); + assert_eq!(labels(&ds.history.redo), redo); + assert_eq!(ds.history.limit, limit); + assert_eq!(s.journal, journal); + assert_eq!(s.undo_depth(), depth); + assert_eq!(s.group_depth, 0); + assert!(s.closed_in_group.is_empty()); + + // The redo step survived, and the next edit pushes one step of its own. + s.execute("edit.redo", &json!({})).unwrap(); + assert!(s.doc().unwrap().contains(second)); + rect(&mut s, 60); + assert_eq!(s.undo_depth(), (depth.0 + 2, 0)); +} + +#[test] +fn a_grouped_closure_that_panics_releases_the_live_hold_so_later_edits_sync() { + use crate::live_tests::{push, relay, serve, session}; + + let (mut s, script) = session(); + let first = rect(&mut s, 0); + s.execute("collab.host", &json!({})).unwrap(); + let (mut server, id, welcome) = serve(s.doc().unwrap()); + push(&script, vec![welcome]); + s.live_poll(); + assert!(s.live_doc(s.ds().unwrap().uid)); + let before = s.doc().unwrap().clone(); + script.lock().unwrap().msgs.clear(); + + let unwound = catch_unwind(AssertUnwindSafe(|| { + s.grouped("Rename then panic", |s| -> crate::Result<()> { + s.execute("node.set", &json!({"id": first, "props": {"name": "Rolled back"}}))?; + s.live_sync(); + assert!(script.lock().unwrap().msgs.is_empty(), "the edit waits for the group to end"); + panic!("a bug in the live closure"); + }) + })); + + assert!(unwound.is_err()); + assert_eq!(s.doc().unwrap(), &before); + s.live_sync(); + assert!(script.lock().unwrap().msgs.is_empty(), "the rolled-back edit never goes out"); + + s.execute("node.set", &json!({"id": first, "props": {"name": "Sent afterwards"}})).unwrap(); + s.live_sync(); + assert_eq!(script.lock().unwrap().msgs.len(), 1, "the later edit goes out"); + let replies = relay(&script, &mut server, id); + push(&script, replies); + s.live_poll(); + assert_eq!(server.document().node(first).unwrap().name, "Sent afterwards"); + assert_eq!(s.doc().unwrap(), server.document()); +} + +#[test] +fn a_panic_in_an_inner_group_rolls_back_through_the_outer_one() { + let mut s = Session::new(); + let unwound = catch_unwind(AssertUnwindSafe(|| { + s.grouped("outer", |s| { + s.execute("comment.add", &json!({"x": 1, "y": 2, "text": "Rolled back"}))?; + s.grouped("inner", |s| -> crate::Result<()> { + s.execute("comment.add", &json!({"x": 3, "y": 4, "text": "Also rolled back"}))?; + panic!("a bug in the inner closure"); + }) + }) + })); + + assert!(unwound.is_err()); + assert!(s.doc().unwrap().comments.is_empty()); + assert!(s.pending_events.is_empty()); + assert_eq!(s.events.latest(), 0); + assert_eq!(s.group_depth, 0); + assert_eq!(s.undo_depth(), (0, 0)); + + // The session still groups normally afterwards. + s.grouped("again", |s| s.execute("comment.add", &json!({"x": 5, "y": 6, "text": "Kept"})).map(|_| ())).unwrap(); + assert_eq!(s.doc().unwrap().comments.len(), 1); + assert_eq!(s.undo_depth(), (1, 0)); +} + +#[test] +fn a_group_that_closes_and_opens_tabs_then_panics_brings_the_tabs_back() { + let mut s = Session::new(); + s.execute("file.new", &json!({"name": "Second"})).unwrap(); + let uids: Vec<_> = s.docs.iter().map(|ds| ds.uid).collect(); + let active = s.active; + + let unwound = catch_unwind(AssertUnwindSafe(|| { + s.grouped("Close, open, panic", |s| -> crate::Result<()> { + s.execute("file.close", &json!({"index": 1, "force": true}))?; + s.execute("file.new", &json!({"name": "Temporary"}))?; + panic!("a bug after switching tabs"); + }) + })); + + assert!(unwound.is_err()); + assert_eq!(s.docs.iter().map(|ds| ds.uid).collect::>(), uids); + assert_eq!(s.active, active); + assert!(s.closed_in_group.is_empty()); + assert!(s.docs.iter().all(|ds| ds.history.limit != usize::MAX)); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1666ae2b8..72639fb95 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -307,6 +307,8 @@ mod fig_export_assets_tests; #[cfg(test)] mod fig_export_tests; #[cfg(test)] +mod fig_font_names_tests; +#[cfg(test)] mod fig_import_tests; #[cfg(test)] mod fill_none_tests; @@ -327,6 +329,8 @@ mod gesture_preview_tests; #[cfg(test)] mod gradient_shorthand_tests; #[cfg(test)] +mod group_panic_tests; +#[cfg(test)] mod guide_snap_tests; #[cfg(test)] mod hide_publishing_tests; diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index a10b3f4b7..4aa847603 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -251,7 +251,8 @@ impl Session { } /// Run `f` as one undo step called `label`: the commands it executes push no steps of their - /// own, and when it fails the documents stay at their group-start state. + /// own, and when it fails or panics the documents stay at their group-start state (a panic + /// then carries on unwinding). pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result) -> Result { self.ds()?; self.checkpointed(Some(label), f) @@ -261,7 +262,12 @@ impl Session { /// A checkpoint also works between closing the last tab and opening another one. fn checkpointed(&mut self, label: Option<&str>, f: impl FnOnce(&mut Self) -> Result) -> Result { let before = self.group_checkpoint(label.is_some()); - let result = f(self); + // A panic in `f` ends the group like a failure, then goes on to the caller's guard (#1085); + // the stand-in error only drives the rollback and is never returned. + let (result, panic) = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) { + Ok(result) => (result, None), + Err(payload) => (Err(EngineError::Other("the group panicked".into())), Some(payload)), + }; self.group_depth -= 1; let at_root = self.group_depth == 0; for ds in &before.docs { @@ -297,6 +303,9 @@ impl Session { self.closed_in_group.clear(); self.flush_pending_events(); } + if let Some(payload) = panic { + std::panic::resume_unwind(payload); + } result } diff --git a/crates/mcp/tests/it/inspect_fields.rs b/crates/mcp/tests/it/inspect_fields.rs index 19119aeb8..930ba3c78 100644 --- a/crates/mcp/tests/it/inspect_fields.rs +++ b/crates/mcp/tests/it/inspect_fields.rs @@ -154,6 +154,11 @@ fn compact_children_keep_window_markers_and_page_in_order() { assert_eq!(&seen, expected); } +/// `text` with Unix line ends: a Windows checkout with `core.autocrlf` has the docs with CRLF. +fn lf(text: &str) -> String { + text.replace("\r\n", "\n") +} + fn json_example(section: &str) -> &str { section.split("```json\n").nth(1).unwrap().split("```").next().unwrap() } @@ -164,7 +169,7 @@ fn compact_examples_run_verbatim_from_the_docs() { for _ in 0..2 { c.create(json!({"type": "rectangle"})); } - let docs = include_str!("../../../../docs/mcp.md"); + let docs = lf(include_str!("../../../../docs/mcp.md")); let section = docs.split("### Compact layer trees\n").nth(1).unwrap(); for line in json_example(section).lines() { let reply: Value = serde_json::from_str(&c.server.handle_line(line).unwrap()).unwrap(); @@ -174,7 +179,7 @@ fn compact_examples_run_verbatim_from_the_docs() { for _ in 0..2 { h.call("engine.execute", json!({"command": "node.create", "params": {"type": "rectangle"}})).unwrap(); } - let docs = include_str!("../../../../docs/control-protocol.md"); + let docs = lf(include_str!("../../../../docs/control-protocol.md")); let section = docs.split("### `node.get ").nth(1).unwrap(); for line in json_example(section).lines() { let request: Value = serde_json::from_str(line).unwrap(); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 3ccaa8ef1..d64c6774a 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -365,6 +365,48 @@ fn norm_style(s: &str) -> String { s.to_lowercase().replace([' ', '-', '_'], "") } +/// A family name without spaces, hyphens and underscores (lower-case already). +fn squash(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace() && !matches!(c, '-' | '_')).collect() +} + +/// `s` is only words of a weight or slope (or a weight number): what a legacy family name adds to +/// the typographic one ("Medium", "Semi Bold", "600"). Not widths: a condensed cut is usually a +/// family of its own ("Roboto Condensed"), so a missing one isn't taken for its wider sibling. +fn style_words(s: &str) -> bool { + const WORDS: [&str; 23] = [ + "thin", + "hairline", + "extralight", + "ultralight", + "light", + "book", + "regular", + "normal", + "roman", + "medium", + "semibold", + "demibold", + "bold", + "extrabold", + "ultrabold", + "black", + "heavy", + "italic", + "oblique", + "semi", + "demi", + "extra", + "ultra", + ]; + let mut words = s.split_whitespace().peekable(); + words.peek().is_some() + && words.all(|w| { + let w = w.to_lowercase(); + WORDS.contains(&w.as_str()) || w.parse::().is_ok_and(|n| (1..=1000).contains(&n)) + }) +} + /// A style name that says italic. fn italic_name(s: &str) -> bool { s.to_lowercase().contains("italic") @@ -545,8 +587,25 @@ pub struct FontDb { wanted: Vec, /// Browser fonts asked for whose bytes haven't come. asked: std::collections::BTreeSet, + /// Bumped whenever families may have come or gone, so `names` is made again. + generation: u64, + /// Name lookups worked out as of a generation ([`key`](Self::key), [`resolve`](Self::resolve)). + names: std::sync::Mutex, } +/// [`FontDb::key`]'s and [`FontDb::resolve`]'s answers for the families known in one generation. +#[derive(Default)] +struct Names { + generation: u64, + /// Each family's key by its squashed name (none yet: not made since the generation began). + squashed: Option>, + /// (family, style) asked for ? (key, style) to draw with; at most [`MAX_RESOLVED`]. + resolved: std::collections::HashMap<(String, String), (String, String)>, +} + +/// The most name lookups kept (a file naming more starts the list again). +const MAX_RESOLVED: usize = 4096; + /// What a later walk of the font folders found ([`rescan_system_fonts`]). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Rescan { @@ -590,6 +649,7 @@ fn raw_db() -> &'static RwLock { impl FontDb { pub fn add(&mut self, face: Face) -> Arc { + self.generation += 1; let key = face.family.to_lowercase(); let display = face.family.clone(); let face = Arc::new(face); @@ -653,29 +713,156 @@ impl FontDb { v.push((w, f.italic || italic_name(&inst.name), k, inst.name.clone())); } } + // A family installed as one family per weight offers those weights too. + for (_, style) in self.split_families(&key) { + v.push((pending_weight(&style).clamp(1, 1000) as u16, italic_name(&style), 0, style)); + } v.sort(); let mut seen = std::collections::HashSet::new(); v.retain(|x| seen.insert(norm_style(&x.3))); v.into_iter().map(|x| x.3).collect() } - /// The key `family` is known under: itself, or for a name designs use for Apple's system font - /// (SF Pro, SF UI Text, …) the system font when no font of that name is installed. + /// The key `family` is known under: itself in any case; for a name designs use for Apple's + /// system font (SF Pro, SF UI Text, …) the system font when no font of that name is installed; + /// else a family spelled the same but for spaces, hyphens and underscores ("SFPro", "Font + /// Awesome"). Unknown: `family` in lower case. pub fn key(&self, family: &str) -> String { - let k = family.to_lowercase(); - let known = |k: &str| self.families.contains_key(k) || self.pending.contains_key(k); - if known(&k) { + let k = family.trim().to_lowercase(); + if self.known(&k) { return k; } - match apple_alias(&k) { - Some(target) if known(target) => target.to_string(), - _ => k, + if let Some(target) = apple_alias(&k).filter(|t| self.known(t)) { + return target.to_string(); } + let mut names = self.names(); + let squashed = names.squashed.get_or_insert_with(|| { + let mut map = std::collections::HashMap::new(); + for key in self.families.keys().chain(self.pending.keys()) { + map.entry(squash(key)).or_insert_with(|| key.clone()); + } + map + }); + squashed.get(&squash(&k)).cloned().unwrap_or(k) } + /// The name lookups of this generation (those of an earlier one are dropped). + fn names(&self) -> std::sync::MutexGuard<'_, Names> { + let mut names = self.names.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if names.generation != self.generation { + *names = Names { generation: self.generation, ..Names::default() }; + } + names + } + + fn known(&self, key: &str) -> bool { + self.families.contains_key(key) || self.pending.contains_key(key) + } + + /// Family `family` is installed: by that name ([`key`](Self::key)), or under the other way + /// its fonts name it ([`resolve`](Self::resolve)). pub fn has_family(&self, family: &str) -> bool { let k = self.key(family); - self.families.contains_key(&k) || self.pending.contains_key(&k) + self.known(&k) || self.split_request(family, "Regular").is_some() || !self.split_families(&k).is_empty() + } + + /// The family key and style to draw `family` + `style` with. Fonts name a family two ways: by + /// its typographic name ("Economia", style "Medium") and, for apps that only know four styles + /// a family, by a legacy name per weight ("Economia Medium", style "Regular"); fonts without + /// typographic names are installed under the legacy ones only. A design may use either, so + /// either finds the other: a family named with style words finds the family without them, + /// and a style the family lacks finds the family named with it. Else `family`'s key as is. + pub fn resolve(&self, family: &str, style: &str) -> (String, String) { + let asked = (family.to_string(), style.to_string()); + if let Some(found) = self.names().resolved.get(&asked) { + return found.clone(); + } + let found = self.resolve_now(family, style); + let mut names = self.names(); + if names.resolved.len() >= MAX_RESOLVED { + names.resolved.clear(); + } + names.resolved.insert(asked, found.clone()); + found + } + + fn resolve_now(&self, family: &str, style: &str) -> (String, String) { + let k = self.key(family); + // Most families have no family named after them and a weight: nothing more to look at. + if self.known(&k) && (!self.has_weight_families(&k) || self.style_known(&k, style)) { + return (k, style.to_string()); + } + if let Some(found) = self.weight_family(&k, style) { + return found; + } + if self.known(&k) { + return (k, style.to_string()); + } + self.split_request(family, style).unwrap_or((k, style.to_string())) + } + + /// Some family is named `key` and then more words ("economia medium" for "economia"). + fn has_weight_families(&self, key: &str) -> bool { + let prefix = format!("{key} "); + let after = |k: Option<&String>| k.is_some_and(|k| k.starts_with(&prefix)); + after(self.families.range(prefix.clone()..).next().map(|(k, _)| k)) || after(self.pending.range(prefix.clone()..).next().map(|(k, _)| k)) + } + + /// Family `key` has a face (or an indexed one) named `style`. + fn style_known(&self, key: &str, style: &str) -> bool { + let faces = self.families.get(key).map(|(_, idx)| idx.as_slice()).unwrap_or_default(); + faces.iter().filter_map(|i| self.faces.get(*i)).any(|f| has_style(f, style)) + || self.pending.get(key).is_some_and(|(_, entries)| entries.iter().any(|e| norm_style(&e.style) == norm_style(style))) + } + + /// The legacy family for `style` of family `key` ("economia medium" for Medium; for Medium + /// Italic, "economia medium italic", else "economia medium" Italic). + fn weight_family(&self, key: &str, style: &str) -> Option<(String, String)> { + let upright: Vec<&str> = style.split_whitespace().filter(|w| !matches!(w.to_lowercase().as_str(), "italic" | "oblique")).collect(); + if upright.is_empty() || !style_words(&upright.join(" ")) || norm_style(&upright.join("")) == "regular" { + return None; + } + let whole = self.key(&format!("{key} {style}")); + if self.known(&whole) { + return Some((whole, "Regular".into())); + } + let weight = self.key(&format!("{key} {}", upright.join(" "))); + self.known(&weight).then(|| (weight, if italic_name(style) { "Italic" } else { "Regular" }.into())) + } + + /// `family` named with style words ("Economia Medium") when only the family without them is + /// known: that family, with those words for its style (and `style` after them unless it is + /// Regular). + fn split_request(&self, family: &str, style: &str) -> Option<(String, String)> { + let words: Vec<&str> = family.split_whitespace().collect(); + (1..words.len()).rev().find_map(|i| { + let (base, rest) = (words[..i].join(" "), words[i..].join(" ")); + let k = self.key(&base); + (style_words(&rest) && self.known(&k)).then(|| { + let style = match norm_style(style).as_str() { + "" | "regular" | "normal" => rest, + _ => format!("{rest} {style}"), + }; + (k, style) + }) + }) + } + + /// Known families named `key` and then style words ("economia medium" for "economia"): with + /// those words as the family's display name spells them ("Medium"). + fn split_families(&self, key: &str) -> Vec<(String, String)> { + let prefix = format!("{key} "); + let mut out: Vec<(String, String)> = Vec::new(); + let after = |k: &&String| k.starts_with(&prefix); + let families = self.families.range(prefix.clone()..).map(|(k, (d, _))| (k, d)).take_while(|(k, _)| after(k)); + let pending = self.pending.range(prefix.clone()..).map(|(k, (d, _))| (k, d)).take_while(|(k, _)| after(k)); + for (k, display) in families.chain(pending) { + let rest = display.chars().skip(prefix.chars().count()).collect::(); + if style_words(&rest) && !out.iter().any(|(o, _)| o == k) { + out.push((k.clone(), rest.trim().to_string())); + } + } + out } /// Read a system family's faces from disk; with `only`, just the face nearest that style (the @@ -704,6 +891,7 @@ impl FontDb { vec![one] } }; + self.generation += 1; let entries = self.keep_local(&key, display.as_deref().unwrap_or(family), entries, only.is_some()); let mut by_path: BTreeMap>> = BTreeMap::new(); for e in entries { @@ -727,6 +915,8 @@ impl FontDb { /// The best face for `family` + `style` (exact style name, else nearest weight/italic). pub fn find(&mut self, family: &str, style: &str) -> Option> { + let (family, style) = self.resolve(family, style); + let (family, style) = (family.as_str(), style.as_str()); self.load_family(family, None); // A font the browser named is read one style at a time: the one asked for, unless a face // read already has it. @@ -740,6 +930,8 @@ impl FontDb { /// `style`: a fallback for missing characters needs one face, not every file of a CJK family /// (10–20 MB each). The family's other faces load when it is used by name. pub fn find_one(&mut self, family: &str, style: &str) -> Option> { + let (family, style) = self.resolve(family, style); + let (family, style) = (family.as_str(), style.as_str()); if !self.families.contains_key(&self.key(family)) { self.load_family(family, Some(style)); } @@ -819,6 +1011,7 @@ impl FontDb { /// Of `entries` (taken from a family's pending ones), those to read from disk: the browser's /// go back to wait (under `display`), and with `ask` are asked for ([`take_wanted_local_fonts`]). fn keep_local(&mut self, key: &str, display: &str, entries: Vec, ask: bool) -> Vec { + self.generation += 1; let (local, files): (Vec, Vec) = entries.into_iter().partition(|e| e.local.is_some()); if local.is_empty() { return files; @@ -846,6 +1039,7 @@ impl FontDb { /// Offer the fonts the browser named: they join the families by name (a family the app has /// keeps its faces, and the styles it lacks wait beside them). The families that were unknown. pub fn add_local(&mut self, fonts: Vec) -> Vec { + self.generation += 1; let before: std::collections::BTreeSet = self.families().into_iter().collect(); let mut found: BTreeMap)> = BTreeMap::new(); for f in fonts.into_iter().filter(|f| !f.family.trim().is_empty()) { @@ -869,6 +1063,7 @@ impl FontDb { pub fn load_local(&mut self, id: usize, bytes: Vec) -> Option { self.asked.remove(&id); let (key, i) = self.pending.iter().find_map(|(k, (_, v))| v.iter().position(|e| e.local == Some(id)).map(|i| (k.clone(), i)))?; + self.generation += 1; let (display, entries) = self.pending.get_mut(&key)?; let (display, e) = (display.clone(), entries.remove(i)); if entries.is_empty() { @@ -885,6 +1080,7 @@ impl FontDb { /// family not loaded yet takes the walk's files, and a loaded family keeps its faces and gets /// the files it hasn't read (styles installed since) as pending, read when it is next used. fn merge_rescan(&mut self, found: BTreeMap)>) -> Rescan { + self.generation += 1; let mut out = Rescan::default(); for (k, (display, entries)) in found { if self.families.contains_key(&k) { @@ -926,6 +1122,7 @@ impl FontDb { /// its faces, which win, and the installed styles it lacks wait beside them (#1246: release /// builds embed Poppins Regular, Italic and Bold, so an installed Poppins SemiBold went unseen). fn merge_scan(&mut self, found: BTreeMap)>) { + self.generation += 1; for (k, (display, mut entries)) in found { if let Some((_, idx)) = self.families.get(&k) { let have: Vec<&Arc> = idx.iter().filter_map(|i| self.faces.get(*i)).collect(); @@ -1360,3 +1557,7 @@ mod tests; #[cfg(test)] #[path = "fontdb_local_tests.rs"] mod local_tests; + +#[cfg(test)] +#[path = "fontdb_names_tests.rs"] +mod names_tests; diff --git a/crates/text/src/fontdb_names_tests.rs b/crates/text/src/fontdb_names_tests.rs new file mode 100644 index 000000000..0d31b7c56 --- /dev/null +++ b/crates/text/src/fontdb_names_tests.rs @@ -0,0 +1,106 @@ +//! A family is found however a design names it (#1292): fonts name a family by its typographic +//! name ("Economia", style "Medium") and by a legacy name per weight ("Economia Medium", style +//! "Regular"), and fonts without typographic names are installed under the legacy ones only. A +//! design (an imported file, a paste) may use either, or spell the family with other spaces or +//! case; the picker lists what is installed. Synthetic family names only, over pending entries +//! (no files) and the embedded faces when the build has them. + +use std::sync::Arc; + +use super::{CRAFT_FONTS, Face, FontBytes, FontDb, Pending, style_words}; + +fn entry(style: &str) -> Pending { + Pending { path: format!("no-such-dir/{style}.ttf").into(), index: 0, style: style.into(), postscript: None, local: None } +} + +fn installed(families: &[(&str, &[&str])]) -> FontDb { + let mut db = FontDb::default(); + for (family, styles) in families { + db.pending.insert(family.to_lowercase(), ((*family).into(), styles.iter().map(|s| entry(s)).collect())); + } + db +} + +fn resolved(db: &FontDb, family: &str, style: &str) -> (String, String) { + db.resolve(family, style) +} + +fn pair(family: &str, style: &str) -> (String, String) { + (family.into(), style.into()) +} + +#[test] +fn a_legacy_family_name_finds_the_typographic_family() { + // The fonts have typographic names: one family with every weight. + let db = installed(&[("Synthetic Grotesk", &["Regular", "Medium", "SemiBold", "Bold", "SemiBold Italic"])]); + assert_eq!(resolved(&db, "Synthetic Grotesk Medium", "Regular"), pair("synthetic grotesk", "Medium")); + assert_eq!(resolved(&db, "Synthetic Grotesk SemiBold", "Italic"), pair("synthetic grotesk", "SemiBold Italic")); + assert_eq!(resolved(&db, "Synthetic Grotesk Regular", "Regular"), pair("synthetic grotesk", "Regular")); + assert!(db.has_family("Synthetic Grotesk Medium")); + // The typographic name as is. + assert_eq!(resolved(&db, "Synthetic Grotesk", "Semibold"), pair("synthetic grotesk", "Semibold")); + // A width isn't a weight: a missing condensed family stays missing. + assert!(!db.has_family("Synthetic Grotesk Condensed")); + assert!(!db.has_family("Synthetic"), "a family isn't found by a part of its name"); +} + +#[test] +fn a_typographic_name_finds_a_family_installed_per_weight() { + // The fonts have only legacy names: a family per weight beyond the four basic styles. + let db = installed(&[ + ("Synthetic Serif", &["Regular", "Italic", "Bold", "Bold Italic"]), + ("Synthetic Serif Medium", &["Regular", "Italic"]), + ("Synthetic Serif SemiBold", &["Regular"]), + ]); + assert_eq!(resolved(&db, "Synthetic Serif", "Medium"), pair("synthetic serif medium", "Regular")); + assert_eq!(resolved(&db, "Synthetic Serif", "Medium Italic"), pair("synthetic serif medium", "Italic")); + assert_eq!(resolved(&db, "Synthetic Serif", "Semi Bold"), pair("synthetic serif semibold", "Regular")); + assert_eq!(resolved(&db, "Synthetic Serif", "Bold"), pair("synthetic serif", "Bold"), "a style the family has stays in it"); + // A weight number keeps the family and goes by weight. + assert_eq!(resolved(&db, "Synthetic Serif", "600"), pair("synthetic serif", "600")); + assert_eq!(figcraft_doc::style_weight("600"), 600); + assert_eq!(figcraft_doc::style_weight("600 Italic"), 600); + // Only some weights installed: the family is there all the same. + let db = installed(&[("Synthetic Serif Medium", &["Regular"])]); + assert!(db.has_family("Synthetic Serif")); + assert_eq!(resolved(&db, "Synthetic Serif", "Medium"), pair("synthetic serif medium", "Regular")); +} + +#[test] +fn case_and_spacing_dont_matter() { + let db = installed(&[("Synthetic Mono", &["Regular"])]); + for name in ["synthetic mono", "SYNTHETIC MONO", " Synthetic Mono ", "SyntheticMono", "Synthetic-Mono", "Synthetic_Mono"] { + assert!(db.has_family(name), "{name}"); + assert_eq!(db.key(name), "synthetic mono", "{name}"); + } + assert!(!db.has_family("Synthetic Monospace")); +} + +#[test] +fn style_words_are_weights_and_slopes() { + for s in ["Medium", "Semi Bold", "SemiBold Italic", "600", "Extra Light", "Black Oblique"] { + assert!(style_words(s), "{s}"); + } + for s in ["", "Sans", "Condensed", "Display", "Text", "1200"] { + assert!(!style_words(s), "{s}"); + } +} + +#[test] +fn faces_are_found_and_listed_under_either_name() { + // An embedded face stands in for an installed one, filed under synthetic legacy names. + let Some(f) = CRAFT_FONTS.first() else { return }; + let face = |family: &str, style: &str| Face::from_bytes(FontBytes::Static(f.bytes), 0, Some(family), Some(style)).unwrap(); + let mut db = FontDb::default(); + db.add(face("Synthetic Display", "Regular")); + let medium = db.add(face("Synthetic Display Medium", "Regular")); + let found = db.find("Synthetic Display", "Medium").unwrap(); + assert!(Arc::ptr_eq(&found, &medium), "the legacy family's face"); + assert!(db.styles("Synthetic Display").contains(&"Medium".to_string()), "{:?}", db.styles("Synthetic Display")); + // The other way round: a design names the legacy family, the typographic one is installed. + let mut db = FontDb::default(); + db.add(face("Synthetic Text", "Regular")); + let semibold = db.add(face("Synthetic Text", "SemiBold")); + assert!(Arc::ptr_eq(&db.find("Synthetic Text SemiBold", "Regular").unwrap(), &semibold)); + assert!(Arc::ptr_eq(&db.find("synthetictext", "Semibold").unwrap(), &semibold)); +} diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 549b7ff82..e18044f52 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -99,6 +99,8 @@ name = "Svenska" "All supported properties" = "Alla egenskaper som stöds" "All the fonts this file uses are installed" = "Alla typsnitt som filen använder är installerade" "All variables" = "Alla variabler" +"Allow and download" = "Tillåt och hämta" +"Allow downloading fonts?" = "Tillåta hämtning av typsnitt?" "Along the bottom" = "Längs nederkanten" "Along the left" = "Längs vänsterkanten" "Along the right" = "Längs högerkanten" @@ -182,6 +184,7 @@ name = "Svenska" "Blend mode" = "Blandningsläge" "Blue" = "Blå" "Blur" = "Oskärpa" +"Board grid" = "Tavlans rutnät" "Boolean" = "Boolesk" "Boolean groups" = "Booleska grupper" "Both directions" = "Båda riktningarna" @@ -218,6 +221,7 @@ name = "Svenska" "Can comment" = "Kan kommentera" "Check" = "Bock" "Check for Updates" = "Sök efter uppdateringar" +"Check for updates" = "Sök efter uppdateringar" "Check for updates…" = "Sök efter uppdateringar…" "Checking for updates…" = "Söker efter uppdateringar…" "Checking the fonts installed on this computer…" = "Kontrollerar vilka typsnitt som är installerade på den här datorn…" @@ -332,6 +336,7 @@ name = "Svenska" "Copied as PNG" = "Kopierat som PNG" "Copied as SVG" = "Kopierat som SVG" "Copied as text" = "Kopierat som text" +"Copied comments for AI" = "Kommentarerna har kopierats för AI" "Copied {value}" = "Kopierade {value}" "Copy" = "Kopiera" "Copy as" = "Kopiera som" @@ -341,6 +346,7 @@ name = "Svenska" "Copy code" = "Kopiera kod" "Copy effect" = "Kopiera effekt" "Copy fill" = "Kopiera fyllning" +"Copy for AI" = "Kopiera för AI" "Copy link" = "Kopiera länk" "Copy stroke" = "Kopiera kontur" "Copy the {label}" = "Kopiera {label}" @@ -369,6 +375,7 @@ name = "Svenska" "Create style from the selected text" = "Skapa stil från den markerade texten" "Created" = "Skapad" "Crop" = "Beskär" +"Crop image" = "Beskär bild" "Crop marks" = "Skärmärken" "Ctrl+arrows" = "Ctrl+piltangenter" "Ctrl+click" = "Ctrl+klick" @@ -448,6 +455,8 @@ name = "Svenska" "Dots" = "Punkter" "Dotted" = "Prickad" "Down" = "Ned" +"Download" = "Hämta" +"Downloading… {percent}%" = "Hämtar… {percent} %" "Drafts" = "Utkast" "Drag the stroke to add a width point" = "Dra i konturen för att lägga till en breddpunkt" "Draw" = "Rita" @@ -534,6 +543,7 @@ name = "Svenska" "Fetching from the server…" = "Hämtar från servern…" "FigCraft couldn't open its window: {error}" = "FigCraft kunde inte öppna sitt fönster: {error}" "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft stöder ännu inte alla funktioner i de här filerna. Anteckningarna visar vad som utelämnades eller ändrades i den importerade kopian; originalfilen är oförändrad." +"FigCraft stopped because of an internal error." = "FigCraft stoppades på grund av ett internt fel." "FigCraft {version} is currently the newest version available." = "FigCraft {version} är den senaste tillgängliga versionen." "File" = "Fil" "File name" = "Filnamn" @@ -592,6 +602,7 @@ name = "Svenska" "Frame selection" = "Rama in markering" "Frame tools" = "Frame-verktyg" "Frames" = "Frames" +"Free fonts come from Google Fonts and Noto, through jsDelivr and GitHub. Each file is checked against the font index before it is installed into the font folder the Craft apps share. Nothing is downloaded until you allow it." = "Fria typsnitt kommer från Google Fonts och Noto, via jsDelivr och GitHub. Varje fil kontrolleras mot typsnittsregistret innan den installeras i den typsnittsmapp som Craft-apparna delar. Ingenting hämtas förrän du tillåter det." "From the library \"{name}\": read-only" = "Från biblioteket \"{name}\": skrivskyddad" "From {name}" = "Från {name}" "From {server}" = "Från {server}" @@ -607,6 +618,7 @@ name = "Svenska" "Go to {name}" = "Gå till {name}" "Grab bag" = "Blandat" "Gradient" = "Gradient" +"Graph paper" = "Rutat papper" "Gray" = "Grå" "Green" = "Grön" "Greyscale components and two example screens to sketch a flow with" = "Komponenter i gråskala och två exempelskärmar att skissa ett flöde med" @@ -721,6 +733,7 @@ name = "Svenska" "Join with a code" = "Anslut med en kod" "Joined" = "Ansluten" "Joined {file}" = "Ansluten till {file}" +"Joining live session…" = "Ansluter till livesession…" "Joining…" = "Ansluter…" "Join|stroke" = "Hörntyp" "Jul" = "jul" @@ -890,6 +903,7 @@ name = "Svenska" "No color styles" = "Inga färgformatmallar" "No colour variables fit here." = "Inga färgvariabler passar här." "No colour variables yet. Create them in Local variables." = "Inga färgvariabler än. Skapa dem i Lokala variabler." +"No comments to copy" = "Inga kommentarer att kopiera" "No comments yet" = "Inga kommentarer än" "No compatibility warnings reported" = "Inga kompatibilitetsvarningar rapporterades" "No components yet. Select layers and press {keys}." = "Inga komponenter än. Markera lager och tryck på {keys}." @@ -938,11 +952,10 @@ name = "Svenska" "Not bound" = "Inte kopplad" "Not in a live session" = "Inte i en livesession" "Not in this font" = "Finns inte i det här typsnittet" +"Not now" = "Inte nu" "Not properties, kept as written: {names}" = "Inte egenskaper, behölls som de skrevs: {names}" -"Not read yet" = "Inte läst än" -"Check for updates" = "Sök efter uppdateringar" "Not checked yet" = "Inte kontrollerat än" -"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Den här filen använder teambibliotek på servrar som du inte har använt här. De kontaktas inte förrän du kontrollerar dem." +"Not read yet" = "Inte läst än" "Not shared" = "Inte delad" "Note {n}" = "Anteckning {n}" "Nothing changed since this version." = "Ingenting har ändrats sedan den här versionen." @@ -1140,6 +1153,9 @@ name = "Svenska" "Regular expression" = "Reguljärt uttryck" "Relative to" = "Relativt till" "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relativt systemets skalning av skärmen (100 % är systemets egen storlek). Dokumentets zoom är separat." +"Reload the page to get back the unsaved files the browser had room for." = "Läs in sidan igen för att få tillbaka de osparade filer som webbläsaren hade plats för." +"Reload the page to get your unsaved files back." = "Läs in sidan igen för att få tillbaka dina osparade filer." +"Reload the page to start again." = "Läs in sidan igen för att börja om." "Remove" = "Ta bort" "Remove all" = "Ta bort alla" "Remove annotation" = "Ta bort anteckning" @@ -1194,6 +1210,7 @@ name = "Svenska" "Reset adjustments" = "Återställ justeringar" "Reset all changes" = "Återställ alla ändringar" "Reset all overrides on this layer" = "Återställ alla overrides på det här lagret" +"Reset grid" = "Återställ rutnät" "Reset overrides" = "Återställ overrides" "Reset slot" = "Återställ plats" "Reset this override" = "Återställ den här overriden" @@ -1422,6 +1439,7 @@ name = "Svenska" "Stroke style" = "Konturstil" "Stroke weight" = "Konturtjocklek" "Strokes" = "Konturer" +"Strong line every" = "Kraftig linje var" "Style" = "Stil" "Styles" = "Stilar" "Stylistic set 1" = "Stiluppsättning 1" @@ -1486,6 +1504,7 @@ name = "Svenska" "This branch was merged or archived." = "Den här grenen har sammanfogats eller arkiverats." "This computer only" = "Endast den här datorn" "This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Den här datorn kommer ihåg ägartoken, så filen kan hanteras från startskärmens onlinelista. Kopiera den om du vill hantera filen från en annan plats: den fungerar som ett lösenord för filen och visas inte igen." +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Den här filen använder teambibliotek på servrar som du inte har använt här. De kontaktas inte förrän du kontrollerar dem." "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Den här sidan behåller ägartoken bara så länge den är öppen. Kopiera den om du vill hantera filen senare: den fungerar som ett lösenord för filen och visas inte igen." "This condition is edited as an expression" = "Det här villkoret redigeras som ett uttryck" "This file" = "Den här filen" @@ -1496,6 +1515,7 @@ name = "Svenska" "This frame is new on the branch." = "Den här framen är ny på grenen." "This frame isn't on the branch." = "Den här framen finns inte på grenen." "This link opens a website outside the prototype:" = "Den här länken öppnar en webbplats utanför prototypen:" +"This link opens your mail app to write a message:" = "Den här länken öppnar din e-postapp för att skriva ett meddelande:" "This server is open to the public: no key needed." = "Servern är öppen för alla: ingen nyckel behövs." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Servern kräver sin nyckel: lägg till ?key=… i adressen (`figcraft-server key` skriver ut den på serverns dator)." "This server takes files only with its key: see Advanced." = "Servern tar bara emot filer med sin nyckel: se Avancerat." @@ -1651,6 +1671,7 @@ name = "Svenska" "Your copy stays open; the file stays on the server" = "Din kopia förblir öppen; filen ligger kvar på servern" "Your name" = "Ditt namn" "Your system asks for reduced motion: animations stay off." = "Systemet begär minskad rörelse: animeringar förblir avstängda." +"Your unsaved files were too large for the browser to keep." = "Dina osparade filer var för stora för att webbläsaren skulle kunna behålla dem." "Z" = "Z" "Z to A" = "Ö till A" "Zoom" = "Zoom" @@ -1718,12 +1739,15 @@ name = "Svenska" "invalid internal .fig text path data was ignored" = "ogiltiga interna .fig-data för text på bana ignorerades" "invalid or oversized vector networks import as outlines" = "ogiltiga eller för stora vektornätverk importeras som former" "invalid or oversized vector path data was skipped" = "ogiltiga eller för stora vektorbanedata hoppades över" +"invalid vector region fill paint list; layer fill used" = "ogiltig lista över fyllningsfärger för vektorregion; lagrets fyllning används" +"invalid vector region paints were skipped" = "ogiltiga färger i vektorregioner hoppades över" "just now" = "nyss" "key triggers without a supported key were skipped" = "tangentutlösare utan en tangent som stöds hoppades över" "key up triggers import as key presses" = "utlösare för släppt tangent importeras som tangenttryckningar" "layers without a position were placed at their parent's origin" = "lager utan position placerades i det överordnade objektets origo" "layers without a type were skipped" = "lager utan typ hoppades över" "legacy image filter container is unsupported; filter conversion was skipped" = "den äldre bildfilterbehållaren stöds inte; filterkonverteringen hoppades över" +"linked vector region fill styles keep resolved appearance; live style links were left out" = "länkade fyllningsstilar i vektorregioner behåller sitt beräknade utseende; levande stillänkar utelämnades" "locked" = "låst" "malformed image filters container was skipped" = "en felaktig bildfilterbehållare hoppades över" "malformed image paintFilter container was skipped" = "en felaktig paintFilter-behållare för bild hoppades över" @@ -1731,6 +1755,10 @@ name = "Svenska" "no bleed" = "inget utfall" "nobody else here yet" = "ingen annan här än" "non-default slot settings are not represented and were skipped" = "fackinställningar som avviker från standard återges inte och hoppades över" +"note or shape generated child has invalid bounds; child skipped" = "ett underobjekt som genererats av en anteckning eller form har ogiltiga gränser; underobjektet hoppades över" +"note or shape generated child is missing its style or bounds; child skipped" = "ett underobjekt som genererats av en anteckning eller form saknar stil eller gränser; underobjektet hoppades över" +"note or shape generated children exceed the layer or nesting limit; remaining children skipped" = "underobjekt som genererats av en anteckning eller form överskrider gränsen för lager eller nästling; återstående underobjekt hoppades över" +"note or shape generated layout is missing or unsupported; using its outer frame" = "layouten som genererats av en anteckning eller form saknas eller stöds inte; dess yttre frame används" "now" = "nu" "offer files to save if it asks to (you choose where each one goes)" = "erbjuda filer att spara om den ber om det (du väljer var varje fil hamnar)" "only in a text style or variable" = "endast i en textstil eller variabel" @@ -1754,6 +1782,7 @@ name = "Svenska" "set-variable actions with values that don't fit the variable were skipped" = "åtgärder för att ange variabel med värden som inte passar variabeln hoppades över" "set-variable actions without a value were skipped" = "åtgärder för att ange variabel utan värde hoppades över" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "åtgärder för att ange variabelläge på samlingar eller lägen som inte finns i variabel-JSON:en hoppades över" +"shape generated outline is missing or damaged; using its rectangle" = "konturen som genererats av en form saknas eller är skadad; dess rektangel används" "slot content assignment target is missing or is not slot content" = "målet för tilldelningen av fackinnehåll saknas eller är inte fackinnehåll" "slot content assignment uses the empty GUID sentinel and was skipped" = "tilldelningen av fackinnehåll använder det tomma GUID:t som markörvärde och hoppades över" "slot layers without a matching slot property were imported as frames" = "facklager utan en motsvarande fackegenskap importerades som frames" @@ -1779,10 +1808,13 @@ name = "Svenska" "variable modes of collections that aren't in the file were skipped" = "variabellägen för samlingar som inte finns i filen hoppades över" "variable modes of collections that aren't in the variables JSON were skipped" = "variabellägen för samlingar som inte finns i variabel-JSON:en hoppades över" "variable values that don't match their type (or composed colours) were skipped" = "variabelvärden som inte matchar sin typ (eller sammansatta färger) hoppades över" +"variable-bound vector region paints keep resolved appearance; live bindings were left out" = "variabelbundna färger i vektorregioner behåller sitt beräknade utseende; levande bindningar utelämnades" "variables of unknown types were skipped" = "variabler av okända typer hoppades över" "variables whose collection isn't in the variables JSON were skipped" = "variabler vars samling inte finns i variabel-JSON:en hoppades över" "variables without readable values were skipped" = "variabler utan läsbara värden hoppades över" "vector points with unknown stroke joins use the layer join" = "vektorpunkter med okända hörntyper använder lagrets hörntyp" +"vector region paint data exceeded import limits and was skipped" = "färgdata för vektorregion överskred importgränserna och hoppades över" +"vector region references a missing fill style; layer fill used" = "vektorregionen refererar till en fyllningsstil som saknas; lagrets fyllning används" "vectors without geometry are empty" = "vektorer utan geometri är tomma" "vectors without geometry are empty (export the file JSON with geometry=paths)" = "vektorer utan geometri är tomma (exportera fil-JSON:en med geometry=paths)" "very long text was cut to 1,048,576 characters" = "mycket lång text kortades till 1 048 576 tecken" @@ -1969,6 +2001,7 @@ name = "Svenska" "comment.next" = "Nästa kommentar" "comment.open" = "Öppna kommentar" "comment.people" = "Personer i kommentarer" +"comment.prompt" = "Kopiera kommentarer för AI" "comment.react" = "Reagera på kommentar" "comment.reply" = "Svara på kommentar" "comment.resolve" = "Lös kommentar" @@ -2050,6 +2083,7 @@ name = "Svenska" "file.exportFramesPdf" = "Exportera frames som PDF" "file.exportLayers" = "Exportera lager" "file.exportSvg" = "Exportera som SVG" +"file.fontReport" = "Inspektera dokumentets typsnitt" "file.import" = "Importera" "file.markSaved" = "Märk som sparad" "file.moveTab" = "Flytta flik" @@ -2195,10 +2229,10 @@ name = "Svenska" "pref.language" = "Gränssnittsspråk" "pref.nudge" = "Steglängd" "pref.numberKeysOpacity" = "Använd siffertangenterna för opacitet" -"pref.scrollWheelZoom" = "Zooma med scrollhjulet" "pref.publishServer" = "Server för delning online" "pref.reduceMotion" = "Minska rörelse" "pref.renameDuplicates" = "Byt namn på duplicerade lager" +"pref.scrollWheelZoom" = "Zooma med scrollhjulet" "pref.showDimensions" = "Visa dimensioner på objekt" "pref.startOnHome" = "Börja på Start" "pref.uiScale" = "Gränssnittsstorlek" @@ -2219,6 +2253,7 @@ name = "Svenska" "proto.interaction.clear" = "Ta bort alla interaktioner" "proto.interaction.remove" = "Ta bort interaktion" "proto.interaction.set" = "Redigera interaktion" +"proto.linkNotice" = "Visa meddelande innan en länk öppnas" "proto.present" = "Presentera" "proto.presentOptions" = "Presentationsalternativ" "proto.preview" = "Öppna prototypförhandsgranskning" @@ -2365,6 +2400,7 @@ name = "Svenska" "tool.star" = "Stjärna" "tool.sticky" = "Lapp" "tool.text" = "Text" +"tool.textOnPath" = "Text längs en bana" "transform.move" = "Flytta" "transform.moveTo" = "Flytta till" "transform.nudge" = "Flytta stegvis" diff --git a/docs/architecture.md b/docs/architecture.md index 9faed5479..5c7300f82 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,7 +75,8 @@ content. Exports: PNG, JPG, WebP, SVG, PDF, code. There is no `.fig` reader yet Workspace lints deny `unwrap`, `expect`, `panic!` and friends in shipped code; `unsafe_code` is forbidden. `figcraft_engine::guard` turns a panicking command into an error and rolls the document -back; the desktop app also guards each UI frame. The browser build can't catch a panic (wasm32 aborts), so there a panic stops the page and its panic hook keeps the files with unsaved changes in local storage for the next page. Junk-parameter sweeps run every command with bad +back, and a panic in a `Session::grouped` closure outside any command rolls the group back like a +failure before the panic continues; the desktop app also guards each UI frame. The browser build can't catch a panic (wasm32 aborts), so there a panic stops the page and its panic hook keeps the files with unsaved changes in local storage for the next page. Junk-parameter sweeps run every command with bad input. ## Revision history diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 96c477f53..071890726 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-30 · **Change:** minor (`.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; 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; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; 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; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **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 @@ -183,13 +183,14 @@ source paint coordinates. | 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 | listed, not replaced: in the desktop app the Missing fonts window opens after a file that uses them opens and from Text ▸ Missing fonts (`text.missingFonts`; `figcraft-cli info` prints `missing font` lines), and their text draws in a fallback font. A family the font store has (Google Fonts, Noto) downloads from the window once the user allows downloading fonts (`font.download`, #754): pinned, checksum-verified files into the Craft apps' shared font folder, after which the text draws in it. No substitute picker | +| Missing-font replacement on open | yes | listed, not replaced: in the desktop app the Missing fonts window opens after a file that uses them opens and from Text ▸ Missing fonts (`text.missingFonts`; `figcraft-cli info` prints `missing font` lines), and their text draws in a fallback font. A family the font store has (Google Fonts, Noto) downloads from the window once the user allows downloading fonts (`font.download`, #754): pinned, checksum-verified files into the Craft apps' shared font folder, after which the text draws in it. A family is found however a file names it: by the legacy name of one of its weights ("Family Medium") or by the typographic name of a family installed one family per weight, and spelt with other case, spaces or hyphens (#1292). No substitute picker | ## Revision history | Date | Change | Summary | |---|---|---| | 2026-10-30 | minor | M3, #1280: `.fig` export writes a colour calculation (a variable's `{alpha}` or `{mix}` value) as the colour it shows in each mode of its collection, other collections in their default modes; one that resolves to nothing (an operand missing, or more than 32 variables to read) is left out with a warning. Tests: `crates/format/src/fig/assets_tests.rs`. Numbers unchanged. | +| 2026-10-30 | minor | Fonts: the missing-font row adds that a family is found under the other names its fonts give it (legacy per-weight names and typographic ones) and with other case and spacing, so a `.fig`, REST or pasted file no longer reports an installed family missing (#1292). Numbers unchanged | | 2026-10-30 | minor | M8, #574: `.fig` import budgets four decoded values per compressed canvas byte instead of one; the 20M floor and the 128M ceiling (32M on 32-bit targets) are unchanged. Dense legitimate files went past one value per compressed byte (further since every scalar field counts) and were refused whole as "not a valid FigCraft file: .fig file: message: too many values". Generated 21 MB and 15 MB canvases (220,044 and 108,012 layers) and a 49.7 MB one budgeted at the ceiling (530,106 layers) now open whole without warnings; a 29 KB canvas that unpacks to 30 million values is still refused. Files that now open instead of being refused take the memory they need: the 49.7 MB canvas's test peaks at about 9 GB. Tests: `crates/format/src/fig/value_budget_tests.rs` (the heavy ones with `--ignored`) and `large_files_get_a_larger_value_budget` in `crates/format/src/fig/tests.rs`. Numbers unchanged. | | 2026-10-30 | minor | M8, #1076: `.fig` ZIP images now share a budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images. The 256 MiB member cap stays; members that do not fit are skipped before allocation with an import warning. Failed reads are charged too, repeated references keep their cached resolution, and smaller later members can still fit. Embedded images and decoded pixel memory are outside this budget. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | | 2026-10-30 | minor | M8, #1114: a document opened or imported by path must be a regular file of at most 16 GiB, and an image placed by path one of at most 256 MiB: a folder, a device or a pipe is refused before anything is read. Real files open as before. Numbers unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index c447a6ed4..1aacb7325 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -76,6 +76,8 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). | +| 2026-10-30 | minor | M8, #1085 (the rest): a panic in a `Session::grouped` closure outside any command (plugin, widget, data fill and CLI groups) skipped the group rollback, leaving documents half edited, the history limit unbounded, live documents held and the group depth raised. `checkpointed` now catches the unwind, runs the same rollback as a failed group and resumes the panic, so the outer guard still reports it. Journal rollover and the browser (wasm aborts) are unchanged. 3 regression tests (`group_panic_tests`). | | 2026-10-30 | minor | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged | | 2026-10-30 | minor | M8, #1076: `.fig` ZIP image reads share a total budget, 4 GiB on 64-bit targets and 1 GiB on 32-bit targets, keeping the 256 MiB per-image cap. Members that do not fit are skipped before allocation with an import warning; failed reads consume their bytes, repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | | 2026-10-30 | minor | M3, follow-up to #1542: stored variable and collection references use exact ids in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker, and prototype actions and expressions. Interaction edits resolve only incoming names and leave stored references alone. Command arguments still accept names as before. Tests: the `stored_variable_ids_*` tests in the doc, engine, format and ui-egui present and responsive suites. Numbers unchanged |