From b957160379280a9dbbc1aca20ffa8cad263fa451 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:37:01 +0100 Subject: [PATCH 01/16] Fix code export printing inf for very large finite numbers (#1757) * Fix code export printing inf for very large finite numbers Found while testing crates/engine/src/dev/codegen.rs:185 (num) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 * Keep rem values finite past num6's scaling overflow num6 had the same x1e6 overflow as num: a huge finite length printed 0rem (CodeRabbit on #1757). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/dev/codegen.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/dev/codegen.rs b/crates/engine/src/dev/codegen.rs index 6d2ab92dd..55786d52d 100644 --- a/crates/engine/src/dev/codegen.rs +++ b/crates/engine/src/dev/codegen.rs @@ -182,7 +182,9 @@ pub(crate) fn num(v: f64) -> String { if !v.is_finite() { return "0".into(); } - let r = (v * 100.0).round() / 100.0; + let scaled = v * 100.0; + // Past the x100 overflow every value is whole anyway, so keep it as is. + let r = if scaled.is_finite() { scaled.round() / 100.0 } else { v }; if (r - r.round()).abs() < 1e-9 { // `as i64` saturates past 2^63, so larger whole values are printed from the float. if r.abs() < 9e18 { format!("{}", r.round() as i64) } else { format!("{:.0}", r.round()) } @@ -193,7 +195,9 @@ pub(crate) fn num(v: f64) -> String { /// `v` without trailing zeros (6 decimals), for lengths that must convert back to the same pixels. fn num6(v: f64) -> String { - let r = (v * 1_000_000.0).round() / 1_000_000.0; + let scaled = v * 1_000_000.0; + // Past the x1e6 overflow every value is whole anyway, so keep it as is. + let r = if scaled.is_finite() { scaled.round() / 1_000_000.0 } else { v }; if r.is_finite() && r != 0.0 { format!("{r}") } else { "0".into() } } @@ -458,6 +462,21 @@ mod tests { use crate::params::id_of; use serde_json::json; + #[test] + fn num_keeps_finite_values_past_the_x100_overflow() { + for v in [1e307, -1e307, f64::MAX] { + let s = num(v); + assert_eq!(s, format!("{v:.0}")); + assert_eq!(s.parse::(), Ok(v)); + } + } + + #[test] + fn num6_keeps_finite_values_past_the_x1e6_overflow() { + assert_eq!(num6(1e305).parse::(), Ok(1e305)); + assert_eq!(num6(0.1234567), "0.123457"); + } + #[test] fn num_keeps_huge_whole_numbers() { assert_eq!(num(1e20), "100000000000000000000"); From 3b5de555876ccb8d2f9d9a4ce9468dbf4632a980 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:37:05 +0100 Subject: [PATCH 02/16] Keep huge finite values finite when stepping number fields (#1753) Found while testing crates/ui-egui/src/widgets/field_math.rs:168 (stepped, used by widgets.rs:131 scrubbed) Co-authored-by: Claude Haiku 5.5 --- crates/ui-egui/src/widgets/field_math.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/widgets/field_math.rs b/crates/ui-egui/src/widgets/field_math.rs index f81500e67..9de90e278 100644 --- a/crates/ui-egui/src/widgets/field_math.rs +++ b/crates/ui-egui/src/widgets/field_math.rs @@ -167,7 +167,12 @@ pub fn eval(text: &str, cur: Option) -> Option { /// (0.1 + 0.2). pub fn stepped(from: f64, steps: f64, step: f64) -> f64 { let v = from + steps * step; - if v.is_finite() { (v * 1e6).round() / 1e6 } else { from } + if !v.is_finite() { + return from; + } + let rounded = (v * 1e6).round() / 1e6; + // Past ~1.8e302 the scaling overflows; such a value has no dust to remove. + if rounded.is_finite() { rounded } else { v } } #[cfg(test)] @@ -182,4 +187,10 @@ mod tests { assert_eq!(parse("50 %", true), Some(Typed::Value(50.0))); assert_eq!(parse("3 px + 2 px", true), Some(Typed::Value(5.0))); } + + #[test] + fn stepped_keeps_a_huge_finite_value_finite() { + assert_eq!(stepped(1e305, 1.0, 1.0), 1e305); + assert!(stepped(f64::MAX / 2.0, 0.0, 1.0).is_finite()); + } } From fad47ac8bcd876333d7e75377190a80c7740055d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:37:09 +0100 Subject: [PATCH 03/16] Relative line height and letter spacing edits with a unit suffix add to the current value (#1755) Found while testing crates/ui-egui/src/panels/typography/spacing.rs:166 (parse_line_height, px branch; also :183 and :180 in parse_letter_spacing, px and % branches) Co-authored-by: Claude Haiku 5.5 --- .../ui-egui/src/panels/typography/spacing.rs | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/typography/spacing.rs b/crates/ui-egui/src/panels/typography/spacing.rs index 54c327dd0..5ffaf22a2 100644 --- a/crates/ui-egui/src/panels/typography/spacing.rs +++ b/crates/ui-egui/src/panels/typography/spacing.rs @@ -164,7 +164,11 @@ pub(crate) fn parse_line_height(s: &str, cur: Option) -> Option Some(p), + _ => None, + }; + return widgets::eval(px.trim(), cur_px).filter(|v| v.is_finite()).map(|v| json!(v.max(0.0))); } match cur { Some(LineHeight::Percent(p)) => widgets::eval(s, Some(p)).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v.max(0.0))))), @@ -178,10 +182,18 @@ pub(crate) fn parse_line_height(s: &str, cur: Option) -> Option) -> Option { let s = s.trim(); if let Some(p) = s.strip_suffix('%') { - return widgets::eval(p.trim(), None).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v)))); + let cur_pct = match cur { + Some(LetterSpacing::Percent(p)) => Some(p), + _ => None, + }; + return widgets::eval(p.trim(), cur_pct).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v)))); } if let Some(p) = s.strip_suffix("px") { - return widgets::eval(p.trim(), None).filter(|v| v.is_finite()).map(|v| json!(v)); + let cur_px = match cur { + Some(LetterSpacing::Pixels(p)) => Some(p), + _ => None, + }; + return widgets::eval(p.trim(), cur_px).filter(|v| v.is_finite()).map(|v| json!(v)); } match cur { Some(LetterSpacing::Pixels(px)) => widgets::eval(s, Some(px)).filter(|v| v.is_finite()).map(|v| json!(v)), @@ -206,3 +218,21 @@ pub(crate) fn letter_spacing_of(v: &Value) -> Option { other => other.as_f64().filter(|v| v.is_finite()).map(LetterSpacing::Pixels), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_edit_with_unit_suffix_adds_to_current() { + let lh = Some(LineHeight::Pixels(10.0)); + assert_eq!(parse_line_height("+2", lh), Some(json!(12.0))); + assert_eq!(parse_line_height("+2px", lh), Some(json!(12.0))); + let ls = Some(LetterSpacing::Pixels(10.0)); + assert_eq!(parse_letter_spacing("+1", ls), Some(json!(11.0))); + assert_eq!(parse_letter_spacing("+1px", ls), Some(json!(11.0))); + let pct = Some(LetterSpacing::Percent(10.0)); + assert_eq!(parse_letter_spacing("+1%", pct), parse_letter_spacing("+1", pct)); + assert_eq!(parse_letter_spacing("+1%", pct), Some(json!("11%"))); + } +} From 45e5a25370fc4fe0df00ac2f15589092ab24229b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:37:13 +0100 Subject: [PATCH 04/16] Escape backslashes and line breaks in CSS font-family export (#1752) * Escape backslashes and line breaks in CSS font-family export Found while testing crates/engine/src/dev/codegen/css.rs:223 (text_decls, the font-family put) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 * Escape quotes and < in CSS font-family export A double quote in a family name is escaped instead of turned into a single quote (CodeRabbit on #1752), and < is written as \3C so a name can't end the generated page's "})); + let css = crate::codegen::css_decls(s.doc().unwrap(), s.doc().unwrap().node(t).unwrap(), &mut Default::default(), (true, true)); + assert!(css.contains(r#"font-family: "Foo\"\3C /style>";"#), "{css}"); +} From 9a7e5d1a1634a927895cd2c1d116523e322dfe82 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:37:18 +0100 Subject: [PATCH 05/16] Apply paint opacity to CSS gradient stops in code export (#1750) Found while testing crates/engine/src/dev/codegen/css.rs:121 (css_decls, the PaintKind::Gradient arm calling css_gradient at :255) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/dev/codegen.rs | 12 ++++++++++++ crates/engine/src/dev/codegen/css.rs | 11 ++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/dev/codegen.rs b/crates/engine/src/dev/codegen.rs index 55786d52d..925acdd0a 100644 --- a/crates/engine/src/dev/codegen.rs +++ b/crates/engine/src/dev/codegen.rs @@ -494,6 +494,18 @@ mod tests { f } + #[test] + fn css_gradient_folds_in_paint_opacity() { + let mut s = Session::new(); + let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap(); + let mut p = figcraft_color::Paint::gradient(figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, Color::BLACK)); + p.opacity = 0.5; + s.doc_mut().unwrap().get_mut(f).unwrap().fills = vec![p]; + let css = code_for(s.doc().unwrap(), f, CodeLang::Css); + assert!(css.contains("rgba(0, 0, 0, 0.50) 0%"), "{css}"); + assert!(!css.contains("#000000 0%"), "{css}"); + } + #[test] fn swiftui_and_compose_for_an_auto_layout_card() { let mut s = Session::new(); diff --git a/crates/engine/src/dev/codegen/css.rs b/crates/engine/src/dev/codegen/css.rs index 0e07f0bf1..087eb7905 100644 --- a/crates/engine/src/dev/codegen/css.rs +++ b/crates/engine/src/dev/codegen/css.rs @@ -120,7 +120,7 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo // A variable binds the first paint's colour. put(key, if i == 0 { tok(doc, n, &["fill"], c, t) } else { c }); } - PaintKind::Gradient(g) => put("background", css_gradient(g, n.width, n.height)), + PaintKind::Gradient(g) => put("background", css_gradient(g, n.width, n.height, p.opacity)), PaintKind::Image(_) => put("background", "url(image.png) center / cover".into()), PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()), } @@ -307,8 +307,13 @@ fn decoration_decls(s: &figcraft_doc::TextStyle, put: &mut impl FnMut(&str, Stri /// A gradient fill on a `w`×`h` box as a CSS background: linear along its handles' direction /// (CSS angles: 0deg up, 90deg right), radial with its handles' radii, angular as a conic /// gradient from its start direction. -fn css_gradient(g: &figcraft_color::Gradient, w: f64, h: f64) -> String { - let stops = g.sorted_stops().iter().map(|s| format!("{} {}%", s.color.to_css(), (s.position * 100.0).round())).collect::>().join(", "); +fn css_gradient(g: &figcraft_color::Gradient, w: f64, h: f64, opacity: f32) -> String { + let stops = g + .sorted_stops() + .iter() + .map(|s| format!("{} {}%", s.color.with_alpha(s.color.a * opacity).to_css(), (s.position * 100.0).round())) + .collect::>() + .join(", "); let (dx, dy) = ((g.to.0 - g.from.0) * w, (g.to.1 - g.from.1) * h); let angle = if dx == 0.0 && dy == 0.0 { 180.0 } else { dx.atan2(-dy).to_degrees().rem_euclid(360.0) }; let pct = |v: f64| format!("{}%", num(v * 100.0)); From 556d44d47a94c6be320ce42f99064bfed970ea34 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:37:22 +0100 Subject: [PATCH 06/16] Reject '+' sign in comment parse_link thread ids (#1740) Found while testing crates/engine/src/cmd/comment.rs:55 (parse_link) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/cmd/comment.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs index cf675d0fc..c2eb69b12 100644 --- a/crates/engine/src/cmd/comment.rs +++ b/crates/engine/src/cmd/comment.rs @@ -52,7 +52,12 @@ pub fn link(id: u64) -> String { /// The thread id in a link (or a bare id). pub fn parse_link(s: &str) -> Option { let s = s.trim(); - s.strip_prefix(LINK_PREFIX).unwrap_or(s).trim_end_matches('/').parse().ok() + let s = s.strip_prefix(LINK_PREFIX).unwrap_or(s).trim_end_matches('/'); + // Digits only: `u64::parse` would also accept a leading '+'. + if !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse().ok() } /// Which threads a list shows. @@ -997,6 +1002,15 @@ mod tests { use crate::Session; + #[test] + fn parse_link_rejects_sign() { + assert_eq!(super::parse_link("+5"), None); + assert_eq!(super::parse_link("figcraft://comment/+5"), None); + assert_eq!(super::parse_link(""), None); + assert_eq!(super::parse_link("5"), Some(5)); + assert_eq!(super::parse_link("figcraft://comment/5/"), Some(5)); + } + #[test] fn comment_thread_lifecycle_and_anchoring() { let mut s = Session::new(); From 96dcb3eda9bd063b66da13cb20cb7086090b8f38 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:37:26 +0100 Subject: [PATCH 07/16] Give each variable a unique custom property in CSS export (#1738) Found while testing crates/engine/src/cmd/variables_io.rs:~80-105 (css, non-tailwind `name` closure using css_var_of) with crates/engine/src/dev/codegen/css.rs:41 (css_var_name) Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/variables_io.rs | 6 ++- crates/engine/src/dev/codegen.rs | 2 +- crates/engine/src/dev/codegen/css.rs | 52 ++++++++++++++++++++++++- crates/engine/src/variables_io_tests.rs | 23 +++++++++++ 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index dfdee7b80..1b3b4ee29 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -8,7 +8,7 @@ use figcraft_doc::{Collection, CollectionId, Document, Mode, ModeId, Variable, V use serde_json::{Value, json}; use super::CommandSpec; -use crate::codegen::{css_var_name, css_var_of}; +use crate::codegen::{CssNames, css_var_name}; use crate::library::{is_remote_collection, local_collections}; use crate::params::{get, string}; use crate::{EngineError, Result, Session, bad, spec}; @@ -73,7 +73,9 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { let flattener = vars.flattener(); // Tailwind theme names, unique per file and the same ones page code reads. let theme = crate::tailwind::Theme::new(doc); - let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) }; + // CSS names, unique per file and the same ones generated code reads. + let names = CssNames::new(doc); + let name = |v: &Variable| if tailwind { theme.name(v) } else { names.name(v) }; // Lengths need a unit where CSS reads them (`border-radius: var(--radius)`); opacity and // font weight don't. let roles = crate::token_roles::Roles::new(doc); diff --git a/crates/engine/src/dev/codegen.rs b/crates/engine/src/dev/codegen.rs index 925acdd0a..b9df04f12 100644 --- a/crates/engine/src/dev/codegen.rs +++ b/crates/engine/src/dev/codegen.rs @@ -30,8 +30,8 @@ pub(crate) use compose::{compose, kotlin_color}; pub(crate) use css::Tokens; #[cfg(test)] pub(crate) use css::css_decls; +pub use css::{CssNames, css_var_name, css_var_of}; use css::{css, tok}; -pub use css::{css_var_name, css_var_of}; pub(crate) use flutter::{FLUTTER_IMPORT, Place, flutter, flutter_indent}; use html::html; pub(crate) use html::{Markup, class_name}; diff --git a/crates/engine/src/dev/codegen/css.rs b/crates/engine/src/dev/codegen/css.rs index 087eb7905..eb9406f2a 100644 --- a/crates/engine/src/dev/codegen/css.rs +++ b/crates/engine/src/dev/codegen/css.rs @@ -1,3 +1,7 @@ +use std::collections::{HashMap, HashSet}; + +use figcraft_doc::VariableId; + use super::{ ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, PADDING, PaintKind, TextAlignH, TextCase, TextDecoration, Variable, css_borders, num, @@ -13,13 +17,18 @@ pub(crate) struct Tokens { pub tailwind: bool, /// The file's theme names (computed on first use). theme: Option, + /// The file's CSS names (computed on first use). + names: Option, } impl Tokens { /// `var(--name, value)`, remembering the variable. fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String { - let name = - if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) }; + let name = if self.tailwind { + self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) + } else { + self.names.get_or_insert_with(|| CssNames::new(doc)).name(variable) + }; let out = format!("var({name}, {value})"); if !self.vars.iter().any(|(n, _)| *n == name) { self.vars.push((name, value)); @@ -60,6 +69,45 @@ pub fn css_var_of(v: &Variable) -> String { if valid { inner.chars().take(80).collect() } else { css_var_name(inner) } } +/// The file's variables' CSS custom properties, unique in the file: [`css_var_of`], and where an +/// earlier variable already has that name, the name with a numeric suffix (`--brand-primary-2`). +pub struct CssNames { + names: HashMap, +} + +impl CssNames { + pub fn new(doc: &Document) -> Self { + let vars = &doc.variables.variables; + let mut names = HashMap::with_capacity(vars.len()); + // Every plain name is reserved first, so a suffixed name never takes another's plain one; + // the next suffix per base keeps this linear. + let plain: Vec = vars.iter().map(css_var_of).collect(); + let mut taken: HashSet = HashSet::with_capacity(vars.len()); + let all: HashSet<&str> = plain.iter().map(String::as_str).collect(); + let mut next: HashMap<&str, usize> = HashMap::new(); + for (v, base) in vars.iter().zip(&plain) { + let mut name = base.clone(); + if !taken.insert(name.clone()) { + let k = next.entry(base.as_str()).or_insert(1); + loop { + *k += 1; + name = format!("{base}-{k}"); + if !all.contains(name.as_str()) && taken.insert(name.clone()) { + break; + } + } + } + names.insert(v.id.clone(), name); + } + CssNames { names } + } + + /// The custom property for `v` (its [`css_var_of`] name for a variable not in the file). + pub fn name(&self, v: &Variable) -> String { + self.names.get(&v.id).cloned().unwrap_or_else(|| css_var_of(v)) + } +} + /// `value`, or a reference to the variable bound to the first of `fields` that has one. pub(super) fn tok(doc: &Document, n: &Node, fields: &[&str], value: String, t: &mut Tokens) -> String { match fields.iter().find_map(|f| crate::devinspect::bound_variable(doc, n, f)) { diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index 57473a057..eba692802 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -639,3 +639,26 @@ fn stored_variable_ids_reset_only_the_inherited_parent_and_collection() { assert_eq!(result.overrides, std::collections::BTreeSet::from([light])); } } + +#[test] +fn css_export_gives_clashing_variable_names_their_own_property() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Brand"})); + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Brand/Primary", "type": "color", "value": "#ff0000"})); + let blue = ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Brand Primary", "type": "color", "value": "#0000ff"})); + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "link", "type": "color", "value": {"alias": blue["id"]}})); + let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string(); + let decls: Vec<(&str, &str)> = + css.lines().filter_map(|l| l.trim().strip_prefix("--").and_then(|d| d.strip_suffix(';')).and_then(|d| d.split_once(": "))).collect(); + let mut names: Vec<&str> = decls.iter().map(|(n, _)| *n).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), decls.len(), "{css}"); + let red = decls.iter().find(|(_, v)| *v == "#FF0000").map(|(n, _)| *n); + let blue = decls.iter().find(|(_, v)| *v == "#0000FF").map(|(n, _)| *n); + // The first keeps its plain name; the alias reads the one its target is declared under. + assert_eq!(red, Some("brand-primary"), "{css}"); + let blue = blue.unwrap_or_default(); + assert_ne!(blue, "brand-primary", "{css}"); + assert!(css.contains(&format!("--link: var(--{blue});")), "{css}"); +} From e04c5d2274329587b9213f27ad8f88708ea77748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=90=A7=F0=9D=90=9E=F0=9D=90=AB=F0=9D=90=AC?= Date: Sat, 31 Oct 2026 15:38:47 +0100 Subject: [PATCH 08/16] text: use craft-font-catalog on Unix (#1765) * text: use craft-font-catalog on Unix On Linux and BSD distributions that do not follow traditional FHS layout (such as NixOS and Guix), or systems using custom font configurations, fonts are not located under `/usr/share/fonts` or `/usr/local/share/fonts`. Instead, font directories are declared in `/etc/fonts/fonts.conf` and its included configuration files (such as `/etc/fonts/conf.d`), or under `$XDG_DATA_DIRS/fonts`. `figcraft-text` already depends on `craft-font-catalog`, which provides `craft_font_catalog::font_dirs`: - Preserves all existing directory sources (`/usr/share/fonts`, `/usr/local/share/fonts`, and user font directories). - Adds Flatpak host directories (`/run/host/fonts`), `$XDG_DATA_DIRS`, and fontconfig directory trees (`/etc/fonts/fonts.conf`). - Deduplicates the combined list via `craft_font_catalog::dedup_dirs` using canonical paths, ensuring equivalent directories and symlinks are not redundantly scanned. Use `craft_font_catalog::font_dirs` in `system_font_dirs()` on Unix, and retain `unix_user_font_dirs` under `#[cfg(test)]` for its unit tests. * text: walk each system font folder once Fontconfig usually lists folders the scan already has (/usr/share/fonts); the list is deduplicated so their faces aren't indexed twice (CodeRabbit on #1765). Co-Authored-By: Claude Opus 5.5 * text: scan the catalog's full Unix font folder list fontconfig_dirs alone dropped /usr/share/fonts, ~/.fonts and the XDG folders when no fontconfig configuration exists; font_dirs keeps them, adds what fontconfig names, and deduplicates, as the PR description intended. The local user-folder helper it replaces is removed with its tests (the catalog applies the same XDG rules). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/text/src/fontdb.rs | 25 ++++++------------------- crates/text/src/fontdb_tests.rs | 33 +++++++-------------------------- 2 files changed, 13 insertions(+), 45 deletions(-) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index fa708a1bd..2c6aac948 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -1250,14 +1250,17 @@ fn system_font_dirs() -> Vec { } #[cfg(all(unix, not(target_os = "macos")))] { - v.push("/usr/share/fonts".into()); - v.push("/usr/local/share/fonts".into()); - v.extend(unix_user_font_dirs(std::env::var_os("HOME"), std::env::var_os("XDG_DATA_HOME"))); + // The standard folders, the user's (`~/.fonts`, `$XDG_DATA_HOME/fonts`), `$XDG_DATA_DIRS` + // and those fontconfig names (NixOS and Guix keep fonts only there). + v.extend(craft_font_catalog::font_dirs(&craft_font_catalog::FontEnv::from_process())); } // Fonts any Craft app downloaded into the suite's font store (a download being installed is a // `.part` file, which the walk skips). #[cfg(not(target_arch = "wasm32"))] v.extend(craft_font_catalog::font_store_dir(&craft_font_catalog::FontEnv::from_process())); + // Fontconfig usually lists folders already here (`/usr/share/fonts`): walk each once. + let mut seen = std::collections::HashSet::new(); + v.retain(|d| seen.insert(d.clone())); v } @@ -1270,22 +1273,6 @@ fn font_asset_dirs(store: &std::path::Path) -> Vec { rd.flatten().filter(|e| e.file_name().to_str().is_some_and(|n| n.starts_with("com_apple_MobileAsset_Font"))).map(|e| e.path()).collect() } -// Fontconfig's default configuration uses $XDG_DATA_HOME/fonts for user fonts. -// Ignore relative XDG paths, as required by the XDG Base Directory Specification. -#[cfg(all(unix, not(target_os = "macos")))] -fn unix_user_font_dirs(home: Option, data_home: Option) -> Vec { - let home = home.map(std::path::PathBuf::from); - let data = data_home.map(std::path::PathBuf::from).filter(|p| p.is_absolute()).or_else(|| home.as_ref().map(|h| h.join(".local/share"))); - let mut dirs = Vec::new(); - if let Some(home) = home { - dirs.push(home.join(".fonts")); - } - if let Some(data) = data { - dirs.push(data.join("fonts")); - } - dirs -} - /// Adds the faces of every font file under `dir` (up to six folders deep) to `out`, keyed by /// lowercased family: files named `.ttf`, `.otf`, `.ttc` or `.otc`, and files without an extension /// that start with a font signature. diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index dc75b13cb..5f0fa522b 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -264,32 +264,6 @@ fn every_platform_has_a_symbol_font_in_the_fallbacks() { assert!(at("Noto Sans") < at("Noto Sans Symbols") && at("DejaVu Sans") < at("Noto Sans Math")); } -#[cfg(all(unix, not(target_os = "macos")))] -#[test] -fn user_font_dirs_follow_xdg_data_home() { - use std::ffi::OsString; - use std::path::PathBuf; - let dirs = |home: Option<&str>, data: Option<&str>| unix_user_font_dirs(home.map(OsString::from), data.map(OsString::from)); - let paths = |items: &[&str]| items.iter().map(PathBuf::from).collect::>(); - assert_eq!(dirs(Some("/home/user"), Some("/custom user data")), paths(&["/home/user/.fonts", "/custom user data/fonts"])); - for data in [None, Some(""), Some("relative/data")] { - assert_eq!(dirs(Some("/home/user"), data), paths(&["/home/user/.fonts", "/home/user/.local/share/fonts"])); - } - assert_eq!(dirs(None, Some("/custom-data")), paths(&["/custom-data/fonts"])); - assert_eq!(dirs(None, None), paths(&[])); - assert_eq!(dirs(None, Some("relative")), paths(&[])); -} - -#[cfg(all(unix, not(target_os = "macos")))] -#[test] -fn user_font_dirs_preserve_non_utf8_paths() { - use std::ffi::OsString; - use std::os::unix::ffi::{OsStrExt, OsStringExt}; - let dirs = unix_user_font_dirs(Some(OsString::from_vec(b"/home/\xff".to_vec())), Some(OsString::from_vec(b"/data/\xfe".to_vec()))); - assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts"); - assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts"); -} - #[test] fn the_scan_reads_font_files_without_an_extension() { // #298: Adobe Fonts keeps the fonts it activates in hidden files named by number. Any installed @@ -614,3 +588,10 @@ fn core_text_lists_the_installed_font_files() { assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts")), "{} files", files.len()); assert!(files.iter().all(|f| f.is_absolute())); } + +#[test] +fn system_font_dirs_are_walked_once_each() { + let dirs = super::system_font_dirs(); + let unique: std::collections::HashSet<_> = dirs.iter().collect(); + assert_eq!(unique.len(), dirs.len(), "{dirs:?}"); +} From b0673ffc8b659c7d0b0b04dea80d8d2314afcc86 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 31 Oct 2026 18:52:48 -0400 Subject: [PATCH 09/16] packaging(flatpak): add OCI image build and GHCR publishing helper (#1784) --- packaging/linux/flatpak-bundle.sh | 8 ++++ packaging/linux/flatpak-oci.sh | 64 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100755 packaging/linux/flatpak-oci.sh diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index b3353b386..808de718f 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -21,10 +21,12 @@ APP_ID=ai.storyteller.figcraft FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 +OCI=0 TARBALL="" while [[ $# -gt 0 ]]; do case "$1" in --no-test) TEST=0; shift ;; + --oci) OCI=1; shift ;; -h | --help) sed -n '2,14p' "$0"; exit 0 ;; -*) echo "unknown argument: $1" >&2; exit 2 ;; *) TARBALL="$1"; shift ;; @@ -62,6 +64,12 @@ OUT="$DIST/$BASENAME.flatpak" # --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing. flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" +if [[ "$OCI" = 1 ]]; then + OUT_OCI="$DIST/$BASENAME-oci" + rm -rf "$OUT_OCI" + flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable + echo "wrote OCI layout to $OUT_OCI" +fi if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" diff --git a/packaging/linux/flatpak-oci.sh b/packaging/linux/flatpak-oci.sh new file mode 100755 index 000000000..33ec81cd9 --- /dev/null +++ b/packaging/linux/flatpak-oci.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Build figcraft as an OCI container layout and push it to GitHub Container Registry (GHCR) +# for consumption by Flatpak OCI clients (flatpak-oci-specs). +# +# Usage: +# packaging/linux/flatpak-oci.sh [--push] [TARBALL] +# +# Environment variables: +# REGISTRY Target registry image (default: ghcr.io/storytold/figcraft) +# TAG Target image tag (default: $VERSION-$ARCH, also tags stable-$ARCH) +# GITHUB_TOKEN Token for GHCR authentication when --push is used +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" + +PUSH=0 +TARBALL="" +while [[ $# -gt 0 ]]; do + case "$1" in + --push) PUSH=1; shift ;; + -h | --help) sed -n '2,12p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ;; + aarch64 | arm64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac + +BASENAME="figcraft-$VERSION-linux-$ARCH" +OUT_OCI="$DIST/$BASENAME-oci" + +echo "==> Building Flatpak OCI layout for $BASENAME" +"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}" + +[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; } + +REGISTRY="${REGISTRY:-ghcr.io/storytold/figcraft}" +TAG="${TAG:-$VERSION-$ARCH}" + +echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)" + +if [[ "$PUSH" = 1 ]]; then + command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; } + + AUTH_ARGS=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN") + fi + + echo "==> Pushing to $REGISTRY:$TAG" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG" + + # Also update architecture-specific stable tag + echo "==> Pushing to $REGISTRY:stable-$ARCH" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH" + + echo "==> Push complete" +fi From 53259cf50eba496998ae156ddc3270197ac056c5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 31 Oct 2026 23:43:14 -0400 Subject: [PATCH 10/16] ci: grant packages write permission to release workflow --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f93219fae..68a705399 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,7 @@ on: permissions: contents: write + packages: write concurrency: group: release-${{ github.ref }} From 7b5f8c369988ad6b46946b91a95a87aa347f0e93 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 12:30:08 -0500 Subject: [PATCH 11/16] docs: sync Craft App Suite list with ArtCraft Launcher --- README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 63c8fb3d1..012ca857a 100644 --- a/README.md +++ b/README.md @@ -314,14 +314,22 @@ stand on its own. | | App | What it's for | Code | Learn more | |:-:|---|---|---|---| -| | **FigCraft** | **Interface design, components and prototypes · you are here** | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | -| | **PhotoCraft** | Image editing | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) | +| | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) | | | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | | | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | | | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | | | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. From 5fc2840d9089e0b21056b57c4dff9f795e38a4e3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 1 Nov 2026 13:58:36 -0500 Subject: [PATCH 12/16] Release 0.14.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 44 ++++++++++++++++++++++---------------------- Cargo.toml | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 797f6498d..888365316 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1763,7 +1763,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.13.1" +version = "0.14.0" dependencies = [ "arboard", "craft-menu", @@ -1795,7 +1795,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.13.1" +version = "0.14.0" dependencies = [ "clap", "clap_complete", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.13.1" +version = "0.14.0" dependencies = [ "base64 0.22.1", "chrono", @@ -1831,7 +1831,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.13.1" +version = "0.14.0" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1854,7 +1854,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.13.1" +version = "0.14.0" dependencies = [ "kurbo", "serde", @@ -1863,7 +1863,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1876,7 +1876,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.13.1" +version = "0.14.0" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1910,7 +1910,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-doc", "figcraft-layout", @@ -1927,7 +1927,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.13.1" +version = "0.14.0" dependencies = [ "kurbo", "serde", @@ -1935,7 +1935,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1943,7 +1943,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.13.1" +version = "0.14.0" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1959,7 +1959,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1968,7 +1968,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1985,7 +1985,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-color", "serde", @@ -1998,7 +1998,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.13.1" +version = "0.14.0" dependencies = [ "crc32fast", "figcraft-color", @@ -2017,7 +2017,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.13.1" +version = "0.14.0" dependencies = [ "chrono", "crc32fast", @@ -2042,7 +2042,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.13.1" +version = "0.14.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -2058,7 +2058,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.13.1" +version = "0.14.0" dependencies = [ "core-text", "craft-font-catalog", @@ -2076,7 +2076,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.13.1" +version = "0.14.0" dependencies = [ "base64 0.22.1", "chrono", @@ -2104,7 +2104,7 @@ dependencies = [ [[package]] name = "figcraft-vectordrawable" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -2118,7 +2118,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.13.1" +version = "0.14.0" dependencies = [ "base64 0.22.1", "eframe", @@ -6739,7 +6739,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index a560971a8..c7a86dd21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.13.1" +version = "0.14.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 83c28cf3aeb2df61c8c0c3941a3e7fbe6dbe5e1a Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 1 Nov 2026 14:12:18 -0500 Subject: [PATCH 13/16] M6: SVG import: a stretched image stays stretched (#1811) Co-authored-by: Claude Fable 5.1 --- crates/svg/src/import.rs | 12 +++++++++++- crates/svg/src/import/structure_tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 71c6c3b05..e3d9d8523 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -608,7 +608,17 @@ fn image(cx: &mut Ctx, img: &usvg::Image, id: &str, parent: NodeId, base: Affine let hash = add_image(cx.doc, bytes, iw, ih); let mut n = Node::new(cx.doc.alloc_id(), NodeKind::Rectangle, layer_name(id, "Image"), w, h); n.transform = transform; - n.fills = vec![Paint::image(hash, ImageScaleMode::Fill)]; + let mut fill = Paint::image(hash, ImageScaleMode::Fill); + // usvg sizes the box to the picture (`meet` and `slice` are in the transform), so a box of + // another shape is `preserveAspectRatio="none"`: the picture stretched edge to edge. A Fill + // would keep its shape and crop it; a crop of the whole picture stretches it. + if (w * ih - h * iw).abs() > 1e-6 * (w * ih).max(h * iw) + && let figcraft_color::PaintKind::Image(ip) = &mut fill.kind + { + ip.scale_mode = ImageScaleMode::Crop; + ip.crop = Some(Affine::IDENTITY.as_coeffs()); + } + n.fills = vec![fill]; cx.add(n, parent); } diff --git a/crates/svg/src/import/structure_tests.rs b/crates/svg/src/import/structure_tests.rs index adeedb005..f16abb66a 100644 --- a/crates/svg/src/import/structure_tests.rs +++ b/crates/svg/src/import/structure_tests.rs @@ -129,6 +129,28 @@ fn strokes_scale_with_their_layer() { assert_eq!((l.width, l.stroke.weight, l.stroke.dashes.clone()), (30.0, 6.0, vec![12.0, 6.0])); } +/// `preserveAspectRatio="none"` stretches a picture into its box edge to edge. usvg sizes the +/// box to the picture for `meet` and `slice` (the fit is in the transform), so a box of another +/// shape is a stretch: a Fill, which keeps the picture's shape and crops it, would show the +/// middle of the picture instead. +#[test] +fn an_image_stretched_into_its_box_stays_stretched() { + use figcraft_color::{ImageScaleMode, PaintKind}; + let (d, f) = + import(&format!(r##""##)); + let n = node(&d, f, "pic"); + assert_eq!((n.width, n.height), (40.0, 40.0)); + let Some(PaintKind::Image(ip)) = n.fills.first().map(|p| &p.kind) else { panic!("{:?}", n.fills) }; + // The 4 × 2 picture's pixels map onto the whole 40 × 40 box: 10 across, 20 down. + let m = figcraft_render::paint::image_transform(ip, 4.0, 2.0, n.width, n.height); + assert_eq!((m.sx, m.ky, m.kx, m.sy, m.tx, m.ty), (10.0, 0.0, 0.0, 20.0, 0.0, 0.0), "{ip:?}"); + // With the aspect kept (the default) the box is the picture's own shape and a Fill is exact. + let (d, f) = import(&format!(r##""##)); + let n = node(&d, f, "pic"); + assert_eq!((n.width, n.height), (40.0, 20.0)); + assert!(matches!(n.fills.first().map(|p| &p.kind), Some(PaintKind::Image(i)) if i.scale_mode == ImageScaleMode::Fill), "{:?}", n.fills); +} + #[test] fn linked_files_are_never_read() { let dir = std::env::temp_dir().join("figcraft-svg-linked"); From 6b2e464361e84e3df28d33da5681ac08e9991763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=82=BC=ED=98=84?= Date: Mon, 2 Nov 2026 04:12:23 +0900 Subject: [PATCH 14/16] M8: a scaled node's spread shadow keeps its room in SVG (#1803) The SVG export blurs a spread drop shadow in a filter whose region is the silhouette's page bounds grown by the spread's mitre and the blur. The spread grows the outline in the node's own units, though, and the transform then scales it: on a star scaled three times, a spread of 10 mitred out to some 97 units while the region only made room for 48, so SVG viewers cut the shadow's points off. The spread now grows the region as far across and down as the transform takes a local step of that length, as the paint bounds already do. Co-authored-by: Claude Opus 5.5 --- crates/svg/src/effects_tests.rs | 42 +++++++++++++++++++++++++++++++++ crates/svg/src/lib.rs | 10 +++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/svg/src/effects_tests.rs b/crates/svg/src/effects_tests.rs index 5177e992b..f81d8e04c 100644 --- a/crates/svg/src/effects_tests.rs +++ b/crates/svg/src/effects_tests.rs @@ -83,3 +83,45 @@ fn hostile_shadow_values_still_export() { assert!(export_svg(&d, &[n], &SvgOptions::default()).is_some()); } } + +/// The filter a spread shadow is blurred in makes room for the spread as the node's transform +/// draws it: on a star scaled three times, the spread's mitres reach three times as far, and on a +/// skewed one further across than down. +#[test] +fn a_scaled_spread_shadow_keeps_its_filter_room() { + use figcraft_doc::SPREAD_MITER_LIMIT; + use figcraft_geom::{Affine, kurbo}; + let star = NodeKind::Star { count: 5, inner_ratio: 0.4 }; + let s = + Shadow { color: Color::from_hex("000000").unwrap(), offset: (6.0, -9.0), radius: 4.0, spread: 10.0, show_behind: true, ..Default::default() }; + for (why, xf) in [ + ("scaled 3", Affine::scale(3.0)), + ("scaled (3, 1)", Affine::scale_non_uniform(3.0, 1.0)), + ("skewed", Affine::new([1.0, 0.0, 2.0, 1.0, 0.0, 0.0])), + ("turned and stretched", Affine::rotate(0.5) * Affine::scale_non_uniform(3.0, 1.0)), + ("as drawn", Affine::IDENTITY), + ] { + let (mut d, n) = with_effects(star.clone(), vec![EffectKind::DropShadow(s.clone())]); + let t = d.get(n).unwrap().transform; + d.get_mut(n).unwrap().transform = t * xf; + let svg = export(&d, n); + let tag = &svg[svg.find(" f64 { + let at = tag.find(&format!(" {name}=\"")).unwrap() + name.len() + 3; + tag[at..at + tag[at..].find('"').unwrap()].parse().unwrap() + }; + let region = kurbo::Rect::new(attr("x"), attr("y"), attr("x") + attr("width"), attr("y") + attr("height")); + // What the shadow paints, as the renderer draws it: the outline stroked twice the spread + // wide with mitred joins, through the node's transform, moved by the offset (in the + // export's frame, which starts at the node's export bounds) and blurred. + let bounds = figcraft_render::export_bounds(&d, n).unwrap(); + let at = Affine::translate((s.offset.0 - bounds.x0, s.offset.1 - bounds.y0)) * d.world_transform(n); + let shape = figcraft_render::node_outline(&d, d.get(n).unwrap()).unwrap(); + let stroke = kurbo::Stroke::new(s.spread * 2.0).with_join(kurbo::Join::Miter).with_miter_limit(SPREAD_MITER_LIMIT); + let mut grown = kurbo::stroke(shape.0.iter(), &stroke, &Default::default(), 0.01); + grown.apply_affine(at); + let blur = s.radius * 1.5; + let painted = figcraft_geom::Shape::bounding_box(&grown).inflate(blur, blur); + assert!(region.union(painted) == region, "{why}: the filter {region:?} cuts the shadow at {painted:?}"); + } +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index de98795d3..58d80ac91 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -1052,10 +1052,14 @@ impl W<'_> { else { continue; }; - let reach = s.spread.abs() * SPREAD_MITER_LIMIT + s.radius.abs() * 1.5 + 2.0; + // The spread grows the outline in its own units (mitres up to the limit), drawn through + // `xf`: as far across and down as a local step that long goes. The blur is in page units. + let spread = if s.spread.is_finite() { s.spread.abs() * SPREAD_MITER_LIMIT } else { 0.0 }; + let [a, b_, c, d, _, _] = xf.as_coeffs(); + let blur = s.radius.abs() * 1.5 + 2.0; + let region = b.inflate(spread * a.hypot(c) + blur, spread * b_.hypot(d) + blur); let cover = if s.show_behind { None } else { self.cover_input(n, xf) }; - let filter = - self.blur_filter(s.radius, b.inflate(reach, reach), cover.as_deref()).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default(); + let filter = self.blur_filter(s.radius, region, cover.as_deref()).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default(); let op = f64::from(s.color.a * n.opacity.clamp(0.0, 1.0)); let blend = if matches!(s.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) { String::new() From 264bcb1c03219fe8785af50487d7b5a367a338c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=82=BC=ED=98=84?= Date: Mon, 2 Nov 2026 04:12:27 +0900 Subject: [PATCH 15/16] M8: an arc's spread shadow and mitred stroke get room for their corners (#1802) * M8: an arc's spread shadow gets room for its mitred corners Rectangles, frames and ellipses grow by exactly their spread, so their layers only make room for the spread itself; other shapes make room for the mitre at sharp corners (up to four times the spread). An ellipse with arc data (a pie slice, or a ring cut short) counted as an ellipse, but its ends meet its sides in sharp corners: a spread of 25 on a slice of 0.3 rad cut its shadow off at the layer's edge (up to 255 levels). Only an ellipse that goes all the way round now grows by exactly its spread. `figcraft_geom::full_sweep` is what `ellipse` already used to tell a whole ellipse or ring from an arc, so the two agree. Co-Authored-By: Claude Opus 5.5 * M8: an arc's mitred stroke gets room for its corners too Review: `outline_reach` counted every ellipse as an outline whose stroke mitres end on the grown box, the same mistake as the spread. A thin slice with a 20-unit mitred stroke and a spread of 10 still had its shadow cut off (255 levels). It now asks `grows_by_exactly_its_spread`, which picks the same shapes and leaves arcs out. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/doc/src/node.rs | 17 +++++++---- crates/geom/src/lib.rs | 8 ++++- crates/render/src/spread_tests.rs | 50 +++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 6ec7e8245..3ac81afdb 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -942,9 +942,9 @@ impl Node { matches!(&self.kind, NodeKind::Vector { network } if network.vertices.iter().any(|v| v.cap.is_some_and(StrokeCap::is_arrow))); let caps = if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() || point_arrows { w.max(self.stroke.weight) * 3.0 } else { w }; - // Right-angled or smooth outlines (boxes, ellipses) end their mitres on the grown box. - let boxy = matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like(); - let miter = if boxy { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) }; + // Right-angled or smooth outlines (boxes, whole ellipses) end their mitres on the grown + // box; an arc's corners don't. + let miter = if self.grows_by_exactly_its_spread() { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) }; base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * miter } else { 0.0 @@ -960,14 +960,19 @@ impl Node { !(matches!(self.kind, NodeKind::Group) || (matches!(self.kind, NodeKind::Text(_)) && self.has_visible_stroke())) } - /// Whether a spread grows this node's outline by exactly the spread (no sharp corners). + /// Whether a spread grows this node's outline by exactly the spread (no sharp corners). An arc + /// (an ellipse that doesn't go all the way round) has corners where its ends meet its sides. fn grows_by_exactly_its_spread(&self) -> bool { - matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like() + match self.kind { + NodeKind::Rectangle | NodeKind::Section { .. } => true, + NodeKind::Ellipse { sweep, .. } => figcraft_geom::full_sweep(sweep), + _ => self.kind.is_frame_like(), + } } /// How much further than its spread a drop shadow can reach at the outline's sharp corners: /// spread moves the edges out in parallel and they meet in a mitre (see - /// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and ellipses grow by exactly the spread. + /// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and whole ellipses grow by exactly the spread. fn spread_miter_reach(&self) -> f64 { if self.grows_by_exactly_its_spread() { return 0.0; diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 012fca429..696a02e92 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -319,6 +319,12 @@ fn line_or_move(path: &mut BezPath, p: Point) { } } +/// Whether an ellipse of this `sweep` (radians) goes all the way round: a whole ellipse or ring, +/// without the sharp corners where an arc's ends meet its sides. +pub fn full_sweep(sweep: f64) -> bool { + finite(sweep).abs() >= TAU - 1e-9 +} + /// An ellipse in the `width`×`height` box; with arc data, a pie/arc/ring: `start` and `sweep` in /// radians (clockwise from 3 o'clock, y-down) and `inner` radius ratio 0..1. pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> BezPath { @@ -327,7 +333,7 @@ pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> B let sweep = finite(sweep).clamp(-TAU, TAU); let start = finite(start); let inner = finite(inner).clamp(0.0, 1.0); - let full = sweep.abs() >= TAU - 1e-9; + let full = full_sweep(sweep); let mut path = BezPath::new(); if full && inner <= 0.0 { return kurbo::Ellipse::new(c, (rx, ry), 0.0).to_path(0.01); diff --git a/crates/render/src/spread_tests.rs b/crates/render/src/spread_tests.rs index 00fbb6333..6b30766fc 100644 --- a/crates/render/src/spread_tests.rs +++ b/crates/render/src/spread_tests.rs @@ -142,3 +142,53 @@ fn spread_bands_are_cached_and_hostile_values_are_safe() { let _ = render(&d, n); } } + +/// An arc (a pie slice, or a ring cut short) has sharp corners where its ends meet its sides, and +/// a spread mitres them out past the spread: its layer makes room for that, as a star's does. A +/// clear shadow spread four times as far gives the same layer room to the full mitre: the shadow +/// it casts is the same. (Both layers end at the view here, so they line up pixel for pixel; +/// layers starting at different pixels can differ by a few levels at their edges.) +#[test] +fn arc_spread_mitres_are_not_cut_off() { + for kind in [ + NodeKind::Ellipse { start: 1.0, sweep: 0.3, inner: 0.0 }, + NodeKind::Ellipse { start: 0.0, sweep: 3.0, inner: 0.0 }, + NodeKind::Ellipse { start: 1.0, sweep: 2.0, inner: 0.8 }, + ] { + for spread in [25.0, 40.0] { + let (d, n) = shape(kind.clone(), vec![EffectKind::DropShadow(shadow(spread))]); + let clear = Shadow { color: Color::new(0.0, 0.0, 0.0, 0.0), ..shadow(spread * 4.0) }; + let (roomy, m) = shape(kind.clone(), vec![EffectKind::DropShadow(shadow(spread)), EffectKind::DropShadow(clear)]); + let (a, b) = (render(&d, n), render(&roomy, m)); + let worst = a.data().chunks(4).zip(b.data().chunks(4)).map(|(p, q)| p[3].abs_diff(q[3])).max().unwrap_or(0); + assert!(worst <= 2, "{kind:?}, a spread of {spread}: {worst} levels cut off"); + } + } +} + +/// A mitred stroke on an arc reaches out past its width at the arc's corners too, and a spread +/// grows from there: a thin slice with both keeps its whole shadow. +#[test] +fn arc_stroke_mitres_are_not_cut_off() { + use figcraft_doc::{StrokeAlign, StrokeJoin}; + let kind = NodeKind::Ellipse { start: std::f64::consts::FRAC_PI_2 - 0.015, sweep: 0.03, inner: 0.0 }; + for align in [StrokeAlign::Center, StrokeAlign::Outside] { + let make = |effects: Vec| { + let mut d = Document::new("arc"); + let p = d.first_page().unwrap(); + let n = d.create(p, kind.clone(), "S", 0.0, 190.0, 400.0, 20.0).unwrap(); + let node = d.get_mut(n).unwrap(); + node.fills = vec![Paint::solid(Color::WHITE)]; + node.strokes = vec![Paint::solid(Color::WHITE)]; + (node.stroke.weight, node.stroke.align, node.stroke.join, node.stroke.miter_limit) = (20.0, align, StrokeJoin::Miter, 4.0); + node.effects = effects.into_iter().map(|kind| Effect { kind, visible: true }).collect(); + (d, n) + }; + let (d, n) = make(vec![EffectKind::DropShadow(shadow(10.0))]); + let clear = Shadow { color: Color::new(0.0, 0.0, 0.0, 0.0), ..shadow(80.0) }; + let (roomy, m) = make(vec![EffectKind::DropShadow(shadow(10.0)), EffectKind::DropShadow(clear)]); + let (a, b) = (render(&d, n), render(&roomy, m)); + let worst = a.data().chunks(4).zip(b.data().chunks(4)).map(|(p, q)| p[3].abs_diff(q[3])).max().unwrap_or(0); + assert!(worst <= 2, "{align:?}: {worst} levels cut off"); + } +} From bd920fc7dec63ee8d433884a175f2f581cc9eb87 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 1 Nov 2026 14:12:31 -0500 Subject: [PATCH 16/16] M8: .fig import: a routed connector takes its line style from the generated record (#1807) (#1813) * M8: .fig import: a routed connector takes its line style from the generated record (#1807) Co-Authored-By: Claude Fable 5.1 * M8: .fig import: a routed connector keeps its own empty paints and dash pattern CodeRabbit review on #1813: an explicit empty strokePaints (no stroke) or dashPattern (solid) on the connector no longer takes the generated record's. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas --- .../format/src/fig/connector_label_tests.rs | 58 +++++++++++++++++++ crates/format/src/fig/map/connector.rs | 26 +++++++++ docs/file-format-parity.md | 5 +- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/crates/format/src/fig/connector_label_tests.rs b/crates/format/src/fig/connector_label_tests.rs index de0c4f2fb..ca76c0b29 100644 --- a/crates/format/src/fig/connector_label_tests.rs +++ b/crates/format/src/fig/connector_label_tests.rs @@ -218,6 +218,64 @@ fn connector_mut<'a, 's>(message: &'a mut Kv<'s>, local_id: u64) -> Option<&'a m }) } +fn set_connector_field(message: &mut Kv<'_>, key: &'static str, value: Kv<'static>) { + let connector = connector_mut(message, 123).expect("synthetic connector"); + let Kv::Object(fields) = connector else { panic!("connector object") }; + if let Some((_, current)) = fields.iter_mut().find(|(name, _)| *name == key) { + *current = value; + } else { + fields.push((key, value)); + } +} + +/// A connector without a saved snapshot (a curved or elbowed one is routed here) still draws its +/// line as the generated record says: the paint, weight, cap and dashes live there, not on the +/// connector (#1807). +#[test] +fn a_routed_connector_takes_its_line_style_from_the_generated_record() { + let mut message = synthetic_connector(1.0, false, 0.0); + set_connector_field(&mut message, "connectorLineStyle", Kv::Enum("CURVED")); + set_connector_field(&mut message, "connectorStart", obj(vec![("position", point(10.0, 10.0))])); + set_connector_field(&mut message, "connectorEnd", obj(vec![("position", point(110.0, 60.0))])); + set_override_field(&mut message, "nodeGenerationData", 0, "strokeWeight", scalar(4.0)); + set_override_field(&mut message, "nodeGenerationData", 0, "strokeCap", Kv::Enum("ROUND")); + set_override_field(&mut message, "nodeGenerationData", 0, "dashPattern", Kv::Array(vec![scalar(16.0), scalar(10.0)])); + let (rest, warnings, _) = map::to_rest(&message, "synthetic", crate::rest::MAX_NODES); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["type"], "VECTOR"); + assert!(connector.get("vectorNetwork").is_some(), "routed here: {connector}"); + assert_eq!(connector["strokes"].as_array().map(Vec::len), Some(1), "{connector}"); + assert!((connector["strokes"][0]["color"]["b"].as_f64().unwrap() - 0.8).abs() < 1e-6); + assert_eq!(connector["strokeWeight"], 4.0); + assert_eq!(connector["strokeDashes"], serde_json::json!([16.0, 10.0])); + assert_eq!(connector["strokeCapEnd"], "ARROW"); + assert!(warnings.iter().any(|warning| warning.contains("follow FigCraft's own routing")), "{warnings:?}"); + + let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap(); + let node = find_node(&native, "synthetic connector"); + assert_eq!((node.strokes.len(), node.stroke.weight, node.stroke.dashes.clone()), (1, 4.0, vec![16.0, 10.0])); +} + +/// A routed connector that writes its own empty paints and dash pattern is meant to have no stroke +/// and a solid line: the generated record's paint and dashes don't replace them. +#[test] +fn a_routed_connector_keeps_its_own_empty_paints_and_dashes() { + let mut message = synthetic_connector(1.0, false, 0.0); + set_connector_field(&mut message, "connectorLineStyle", Kv::Enum("CURVED")); + set_connector_field(&mut message, "connectorStart", obj(vec![("position", point(10.0, 10.0))])); + set_connector_field(&mut message, "connectorEnd", obj(vec![("position", point(110.0, 60.0))])); + set_connector_field(&mut message, "strokePaints", Kv::Array(Vec::new())); + set_connector_field(&mut message, "dashPattern", Kv::Array(Vec::new())); + set_override_field(&mut message, "nodeGenerationData", 0, "strokeWeight", scalar(4.0)); + set_override_field(&mut message, "nodeGenerationData", 0, "dashPattern", Kv::Array(vec![scalar(16.0), scalar(10.0)])); + let (rest, _, _) = map::to_rest(&message, "synthetic", crate::rest::MAX_NODES); + let connector = &rest["document"]["children"][0]["children"][0]; + assert!(connector.get("vectorNetwork").is_some(), "routed here: {connector}"); + assert!(connector["strokes"].as_array().is_none_or(Vec::is_empty), "{connector}"); + assert!(connector.get("strokeDashes").is_none(), "{connector}"); + assert_eq!(connector["strokeWeight"], 4.0); +} + fn set_override_field(message: &mut Kv<'_>, field: &str, local_id: u64, key: &'static str, value: Kv<'static>) { let connector = connector_mut(message, 123).expect("synthetic connector"); let section = field_mut(connector, field).expect("connector override section"); diff --git a/crates/format/src/fig/map/connector.rs b/crates/format/src/fig/map/connector.rs index 7da7458b9..de2ff501c 100644 --- a/crates/format/src/fig/map/connector.rs +++ b/crates/format/src/fig/map/connector.rs @@ -1,4 +1,5 @@ use super::layer::{cap_name, own_text, svg_path, valid_size}; +use super::paint::{paint, stroke}; use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_record, guid, json, matrix, network, num, rows}; use figcraft_doc::geom::kurbo::{BezPath, PathEl, Rect, Shape}; use figcraft_doc::{RouteEnd, Routing, Side, route_points}; @@ -29,6 +30,7 @@ impl<'a> Mapper<'a> { return Vec::new(); } let mid = self.connector(o, v, space); + self.connector_line_style(o, v); if shape.label { return self.connector_label(o, v, rest_id, mid); } @@ -122,6 +124,30 @@ impl<'a> Mapper<'a> { middle(&path).map(|m| m - b.origin().to_vec2()) } + /// The line's paint, weight, cap and dashes, which a connector keeps in its generated line + /// record rather than on itself: without a saved snapshot the routed line took none of them and + /// drew nothing (#1807). The connector's own values, when it has any, win. + fn connector_line_style(&mut self, o: &mut Obj, v: &View<'a>) { + let Some(line_style) = generated_record(v, "nodeGenerationData", 0) else { return }; + let style = View::of(line_style); + // A connector that writes its own (even empty) paints or dash pattern keeps them: an empty + // list is no stroke, or a solid line, not a missing value. + if v.get("strokePaints").is_none() && o.get("strokes").and_then(Value::as_array).is_none_or(Vec::is_empty) { + let strokes: Vec = style.list("strokePaints").iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect(); + if !strokes.is_empty() { + o.insert("strokes".into(), Value::Array(strokes)); + } + } + let mut line = Obj::new(); + stroke(&mut line, &style); + if v.get("dashPattern").is_some() { + line.remove("strokeDashes"); + } + for (key, value) in line { + o.entry(key).or_insert(value); + } + } + /// A connector with a label but no saved snapshot of it, as the frame a saved one imports as: /// `o` keeps the connector's place and size and gives its line to a vector child, and the /// label is a text child centred on `mid` (the line's middle; the frame's centre when the line diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 50805f39e..56cc701a2 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-31 · **Change:** minor (a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.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-11-01 · **Change:** minor (a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.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) 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 @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234), and the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Elbowed and curved connectors without a saved path are routed as FigCraft's board connectors are (right angles, or one curve, from the sides of the layers they join). A connector's label without a saved snapshot is kept as text centred on its line, without its background. Not read: table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234), and the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Elbowed and curved connectors without a saved path are routed as FigCraft's board connectors are (right angles, or one curve, from the sides of the layers they join), and a connector routed here takes its line's paint, weight, cap and dashes from the file's generated line record, as one with a saved snapshot does (#1807). A connector's label without a saved snapshot is kept as text centred on its line, without its background. Not read: table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | | `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](penpot.md). | further live Penpot and cross-platform QA | | `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, stretching filling every item across, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides; from Sketch 64, before which a group's fill draws nothing), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); operands' own borders ignored as Sketch does; open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (their image under their style's fills; colour adjustments as image filters with Sketch's tone curve, contrast and then brightness added), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, gradients mixed in OKLab or OKLCH as sRGB stops along the same colours, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer, motion and background blurs (Sketch's radius is the standard deviation), opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does, and its opacity fades it and what it clips together; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, also on shapes' image fills, layer and text styles, nested paths), clipping only where the master clips (`clippingBehavior` 1), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only when the master includes them in both instances and exports, as on Sketch's canvas and renders; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open, an auto-width one growing from its aligned edge. Warnings: noise fills, OKLab and OKLCH gradients, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%); the rules for master backgrounds, symbol clipping, mask opacity, bitmap fills, image overrides, stretching stacks, blur radii, colour adjustments, gradient interpolation and text growth checked against Sketch's renders of files built for each case (black-box, synthetic) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session). Known limitations: a plain group (not a frame) that fills a stack keeps its layers' size where Sketch stretches them; a font Sketch can't find draws in the installed family's own weight (Sketch falls back to a regular face); text in the system SF Pro comes out a few percent narrower than Sketch's; macOS's reserved system fonts (PingFang) aren't found; brightness past about +0.4 is approximated (image filters can't add); OKLab gradients become sRGB stops (FigCraft gradients mix in sRGB) | | `.jam` (whiteboards) | native to FigJam | **yes, best effort** (`file.open`, `file.import`, File ▸ Open, drop, CLI, MCP) | no | the same archive as a `.fig` (its canvas header reads `fig-jam.`) through the same reader (#1342): it opens as a board (the file kind; a `.fig` stays a design file), with generated sticky and shape backgrounds and text kept (#1341) as ordinary layers rather than native board objects (see [boards-parity](boards-parity.md)); tables, stamps, code blocks and the like come in as frames with their text and connectors as vectors, with an import note; a layer type the reader doesn't know is left out with a note naming it; writing `.jam` is not supported | — | @@ -187,6 +187,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-11-01 | patch | `.fig` and `.jam` import: a connector without a saved snapshot (elbowed and curved ones are routed here) took no stroke, so it was invisible (#1807); its line's paint, weight, cap and dashes now come from the file's generated line record, as a saved snapshot's do, and the connector's own values, when it has any, win. Test: `fig::connector_label_tests::a_routed_connector_takes_its_line_style_from_the_generated_record`. Numbers unchanged | | 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | | 2026-10-30 | minor | `.fig` import: a connector's label without a saved snapshot was skipped; now the connector imports as a frame holding its line and the label, as a connector with a saved snapshot does, the label centred on the line's middle by length (in a 200-wide box, laid out again with the fonts at hand), in the connector's fill paints or black. Its background isn't known without the snapshot and is left out. Not checked on real files. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` | | 2026-10-30 | minor | Pasted content row: an image the clipboard doesn't carry keeps its name and shows a placeholder that reads as missing; the paste names those layers in its message and opens the import report with its notes (#1646). Numbers unchanged |