diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 0de51f647..b9587293e 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -568,7 +568,7 @@ impl TextPath { /// The path (empty when `d` doesn't parse). pub fn path(&self) -> figcraft_geom::BezPath { - figcraft_geom::BezPath::from_svg(&self.d).unwrap_or_default() + figcraft_geom::svg_path(&self.d).unwrap_or_default() } /// The start offset clamped into 0..1 (NaN: 0). diff --git a/crates/engine/src/automation/file_access.rs b/crates/engine/src/automation/file_access.rs index 26f15700b..5ce06b122 100644 --- a/crates/engine/src/automation/file_access.rs +++ b/crates/engine/src/automation/file_access.rs @@ -300,6 +300,12 @@ pub fn open(path: impl AsRef) -> Result { std::fs::File::open(allow(path, Access::Read)?).map_err(io(path)) } +/// What the file system says about `path` itself (a link is not followed). +pub fn link_metadata(path: impl AsRef) -> Result { + let path = path.as_ref(); + std::fs::symlink_metadata(allow(path, Access::Read)?).map_err(io(path)) +} + /// What the file system says about `path` (links followed). pub fn metadata(path: impl AsRef) -> Result { let path = path.as_ref(); diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 52b34e101..435c3422a 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -194,7 +194,7 @@ fn create_kind(s: &mut Session, p: &Value, t: &str) -> Result { /// Parse SVG path data (`M L H V C S Q T A Z`, absolute and relative) into a bezier path. pub fn parse_path_data(d: &str) -> Option { - figcraft_geom::BezPath::from_svg(d).ok() + figcraft_geom::svg_path(d).ok() } /// A vector's geometry made from SVG path data (see [`path_network`]). @@ -213,7 +213,9 @@ pub(crate) struct PathShape { pub(crate) fn path_network(cmd: &str, d: &str, width: Option, height: Option) -> Result { use figcraft_geom::Shape; let empty = || bad(cmd, "`path` draws nothing: a vector needs a visible line or area"); - let mut path = parse_path_data(d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?; + let mut path = figcraft_geom::svg_path(d).map_err(|e| { + if e.contains("too large") { bad(cmd, "`path` has coordinates too large to draw") } else { bad(cmd, "`path` is not valid SVG path data") } + })?; let bb = path.bounding_box(); if !(bb.x0.is_finite() && bb.y0.is_finite() && bb.width().is_finite() && bb.height().is_finite()) { return Err(bad(cmd, "`path` has coordinates too large to draw")); diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 5f3263765..8aeb12d49 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -194,7 +194,7 @@ fn with(p: &Value, keys: &[&str], mut base: Value) -> Value { } /// An enabled library of the active document (`library` param), read from its file. -fn library(s: &mut Session, cmd: &str, p: &Value) -> Result<(LibraryLink, Arc)> { +pub(crate) fn library(s: &mut Session, cmd: &str, p: &Value) -> Result<(LibraryLink, Arc)> { let key = req_string(cmd, p, "library")?; let link = find_link(s.doc()?, &key) .cloned() diff --git a/crates/engine/src/extensions/plugin_directory.rs b/crates/engine/src/extensions/plugin_directory.rs index 2c7e00e8f..a6adde7bb 100644 --- a/crates/engine/src/extensions/plugin_directory.rs +++ b/crates/engine/src/extensions/plugin_directory.rs @@ -231,12 +231,23 @@ fn bundle_from_disk(cmd: &str, path: &str) -> Result { let wasm = module_file(&file, &m)?; let mut assets = Vec::new(); let dir = file.parent().map(|d| d.join(ASSETS)); + // Links are not followed: a folder handed to the publisher could otherwise put any file the + // publisher can read (a key, a token) in the bundle the directory then serves to everyone. + let linked = |p: &std::path::Path| crate::file_access::link_metadata(p).is_ok_and(|m| m.is_symlink()); + if dir.as_ref().is_some_and(|d| linked(d)) { + return Err(bad(cmd, format!("the {ASSETS} folder is a link: only files inside the plugin's folder are published"))); + } if let Some(entries) = dir.as_ref().and_then(|d| crate::file_access::read_dir(d).ok()) { - let mut names: Vec<(String, std::path::PathBuf)> = entries - .into_iter() - .filter(|p| crate::file_access::metadata(p).is_ok_and(|m| m.is_file())) - .map(|p| (p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), p)) - .collect(); + let mut names: Vec<(String, std::path::PathBuf)> = Vec::new(); + for p in entries { + let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + if linked(&p) { + return Err(bad(cmd, format!("{ASSETS}/{name} is a link: only files inside the plugin's folder are published"))); + } + if crate::file_access::link_metadata(&p).is_ok_and(|m| m.is_file()) { + names.push((name, p)); + } + } names.sort(); if names.len() > MAX_ASSETS { return Err(bad(cmd, format!("a plugin's assets folder holds at most {MAX_ASSETS} files"))); diff --git a/crates/engine/src/plugin_directory_tests.rs b/crates/engine/src/plugin_directory_tests.rs index 358845933..ada39fd5e 100644 --- a/crates/engine/src/plugin_directory_tests.rs +++ b/crates/engine/src/plugin_directory_tests.rs @@ -253,6 +253,31 @@ fn publishing_sends_a_checked_folder() { let _ = std::fs::remove_dir_all(&dir); } +#[cfg(unix)] +#[test] +fn publishing_refuses_a_linked_asset_instead_of_bundling_what_it_points_at() { + let (mut s, d) = session(); + let dir = temp("publish-link"); + std::fs::create_dir_all(dir.join("build")).unwrap(); + std::fs::create_dir_all(dir.join("assets")).unwrap(); + std::fs::write(dir.join("build").join("tiny.wasm"), wasm()).unwrap(); + std::fs::write(dir.join("figcraft-plugin.json"), json!({"id": ID, "name": "Tiny", "version": "1.0.0", "main": "build/tiny.wasm"}).to_string()) + .unwrap(); + // A private file outside the plugin folder, reached through a link in its assets. + let secret = temp("publish-link-secret").join("token.txt"); + std::fs::write(&secret, b"per-user-secret").unwrap(); + std::os::unix::fs::symlink(&secret, dir.join("assets").join("notes.txt")).unwrap(); + let e = s.execute("plugin.directory.publish", &json!({"path": dir.to_string_lossy(), "server": "ws://h:1/"})).unwrap_err().to_string(); + assert!(e.contains("link"), "{e}"); + assert!(d.lock().unwrap().plugins.is_empty(), "nothing was sent"); + // The whole assets folder as a link is refused the same way. + std::fs::remove_file(dir.join("assets").join("notes.txt")).unwrap(); + std::fs::remove_dir(dir.join("assets")).unwrap(); + std::os::unix::fs::symlink(secret.parent().unwrap(), dir.join("assets")).unwrap(); + let e = s.execute("plugin.directory.publish", &json!({"path": dir.to_string_lossy(), "server": "ws://h:1/"})).unwrap_err().to_string(); + assert!(e.contains("link"), "{e}"); +} + #[test] fn a_bundle_asking_for_more_than_the_directory_showed_is_not_installed() { let (mut s, d) = session(); diff --git a/crates/engine/src/selection/selection_colors.rs b/crates/engine/src/selection/selection_colors.rs index 6ab99e819..53ef11fae 100644 --- a/crates/engine/src/selection/selection_colors.rs +++ b/crates/engine/src/selection/selection_colors.rs @@ -5,14 +5,14 @@ use std::collections::{BTreeMap, BTreeSet}; use figcraft_color::{Paint, PaintKind}; -use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableType, VariableValue}; +use figcraft_doc::{Document, NodeId, NodeKind, Style, StyleKind, VariableId, VariableType, VariableValue}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint}; use crate::cmd::styles::{rebind_paint_alpha, unbind_slot}; -use crate::params::{get, paint_of, parse, req_string, targets}; -use crate::{CommandSpec, Result, Session, bad, spec}; +use crate::params::{get, paint_of, parse, req_string, string, targets}; +use crate::{CommandSpec, EngineError, Result, Session, bad, spec}; #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -127,10 +127,17 @@ pub fn specs() -> Vec { spec!( "selection.applyColorStyle", "Apply selection color style", - "{targets, style}; applies the color style to matching fill/stroke lists (text ranges keep typography)", + "{targets, style (id or name), library? (an enabled library: id, name or path; the style is then one it publishes, copied in linked and read-only)}; applies the color style to matching fill/stroke lists (text ranges keep typography)", true, apply_style ), + spec!( + "selection.createColorStyle", + "Create selection color style", + "{targets, name, description?} → {id}; makes a color style of the paint the targets share and applies it to them as selection.applyColorStyle does, keeping the variables bound to the paint; a name another style has, a target whose list holds other paints, or targets bound to different variables are refused", + true, + create_style + ), spec!( "selection.bindColorVariable", "Apply selection color variable", @@ -205,15 +212,96 @@ fn apply_style(s: &mut Session, p: &Value) -> Result { const CMD: &str = "selection.applyColorStyle"; let targets = checked_targets(s, p, CMD)?; let name = req_string(CMD, p, "style")?; + let name = if get(p, "library").is_some() { import_library_style(s, CMD, p, &name)? } else { name }; let style = s.doc()?.style(&name).cloned().ok_or_else(|| bad(CMD, "no such color style"))?; if !matches!(style.kind, StyleKind::Paint { .. }) { return Err(bad(CMD, "a color style is required")); } - for target in &targets { + link_style(s, &targets, &style)?; + Ok(json!({"ok": true})) +} + +/// Copy the color style `key` (id, else name) that the enabled library `library` publishes into the +/// document, linked and read-only, and return the copy's id. +fn import_library_style(s: &mut Session, cmd: &str, p: &Value, key: &str) -> Result { + let (link, lib) = crate::cmd::library::library(s, cmd, p)?; + let published = |st: &&Style| lib.manifest.style(&st.id).is_some_and(|x| x.published); + let styles = || lib.doc.styles.iter().filter(published); + let style = styles() + .find(|st| st.id == key) + .or_else(|| styles().find(|st| st.name == key)) + .ok_or_else(|| bad(cmd, format!("\"{}\" publishes no style `{key}`", link.name)))?; + if !matches!(style.kind, StyleKind::Paint { .. }) { + return Err(bad(cmd, "a color style is required")); + } + let keys = BTreeSet::from([style.id.clone()]); + crate::library::import_styles(s.doc_mut()?, &lib, &keys, false) + .into_values() + .next() + .ok_or_else(|| EngineError::Other("the style didn't import".into())) +} + +fn create_style(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "selection.createColorStyle"; + let targets = checked_targets(s, p, CMD)?; + let doc = s.doc()?; + let paint = targets.first().and_then(|t| t.paint(doc)).ok_or_else(|| bad(CMD, "a selection color target no longer exists"))?; + if !matches!(paint.kind, PaintKind::Solid { .. } | PaintKind::Gradient(_)) { + return Err(bad(CMD, "a solid or gradient paint is required")); + } + if targets.iter().any(|t| t.paint(doc).as_ref() != Some(&paint)) { + return Err(bad(CMD, "the targets no longer share one paint")); + } + if let Some(why) = create_refusal(doc, &targets) { + return Err(bad(CMD, why)); + } + let name = crate::cmd::variables::clean_name(&req_string(CMD, p, "name")?).ok_or_else(|| bad(CMD, "name can't be empty"))?; + if doc.styles.iter().any(|st| st.name == name) { + return Err(bad(CMD, format!("a style named `{name}` already exists"))); + } + let kind = StyleKind::Paint { paints: vec![paint] }; + let bound = targets.first().map(|t| target_bindings(doc, t, &kind)).unwrap_or_default(); + let doc = s.doc_mut()?; + let id = doc.new_style_id(); + let style = Style { id: id.clone(), name, description: string(p, "description").unwrap_or_default(), kind, bound }; + doc.styles.push(style.clone()); + link_style(s, &targets, &style)?; + Ok(json!({"id": id})) +} + +/// Why a color style can't be made from `targets` (`selection.createColorStyle`), if it can't: a +/// linked style replaces the whole fill or stroke list, so a target whose list holds other paints +/// would lose them. The new style takes over the variables bound to the paint, so targets bound to +/// different variables are refused too. The Selection colors menu leaves Create style out for such rows. +pub fn create_refusal(doc: &Document, targets: &[PaintTarget]) -> Option<&'static str> { + if targets.iter().any(|t| t.paints(doc).is_none_or(|paints| paints.len() != 1)) { + return Some("a style can only be made from a fill or stroke list that holds this one paint"); + } + let paint = targets.first().and_then(|t| t.paint(doc))?; + let kind = StyleKind::Paint { paints: vec![paint] }; + let mut bindings = targets.iter().map(|t| target_bindings(doc, t, &kind)); + let first = bindings.next()?; + bindings.any(|b| b != first).then_some("the targets are bound to different variables") +} + +/// The variable bindings a paint style of `kind` made from `target` takes over: those on the fill or +/// stroke list the target stands for. A text range with fills of its own holds none. +fn target_bindings(doc: &Document, target: &PaintTarget, kind: &StyleKind) -> BTreeMap { + if list_field(doc, target).is_none() { + return BTreeMap::new(); + } + let field = if target.fills { "fillStyle" } else { "strokeStyle" }; + doc.node(target.id).map(|n| crate::style_vars::from_layer(n, field, kind)).unwrap_or_default() +} + +/// Apply paint style `style` to every target: a fill or stroke list is linked to it; a text range +/// takes its resolved paints and keeps its typography. +fn link_style(s: &mut Session, targets: &[PaintTarget], style: &Style) -> Result<()> { + for target in targets { if let Some((start, end)) = target.range { // Text ranges have typography-style links, not paint-style links. Copy resolved paint // values just as an explicit range fill does; never replace its font or size. - let StyleKind::Paint { paints } = crate::style_vars::kind_for(s.doc()?, &style, target.id).into_owned() else { continue }; + let StyleKind::Paint { paints } = crate::style_vars::kind_for(s.doc()?, style, target.id).into_owned() else { continue }; let n = s.doc_mut()?.get_mut(target.id)?; if let NodeKind::Text(t) = &mut n.kind { t.restyle(start, end, |st| st.fills = Some(paints.clone())); @@ -222,7 +310,7 @@ fn apply_style(s: &mut Session, p: &Value) -> Result { crate::cmd::styles::apply_style_ref(s.doc_mut()?, target.id, if target.fills { "fillStyle" } else { "strokeStyle" }, Some(&style.id))?; } } - Ok(json!({"ok": true})) + Ok(()) } /// The binding key (`fills//color` or `strokes//color`) of the slot a target stands for, when the diff --git a/crates/engine/tests/it/selection_colors.rs b/crates/engine/tests/it/selection_colors.rs index c3fc4f97c..be661e21a 100644 --- a/crates/engine/tests/it/selection_colors.rs +++ b/crates/engine/tests/it/selection_colors.rs @@ -2,7 +2,7 @@ use figcraft_color::Paint; use figcraft_doc::NodeId; use figcraft_engine::{ Session, - selection_colors::{SelectionColor, collect}, + selection_colors::{self, SelectionColor, collect}, }; use serde_json::{Value, json}; @@ -156,9 +156,14 @@ fn invalid_or_stale_targets_roll_back_atomically() { assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err()); assert_eq!(fill(&s, a), "FF0000"); assert_eq!(s.undo_depth(), before); - for command in - ["selection.setPaint", "selection.selectColor", "selection.applyColorStyle", "selection.bindColorVariable", "selection.detachColor"] - { + for command in [ + "selection.setPaint", + "selection.selectColor", + "selection.applyColorStyle", + "selection.bindColorVariable", + "selection.detachColor", + "selection.createColorStyle", + ] { for params in [json!({}), json!({"targets":[]}), json!({"targets":[{"id":a,"fills":true,"index":0,"range":[0,1]}]})] { assert!(s.execute(command, ¶ms).is_err()); } @@ -421,3 +426,269 @@ fn scopes_filter_the_menu_but_the_command_binds_anyway() { run(&mut s, "selection.bindColorVariable", json!({"targets":fill_slot,"variable":"Line"})); assert_eq!(fill(&s, a), "00FF00"); } + +#[test] +fn a_style_created_from_a_row_links_its_fills_and_strokes_in_one_undo_step() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#0000ff"); + run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"})); + let red = group(&s, &[a, b], "FF0000"); + let before = s.undo_depth(); + let id = run(&mut s, "selection.createColorStyle", json!({"targets":red.targets,"name":"Brand/Red"}))["id"].clone(); + let doc = s.doc().unwrap(); + let style = doc.style(id.as_str().unwrap()).unwrap(); + assert_eq!(style.name, "Brand/Red"); + assert!(matches!(&style.kind, figcraft_doc::StyleKind::Paint { paints } if paints.len() == 1 && paints[0] == red.paint)); + assert_eq!(doc.node(a).unwrap().styles.fill.as_deref(), id.as_str()); + assert_eq!(doc.node(b).unwrap().styles.stroke.as_deref(), id.as_str()); + assert_eq!(doc.node(b).unwrap().styles.fill, None); + assert_eq!(fill(&s, b), "0000FF"); + assert_eq!(s.undo_depth().0, before.0 + 1); + run(&mut s, "style.update", json!({"style":id,"value":"#00ff00"})); + assert_eq!(fill(&s, a), "00FF00"); + assert_eq!(s.doc().unwrap().node(b).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00"); + s.undo().unwrap(); + s.undo().unwrap(); + assert!(s.doc().unwrap().style("Brand/Red").is_none()); + assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill, None); + assert_eq!(fill(&s, a), "FF0000"); + s.redo().unwrap(); + assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill.as_deref(), id.as_str()); +} + +#[test] +fn a_created_style_keeps_the_rows_gradient_opacity_and_blend_mode() { + use figcraft_color::{BlendMode, Color, Gradient, GradientKind}; + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#0000ff"); + let mut paint = Paint::gradient(Gradient::default_for(GradientKind::Radial, Color::BLACK)); + paint.opacity = 0.5; + paint.blend = BlendMode::Multiply; + for id in [a, b] { + run(&mut s, "fill.set", json!({"id":id,"paint":paint})); + } + let row = collect(s.doc().unwrap(), &[a, b]).into_iter().find(|g| g.paint == paint).unwrap(); + assert_eq!(row.targets.len(), 2); + let id = run(&mut s, "selection.createColorStyle", json!({"targets":row.targets,"name":"Shade"}))["id"].clone(); + let doc = s.doc().unwrap(); + assert!(matches!(&doc.style("Shade").unwrap().kind, figcraft_doc::StyleKind::Paint { paints } if paints == &vec![paint.clone()])); + for n in [a, b] { + assert_eq!(doc.node(n).unwrap().fills, vec![paint.clone()]); + assert_eq!(doc.node(n).unwrap().styles.fill.as_deref(), id.as_str()); + } +} + +#[test] +fn creating_a_style_refuses_taken_or_empty_names_and_stale_rows_and_changes_nothing() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#ff0000"); + run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#00ff00"})); + let red = group(&s, &[a, b], "FF0000"); + let before = s.undo_depth(); + let styles = s.doc().unwrap().styles.len(); + for name in ["Brand", " Brand ", "", " "] { + assert!(s.execute("selection.createColorStyle", &json!({"targets":red.targets,"name":name})).is_err(), "{name:?}"); + } + assert!(s.execute("selection.createColorStyle", &json!({"targets":red.targets})).is_err()); + // One slot of the row changed since it was listed: the row no longer stands for one paint. + run(&mut s, "fill.set", json!({"id":b,"color":"#0000ff"})); + let before_stale = s.undo_depth(); + assert!(s.execute("selection.createColorStyle", &json!({"targets":red.targets,"name":"Red"})).is_err()); + assert_eq!(s.undo_depth(), before_stale); + assert_eq!(s.undo_depth().0, before.0 + 1); + let doc = s.doc().unwrap(); + assert_eq!(doc.styles.len(), styles); + assert_eq!(doc.node(a).unwrap().styles.fill, None); + assert_eq!(fill(&s, a), "FF0000"); +} + +#[test] +fn creating_a_style_refuses_a_row_whose_list_holds_other_paints() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#ff0000"); + run(&mut s, "fill.add", json!({"id":a,"paint":"#0000ff"})); + let red = group(&s, &[a, b], "FF0000"); + let before = s.undo_depth(); + let styles = s.doc().unwrap().styles.len(); + let err = s.execute("selection.createColorStyle", &json!({"targets":red.targets,"name":"Red"})).unwrap_err(); + assert!(err.to_string().contains("holds this one paint"), "{err}"); + assert!(selection_colors::create_refusal(s.doc().unwrap(), &red.targets).is_some()); + assert_eq!(s.undo_depth(), before); + let doc = s.doc().unwrap(); + assert_eq!(doc.styles.len(), styles); + assert_eq!(doc.node(a).unwrap().fills.len(), 2, "the layer keeps its other fill"); + assert!(doc.node(b).unwrap().styles.fill.is_none()); + // The single-paint layer alone still makes a style. + let b_only: Vec<_> = red.targets.iter().filter(|t| t.id == b).cloned().collect(); + assert!(selection_colors::create_refusal(s.doc().unwrap(), &b_only).is_none()); + run(&mut s, "selection.createColorStyle", json!({"targets":b_only,"name":"Red"})); + assert!(s.doc().unwrap().node(b).unwrap().styles.fill.is_some()); +} + +#[test] +fn a_style_created_from_a_variable_bound_row_keeps_following_the_variable() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#ff0000"); + let v = theme_variable(&mut s, "Brand", "#ff0000"); + for id in [a, b] { + run(&mut s, "variable.bind", json!({"id":id,"field":"fill","variable":v})); + } + let red = group(&s, &[a, b], "FF0000"); + assert_eq!(red.targets.len(), 2); + run(&mut s, "selection.createColorStyle", json!({"targets":red.targets,"name":"Brand"})); + let style = s.doc().unwrap().style("Brand").unwrap(); + assert_eq!(style.bound.get("fills/0/color").map(String::as_str), v.as_str()); + run(&mut s, "variable.set", json!({"variable":v,"value":"#00ff00"})); + assert_eq!(fill(&s, a), "00FF00"); + assert_eq!(fill(&s, b), "00FF00"); +} + +#[test] +fn a_style_created_from_a_gradient_keeps_its_stop_variables() { + use figcraft_color::{Color, Gradient, GradientKind, PaintKind}; + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let paint = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK)); + run(&mut s, "fill.set", json!({"id":a,"paint":paint})); + let v = theme_variable(&mut s, "Brand", "#ff0000"); + run(&mut s, "variable.bind", json!({"id":a,"field":"fills/0/stops/0/color","variable":v})); + let row = collect(s.doc().unwrap(), &[a]).into_iter().next().unwrap(); + run(&mut s, "selection.createColorStyle", json!({"targets":row.targets,"name":"Shade"})); + assert!(s.doc().unwrap().style("Shade").unwrap().bound.contains_key("fills/0/stops/0/color")); + run(&mut s, "variable.set", json!({"variable":v,"value":"#00ff00"})); + let PaintKind::Gradient(g) = &s.doc().unwrap().node(a).unwrap().fills[0].kind else { panic!("not a gradient") }; + assert_eq!(g.stops[0].color.to_hex(), "00FF00"); +} + +#[test] +fn creating_a_style_refuses_targets_bound_to_different_variables() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#ff0000"); + let v = theme_variable(&mut s, "Brand", "#ff0000"); + run(&mut s, "variable.bind", json!({"id":a,"field":"fill","variable":v})); + let targets: Vec<_> = collect(s.doc().unwrap(), &[a, b]).into_iter().flat_map(|g| g.targets).collect(); + assert_eq!(targets.len(), 2); + assert!(selection_colors::create_refusal(s.doc().unwrap(), &targets).is_some()); + let before = s.undo_depth(); + let styles = s.doc().unwrap().styles.len(); + let err = s.execute("selection.createColorStyle", &json!({"targets":targets,"name":"Red"})).unwrap_err(); + assert!(err.to_string().contains("different variables"), "{err}"); + assert_eq!(s.undo_depth(), before); + let doc = s.doc().unwrap(); + assert_eq!(doc.styles.len(), styles); + assert!(!doc.node(a).unwrap().bound.is_empty()); + assert!(doc.node(b).unwrap().styles.fill.is_none()); +} + +#[test] +fn a_linked_row_switches_to_another_style() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#0000ff"); + run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"})); + let accent = run(&mut s, "style.create", json!({"type":"paint","name":"Accent","value":"#00ff00"}))["id"].clone(); + run(&mut s, "style.apply", json!({"id":a,"style":"Brand","target":"fill"})); + let linked = collect(s.doc().unwrap(), &[a, b]).into_iter().find(|g| g.style.is_some()).unwrap(); + run(&mut s, "selection.applyColorStyle", json!({"targets":linked.targets,"style":"Accent"})); + assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill.as_deref(), accent.as_str()); + assert_eq!(fill(&s, a), "00FF00"); + assert_eq!(fill(&s, b), "0000FF"); +} + +struct Fixture(std::path::PathBuf); + +impl Drop for Fixture { + #[allow(clippy::disallowed_methods)] + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// A saved library file publishing the color style "Brand" (#0000ff) and the text style "Heading", +/// with the color style "_Draft" left unpublished; the fixture, the file and the library's id. +#[allow(clippy::disallowed_methods)] +fn color_library(tag: &str) -> (Fixture, std::path::PathBuf, Value) { + let fixture = Fixture(std::env::temp_dir().join(format!("figcraft-selection-colors-{tag}-{}", std::process::id()))); + let _ = std::fs::remove_dir_all(&fixture.0); + std::fs::create_dir(&fixture.0).unwrap(); + let path = fixture.0.join("library.figcraft"); + let mut publisher = Session::new(); + run(&mut publisher, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"})); + run(&mut publisher, "style.create", json!({"type":"paint","name":"_Draft","value":"#00ff00"})); + let text = run(&mut publisher, "text.create", json!({"text":"Title"}))["id"].clone(); + run(&mut publisher, "style.create", json!({"type":"text","name":"Heading","from":text})); + let published = run(&mut publisher, "library.publish", json!({})); + assert_eq!(published["styles"], 2); + run(&mut publisher, "file.saveAs", json!({"path":path})); + (fixture, path, published["id"].clone()) +} + +#[test] +fn a_row_applies_a_published_library_style_as_a_linked_copy_in_one_undo_step() { + let (_fixture, path, library) = color_library("apply"); + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#00ff00"); + run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"})); + run(&mut s, "library.add", json!({"path":path})); + let red = group(&s, &[a, b], "FF0000"); + let before = s.undo_depth(); + run(&mut s, "selection.applyColorStyle", json!({"targets":red.targets,"style":"Brand","library":library})); + let doc = s.doc().unwrap(); + let copy = doc.node(a).unwrap().styles.fill.clone().unwrap(); + assert!(figcraft_engine::library::is_remote_style(doc, ©)); + assert_eq!(doc.node(b).unwrap().styles.stroke.as_deref(), Some(copy.as_str())); + assert_eq!(fill(&s, a), "0000FF"); + assert_eq!(fill(&s, b), "00FF00"); + assert_eq!(s.undo_depth().0, before.0 + 1); + s.undo().unwrap(); + let doc = s.doc().unwrap(); + assert!(doc.style(©).is_none()); + assert_eq!(doc.node(a).unwrap().styles.fill, None); + assert_eq!(fill(&s, a), "FF0000"); + // Choosing it again reuses the one copy. + s.redo().unwrap(); + let blue = group(&s, &[a, b], "0000FF"); + run(&mut s, "selection.applyColorStyle", json!({"targets":blue.targets,"style":"Brand","library":library})); + assert_eq!(s.doc().unwrap().styles.len(), 1); +} + +#[test] +fn library_styles_that_are_unpublished_not_colors_or_not_enabled_are_refused() { + let (_fixture, path, library) = color_library("refuse"); + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + rectangle(&mut s, "#00ff00"); + let red = group(&s, &[a], "FF0000"); + assert!(s.execute("selection.applyColorStyle", &json!({"targets":red.targets,"style":"Brand","library":library})).is_err()); + run(&mut s, "library.add", json!({"path":path})); + let before = s.undo_depth(); + for (style, lib) in [("_Draft", library.clone()), ("Heading", library.clone()), ("Missing", library.clone()), ("Brand", json!("Elsewhere"))] { + assert!(s.execute("selection.applyColorStyle", &json!({"targets":red.targets,"style":style,"library":lib})).is_err(), "{style}"); + } + assert_eq!(s.undo_depth(), before); + assert!(s.doc().unwrap().styles.is_empty()); + assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill, None); +} + +#[test] +fn a_library_that_is_not_a_string_is_refused_rather_than_linking_a_local_style() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"})); + let red = group(&s, &[a], "FF0000"); + let before = s.undo_depth(); + for lib in [json!({}), json!([]), json!({"id":"Brand"})] { + let err = s.execute("selection.applyColorStyle", &json!({"targets":red.targets,"style":"Brand","library":lib})).unwrap_err(); + assert!(err.to_string().contains("`library` must be a string"), "{lib}: {err}"); + } + assert_eq!(s.undo_depth(), before); + assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill, None); + assert_eq!(fill(&s, a), "FF0000"); +} diff --git a/crates/format/src/fig/map/connector.rs b/crates/format/src/fig/map/connector.rs index e89b875ed..95d988248 100644 --- a/crates/format/src/fig/map/connector.rs +++ b/crates/format/src/fig/map/connector.rs @@ -78,8 +78,13 @@ impl<'a> Mapper<'a> { let (w, h) = n.get("size").map_or((0.0, 0.0), |s| (num(s, "x").unwrap_or(0.0), num(s, "y").unwrap_or(0.0))); let at = e.get("relativePosition").and_then(|r| Some((num(r, "x")?, num(r, "y")?))); let t = n.get("transform").and_then(matrix).unwrap_or_default(); + // An automatic magnet (`AUTO`: the side is picked when the connector is drawn; + // `CENTER`: the end aims at the layer's middle) attaches to the side facing the + // other end, as a board connector drawn here does; the file's `relativePosition` + // is not where such an end sits (#1909). + let automatic = matches!(e.get("magnet").and_then(Kv::str), Some("AUTO" | "CENTER")); match routing { - Routing::Straight => { + Routing::Straight if !automatic => { let (rx, ry) = at.unwrap_or((0.5, 0.5)); RouteEnd::Point(t * Point::new(w * rx, h * ry)) } @@ -453,7 +458,8 @@ fn saved_runs(v: &View<'_>, from: RouteEnd, to: RouteEnd) -> Option, at: Option<(f64, f64)>) -> Side { match e.get("magnet").and_then(Kv::str) { @@ -461,6 +467,7 @@ fn side(e: &Kv<'_>, at: Option<(f64, f64)>) -> Side { Some("RIGHT") => Side::Right, Some("BOTTOM") => Side::Bottom, Some("LEFT") => Side::Left, + Some("AUTO" | "CENTER") => Side::Auto, _ => match at { Some((x, _)) if x <= 0.0 => Side::Left, Some((x, _)) if x >= 1.0 => Side::Right, diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 54ef905e5..176c95c9b 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -666,6 +666,54 @@ fn connectors_without_a_saved_path_follow_their_line_style() { assert!(warnings.iter().any(|w| w.contains("FigCraft's own routing")), "{warnings:?}"); } +/// #1909: a connector end whose magnet is `AUTO` (FigJam picks the side when it draws) or `CENTER` +/// (the end aims at the layer's middle) leaves the side facing the other end, as a board connector +/// drawn here does, whatever `relativePosition` the file kept for it; a named magnet still wins. +#[test] +fn connectors_with_an_automatic_side_leave_the_side_facing_the_target() { + let mut message = tail_message(); + let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() }; + let box_at = |id, name: &str, x| node(id, Some((1, "*")), "RECTANGLE", name, vec![("size", xy(100.0, 100.0)), ("transform", at(x, 600.0))]); + // The ends keep a stale point on the left of A and the right of B, as the reporter's file did. + let end = |node, magnet: &'static str, x| o(vec![("endpointNodeID", g(node)), ("magnet", e(magnet)), ("relativePosition", xy(x, 0.5))]); + let joined = |id, name: &str, style: &'static str, start: &'static str, finish: &'static str| { + node( + id, + Some((1, "+")), + "CONNECTOR", + name, + vec![ + ("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])), + ("connectorStart", end(60, start, 0.0)), + ("connectorEnd", end(61, finish, 1.0)), + ("connectorLineStyle", e(style)), + ], + ) + }; + nodes.extend([ + box_at(60, "A", 0.0), + box_at(61, "B", 300.0), + joined(62, "Elbow", "ELBOWED", "AUTO", "AUTO"), + joined(63, "Line", "STRAIGHT", "CENTER", "CENTER"), + joined(64, "Curve", "CURVED", "AUTO", "AUTO"), + joined(65, "Named", "ELBOWED", "BOTTOM", "AUTO"), + ]); + let (doc, _) = import(&canvas(&schema(), &message), "Sides").unwrap(); + let ends = |name: &str| -> ((f64, f64), (f64, f64)) { + let world = doc.world_transform(named(&doc, name).id); + let pts: Vec<(f64, f64)> = + network_of(&doc, name).vertices.iter().map(|v| world * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect(); + (pts[0], pts[pts.len() - 1]) + }; + // From A's right side (facing B) to B's left side (facing A), not from the stale left point; each + // end starts `END_GAP` (4) outside its side. + for name in ["Elbow", "Line", "Curve"] { + assert_eq!(ends(name), ((104.0, 650.0), (296.0, 650.0)), "{name}"); + } + // A named magnet keeps its side: the bottom of A; the automatic end still faces A. + assert_eq!(ends("Named"), ((50.0, 704.0), (296.0, 650.0))); +} + /// A connector with a label but no saved snapshot: a frame holding its line and its label, /// centred on the line's middle, in black when the connector gives no paint. #[test] diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 9f82b5df1..777642d9a 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -29,6 +29,7 @@ enum StrokeJoin MITER BEVEL ROUND enum VectorMirror NONE ANGLE ANGLE_AND_LENGTH enum ShapeWithTextType SQUARE ELLIPSE enum ConnectorLineStyle ELBOWED STRAIGHT CURVED +enum ConnectorMagnet NONE AUTO TOP LEFT BOTTOM RIGHT CENTER enum ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQUALS enum NavigationType NAVIGATE OVERLAY enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE @@ -50,7 +51,7 @@ message VariableTarget VariableID id message VariableSetID GUID guid, AssetRef assetRef message ConditionalActions Action[] actions, VariableData condition message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable -message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition +message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition, ConnectorMagnet magnet message ConnectorControlPoint Vector position, Vector axis message VariableData AnyValue value, DataType dataType message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar, ImageScaleMode imageScaleMode diff --git a/crates/format/src/penpot.rs b/crates/format/src/penpot.rs index fd1f518b4..10d00c3ed 100644 --- a/crates/format/src/penpot.rs +++ b/crates/format/src/penpot.rs @@ -3978,7 +3978,7 @@ fn vector_path(v: &Value, warnings: &mut Vec) -> Option { /// Parse supported vector commands and report unrepresentable geometry. fn vector_bezpath(v: &Value, warnings: &mut Vec) -> Option { if let Some(d) = v.get("content").and_then(Value::as_str) { - let path = BezPath::from_svg(d).ok().filter(|p| !p.elements().is_empty())?; + let path = figcraft_doc::geom::svg_path(d).ok().filter(|p| !p.elements().is_empty())?; let origin = Affine::translate((-geometry(v, "x", 0.0), -geometry(v, "y", 0.0))); return Some(origin * path); } diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs index 3df4a5657..99acbab2f 100644 --- a/crates/format/src/rest.rs +++ b/crates/format/src/rest.rs @@ -859,7 +859,7 @@ impl<'a> Ctx<'a> { self.warn("very long vector paths were skipped"); continue; } - match BezPath::from_svg(d) { + match figcraft_doc::geom::svg_path(d) { Ok(bp) if sane(&bp) && out.elements().len() + bp.elements().len() <= MAX_PATH_ELEMENTS => { winding.get_or_insert(if text(p, "windingRule") == Some("EVENODD") { WindingRule::EvenOdd } else { WindingRule::NonZero }); out.extend(bp); diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 477b81d7e..8d80f47a6 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -18,6 +18,62 @@ pub use transform::*; /// Corner radii in clockwise order from the top-left: `[tl, tr, br, bl]`. pub type Radii = [f64; 4]; +/// The tolerance an arc of radius `r` is turned into cubics with: a hundredth of a unit, or a +/// millionth of the radius once that is more. kurbo's cubic count grows with radius ÷ tolerance, +/// so a fixed tolerance let a radius of 1e300 ask for more cubics than memory holds. +pub fn arc_tolerance(r: f64) -> f64 { + // An infinite radius (finite inputs can overflow on the way, 1e300 squared) gets an infinite + // tolerance: kurbo's count is then NaN, which it floors to its minimum of a few cubics. + if r.is_finite() { (r.abs() * 1e-6).max(0.01) } else { f64::INFINITY } +} + +/// Largest coordinate, radius or other number SVG path data may carry ([`svg_path`]). +pub const MAX_SVG_COORD: f64 = 1e9; + +/// `d` (SVG path data) as a path. A number past [`MAX_SVG_COORD`] is refused before parsing: the +/// parser turns an arc into cubics by its radius at a fixed tolerance, so a radius of 1e300 would +/// be unbounded work and memory on the way to geometry that is refused anyway. +pub fn svg_path(d: &str) -> Result { + if number_past(d, MAX_SVG_COORD) { + return Err(format!("path data holds a number too large (past {MAX_SVG_COORD:e})")); + } + BezPath::from_svg(d).map_err(|e| e.to_string()) +} + +/// Whether a number in SVG path data `d` lies past ±`max` (or isn't finite). Numbers are split as +/// the SVG grammar does (`1-2` and `.5.5` are two each; `1e-3` is one). +fn number_past(d: &str, max: f64) -> bool { + fn big(cur: &mut String, max: f64) -> bool { + let big = cur.parse::().is_ok_and(|v| v.is_nan() || v.abs() > max); + cur.clear(); + big + } + let (mut cur, mut dot, mut exp) = (String::new(), false, false); + for c in d.chars() { + let continues = match c { + '0'..='9' => true, + '.' => !dot && !exp, + 'e' | 'E' => !exp && cur.chars().any(|c| c.is_ascii_digit()), + '+' | '-' => cur.ends_with(['e', 'E']), + _ => false, + }; + if continues { + dot |= c == '.'; + exp |= matches!(c, 'e' | 'E'); + cur.push(c); + continue; + } + if big(&mut cur, max) { + return true; + } + (dot, exp) = (c == '.', false); + if matches!(c, '+' | '-' | '.') { + cur.push(c); + } + } + big(&mut cur, max) +} + fn finite(v: f64) -> f64 { if v.is_finite() { v } else { 0.0 } } @@ -126,7 +182,7 @@ fn append_corner(path: &mut BezPath, corner: Point, din: Vec2, dout: Vec2, prof: } if sweep.abs() > 1e-9 { let arc = kurbo::Arc::new(center, (r, r), a0, sweep, 0.0); - arc.to_cubic_beziers(0.01, |p1, p2, p| path.curve_to(p1, p2, p)); + arc.to_cubic_beziers(arc_tolerance(r), |p1, p2, p| path.curve_to(p1, p2, p)); } let end = at(0.0, p); if a + b + c > 1e-9 { @@ -260,7 +316,7 @@ pub fn smoothed_polygon(points: &[Point], radius: f64, smoothing: f64) -> BezPat None => path.line_to(k.p3), } if k.sweep.abs() > 1e-9 { - kurbo::Arc::new(k.center, (k.r, k.r), k.a0, k.sweep, 0.0).to_cubic_beziers(0.01, |p1, p2, q| path.curve_to(p1, p2, q)); + kurbo::Arc::new(k.center, (k.r, k.r), k.a0, k.sweep, 0.0).to_cubic_beziers(arc_tolerance(k.r), |p1, p2, q| path.curve_to(p1, p2, q)); } match k.ease_out { Some((h1, h2)) => path.curve_to(h1, h2, k.end), @@ -400,25 +456,29 @@ pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> B let start = finite(start); let inner = finite(inner).clamp(0.0, 1.0); let full = full_sweep(sweep); + let tol = arc_tolerance(rx.max(ry)); let mut path = BezPath::new(); + // A whole turn as an arc with these radii: kurbo's `Ellipse` recovers its radii from an + // affine map, which overflows to infinity for radii near f64's limit (and infinite cubics). + let whole = |rx: f64, ry: f64| kurbo::Arc::new(c, (rx, ry), 0.0, TAU, 0.0); if full && inner <= 0.0 { - return kurbo::Ellipse::new(c, (rx, ry), 0.0).to_path(0.01); + return whole(rx, ry).to_path(tol); } if full { // A ring: outer clockwise, inner counter-clockwise. - path.extend(kurbo::Ellipse::new(c, (rx, ry), 0.0).path_elements(0.01)); - let inner_path = kurbo::Ellipse::new(c, (rx * inner, ry * inner), 0.0).to_path(0.01); + path.extend(whole(rx, ry).path_elements(tol)); + let inner_path = whole(rx * inner, ry * inner).to_path(tol); path.extend(inner_path.reverse_subpaths()); return path; } let outer = kurbo::Arc::new(c, (rx, ry), start, sweep, 0.0); path.move_to(c + Vec2::new(rx * start.cos(), ry * start.sin())); - outer.to_cubic_beziers(0.01, |p1, p2, p| path.curve_to(p1, p2, p)); + outer.to_cubic_beziers(tol, |p1, p2, p| path.curve_to(p1, p2, p)); if inner > 0.0 { let end = start + sweep; path.line_to(c + Vec2::new(rx * inner * end.cos(), ry * inner * end.sin())); let back = kurbo::Arc::new(c, (rx * inner, ry * inner), end, -sweep, 0.0); - back.to_cubic_beziers(0.01, |p1, p2, p| path.curve_to(p1, p2, p)); + back.to_cubic_beziers(tol, |p1, p2, p| path.curve_to(p1, p2, p)); } else { path.line_to(c); } diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index f95bdd032..e4ba8b6a7 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -231,3 +231,49 @@ fn reverse_subpaths_preserves_open_and_closed() { let r = ::reverse_subpaths(&closed); assert!(matches!(r.elements().last(), Some(kurbo::PathEl::ClosePath)), "{:?}", r.elements()); } + +#[test] +fn a_huge_ellipse_arc_is_a_few_cubics() { + let big = ellipse(1e300, 1e300, 0.3, 2.0, 0.5); + assert!(big.elements().len() < 200, "{}", big.elements().len()); +} + +#[test] +fn a_huge_ring_is_a_few_cubics() { + let ring = ellipse(1e300, 1e300, 0.0, TAU, 0.5); + assert!(ring.elements().len() < 200, "{}", ring.elements().len()); +} + +#[test] +fn a_huge_smoothed_star_is_a_few_cubics() { + let star = smoothed_polygon(&star_points(1e300, 1e300, 5, 0.5), 1e299, 0.5); + assert!(star.elements().len() < 400, "{}", star.elements().len()); +} + +#[test] +fn a_huge_rounded_rect_is_a_few_cubics() { + let rect = rounded_rect(1e300, 1e300, [1e299; 4], 0.0); + assert!(rect.elements().len() < 100, "{}", rect.elements().len()); +} + +#[test] +fn an_infinite_radius_is_a_few_cubics() { + let mut n = 0; + kurbo::Arc::new((0.0, 0.0), (f64::INFINITY, 1.0), 0.0, 1.0, 0.0).to_cubic_beziers(arc_tolerance(f64::INFINITY), |_, _, _| n += 1); + assert!(n < 16, "{n}"); +} + +#[test] +fn svg_path_refuses_numbers_past_the_limit_before_parsing() { + let start = std::time::Instant::now(); + for d in ["M0 0 A1e300 1e300 0 0 1 1 1", "M0 0A1e300-1e300 0 0 1 1 1", "M 0 0 L 1e10 5", "M0 0L-2e9.5 0", "M0 0 L 1e999 0"] { + assert!(svg_path(d).is_err(), "{d}"); + } + assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed()); + for d in ["M0 0 L 1e9 5 A 5 5 0 0 1 10 10 Z", "M0 0 L-2.5 0.5 L1e-3 -4e2 Z", "M10,20 l1,-2.5e+1"] { + assert!(svg_path(d).is_ok(), "{d}"); + } + assert!(!number_past("M0 0L-2.5.5 1e-3-4e2z", 1e3)); + assert!(number_past("M0 0L-2.5.5 1e-3-4e3z", 1e3)); + assert!(number_past("M.5.5e10", 1e3)); +} diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index e478c850e..c24a22248 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -619,7 +619,8 @@ fn elliptical_rect(r: figcraft_geom::Rect, [tl, tr, br, bl]: [(f64, f64); 4]) -> if rx > 0.0 && ry > 0.0 { let c = Point::new(corner.x + sx * rx, corner.y + sy * ry); p.line_to((c.x + rx * start.cos(), c.y + ry * start.sin())); - kurbo::Arc::new(c, (rx, ry), start, FRAC_PI_2, 0.0).to_cubic_beziers(0.01, |a, b, e| p.curve_to(a, b, e)); + kurbo::Arc::new(c, (rx, ry), start, FRAC_PI_2, 0.0) + .to_cubic_beziers(figcraft_geom::arc_tolerance(rx.max(ry)), |a, b, e| p.curve_to(a, b, e)); } else { p.line_to(corner); } diff --git a/crates/render/src/grain.rs b/crates/render/src/grain.rs index d339a29fd..0ee827765 100644 --- a/crates/render/src/grain.rs +++ b/crates/render/src/grain.rs @@ -26,6 +26,14 @@ fn unit(h: u64, at: u32) -> f32 { f32::from(((h >> at) & 0xFFFF) as u16) / 65536.0 } +/// Cell positions are rounded to 1 / `STEPS_PER_CELL` of a cell before they're cut into cells. A +/// pixel centre can fall exactly on a cell edge (at a zoom of 1.7, a 1-unit cell is 1.7 pixels +/// across: every tenth edge does), and a view whose origin is a whole number of pixels off (a +/// patch, a band, a pan) reaches it with another rounding error. Unrounded, a row of grains lands +/// in the next cell; rounded, the edge moves 1 / 2²¹ of a cell aside, off the centres layers at +/// round positions and zooms put it on. +const STEPS_PER_CELL: f64 = 1_048_576.0; + /// Maps layer pixels to the grain cells under their centres. struct Cells { /// Layer pixels → local units, divided by the grain size: `[a, b, c, d, e, f]`. @@ -44,7 +52,8 @@ impl Cells { fn at(&self, x: u32, y: u32) -> (i64, i64) { let (px, py) = (f64::from(x) + 0.5, f64::from(y) + 0.5); let [a, b, c, d, e, f] = self.m; - ((a * px + c * py + e).floor() as i64, (b * px + d * py + f).floor() as i64) + let cell = |v: f64| ((v * STEPS_PER_CELL).round() / STEPS_PER_CELL).floor() as i64; + (cell(a * px + c * py + e), cell(b * px + d * py + f)) } } @@ -120,11 +129,14 @@ pub fn texture(layer: &mut Pixmap, t: &Texture, xf: Affine, scale: f64, seed: u3 let hv = hash(ix, iy, seed ^ 0x7E57_u32); // A uniform point in the disc of radius r. let (ang, dist) = (unit(hv, 0) * std::f32::consts::TAU, unit(hv, 16).sqrt() * r); - let (fx, fy) = (x as f32 + 0.5 + ang.cos() * dist, y as f32 + 0.5 + ang.sin() * dist); + // The move in whole pixels, apart from `x` and `y`: the same pixel of the page moves + // the same way wherever the layer's pixels start. + let (mx, my) = ((0.5 + ang.cos() * dist).floor() as i64, (0.5 + ang.sin() * dist).floor() as i64); + let (fx, fy) = (i64::from(x) + mx, i64::from(y) + my); let i = ((y * w + x) * 4) as usize; let mut px = [0u8; 4]; - if fx >= 0.0 && fy >= 0.0 && (fx as u32) < w && (fy as u32) < h { - let j = (((fy as u32) * w + fx as u32) * 4) as usize; + if fx >= 0 && fy >= 0 && fx < i64::from(w) && fy < i64::from(h) { + let j = ((fy * i64::from(w) + fx) * 4) as usize; if let Some(s) = src.get(j..j + 4) { px.copy_from_slice(s); } diff --git a/crates/render/src/grain_tests.rs b/crates/render/src/grain_tests.rs index bde0129a4..743530944 100644 --- a/crates/render/src/grain_tests.rs +++ b/crates/render/src/grain_tests.rs @@ -96,6 +96,26 @@ fn noise_grains_have_their_size_and_stay_put() { assert_eq!(px(&z, 23, 23), c); } +/// Panning by whole pixels at a zoom where grain edges fall on pixel centres (1.7: a 1-unit grain +/// is 1.7 pixels, every tenth edge on a centre) reaches those edges from another origin, with +/// another rounding error: the grains still stay put. +#[test] +fn grains_stay_put_where_their_edges_fall_on_pixel_centres() { + let s = 1.7; + for (why, kind) in [ + ("noise", noise(Noise { density: 0.5, size: 0.5, color: Color::BLACK, ..Noise::default() })), + ("texture", EffectKind::Texture(Texture { size: 0.5, radius: 12.0, clip_to_shape: false, ..Texture::default() })), + ] { + let (d, r) = square(kind); + let a = render_at(&d, r, (0.0, 0.0), s); + for (dx, dy) in [(79u32, 62u32), (13, 7), (31, 47), (5, 99)] { + let b = render_at(&d, r, (f64::from(dx) / s, f64::from(dy) / s), s); + let off = (0..200 - dy).flat_map(|y| (0..200 - dx).map(move |x| (x, y))).filter(|&(x, y)| px(&a, x + dx, y + dy) != px(&b, x, y)).count(); + assert_eq!(off, 0, "{why}, a pan by ({dx}, {dy}) pixels"); + } + } +} + #[test] fn noise_blend_mode_applies() { let (d, r) = square(noise(Noise { density: 1.0, color: Color::WHITE, blend: figcraft_color::BlendMode::Multiply, ..Noise::default() })); @@ -138,3 +158,32 @@ fn hostile_grain_values_do_not_crash() { let _ = render_at(&d, r, (0.0, 0.0), 1.0); } } + +/// Texture moves each pixel by whole pixels, the same in any layer: far from the layer's left edge +/// (past 1024 pixels), a move worked out in `f32` together with the pixel's index came out a +/// pixel off for some indices, so a pan changed which pixel a grain took. (The stripes' gradient +/// itself can differ by a level between the two renders; a grain taking the wrong pixel differs by +/// far more.) +#[test] +fn texture_moves_far_into_a_wide_layer_stay_put() { + use figcraft_color::{ColorStop, Gradient, GradientKind}; + let mut d = Document::new("wide"); + let p = d.first_page().unwrap(); + let r = d.create(p, NodeKind::Rectangle, "Stripes", 0.0, 0.0, 2400.0, 60.0).unwrap(); + let n = d.get_mut(r).unwrap(); + // Black and white stripes, 3 units each: a pixel's neighbours differ. + let stops = (0..=800).map(|i| ColorStop { position: i as f32 / 800.0, color: if i % 2 == 0 { Color::BLACK } else { Color::WHITE } }).collect(); + n.fills = vec![Paint::gradient(Gradient { kind: GradientKind::Linear, stops, from: (0.0, 0.5), to: (1.0, 0.5), width: (0.0, 1.0) })]; + n.effects = + vec![Effect { kind: EffectKind::Texture(Texture { size: 1.0, radius: 12.0, clip_to_shape: false, ..Texture::default() }), visible: true }]; + let view = |x: f64, w: u32| View { origin: (x, -20.0), scale: 1.0, width: w, height: 100 }; + let mut a = Pixmap::new(2440, 100).unwrap(); + render_nodes_into(&d, &[r], &view(-20.0, 2440), &mut a); + for dx in [300u32, 1021, 1500] { + let mut b = Pixmap::new(900, 100).unwrap(); + render_nodes_into(&d, &[r], &view(f64::from(dx) - 20.0, 900), &mut b); + let far = |p: [u8; 4], q: [u8; 4]| p.iter().zip(q).any(|(u, v)| u.abs_diff(v) > 8); + let off = (0..100).flat_map(|y| (0..900).map(move |x| (x, y))).filter(|&(x, y)| far(px(&a, x + dx, y), px(&b, x, y))).count(); + assert_eq!(off, 0, "a pan by {dx} pixels"); + } +} diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs index dd93abc72..103178542 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -569,3 +569,33 @@ fn pans_patch_backdrops_at_the_edges_that_moved_in() { } } } + +#[test] +fn grainy_layers_patch_as_they_render() { + fonts(); + let texture = |clip_to_shape| EffectKind::Texture(figcraft_doc::Texture { size: 0.5, radius: 12.0, clip_to_shape, ..Default::default() }); + let noise = EffectKind::Noise(figcraft_doc::Noise { size: 0.5, density: 0.4, color: Color::BLACK, ..Default::default() }); + // At a zoom of 1.7, a grain of 0.5 is 1.7 pixels across: every tenth grain edge falls on a row + // of pixel centres, and a patch renders those rows from another origin than a full render. + for (why, kind) in [("texture", texture(false)), ("texture clipped to its shape", texture(true)), ("noise", noise)] { + let mut d = Document::new("grain"); + let p = d.first_page().unwrap(); + let e = d.create(p, NodeKind::Rectangle, "E", 60.0, 50.0, 120.0, 90.0).unwrap(); + solid(d.get_mut(e).unwrap(), "E03030"); + d.get_mut(e).unwrap().effects = vec![Effect { kind, visible: true }]; + let m = d.create(p, NodeKind::Rectangle, "M", 220.0, 60.0, 30.0, 30.0).unwrap(); + solid(d.get_mut(m).unwrap(), "20A060"); + let doc = Arc::new(d); + let v = View { origin: (0.0, 0.0), scale: 1.7, width: 400, height: 300 }; + let mut r = Retained::default(); + check(&mut r, &doc, p, &v); + // Over the grainy layer, which is redrawn whole. + let over = edit(&doc, |d| d.get_mut(m).unwrap().transform = Affine::translate((100.0, 80.0))); + let (shown, pix, work) = r.render(&over, p, &v, &Options::default()).unwrap(); + let pix = pix.clone(); + let full = render_view(&over, p, &shown, &Options::default()).unwrap(); + let worst = pix.data().iter().zip(full.data()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + assert!(partial(work), "{why}: {work:?}"); + assert!(worst <= 2, "{why}: {work:?} is {worst} levels off a full render"); + } +} diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 3ce53e15a..2baa2b31e 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -2271,6 +2271,7 @@ status = "draft — needs native review" "selection.applyColorStyle" = "Aplicar estilo de color a la selección" "selection.bindColorVariable" = "Aplicar variable de color a la selección" "selection.colors" = "Colores de la selección" +"selection.createColorStyle" = "Crear estilo de color" "selection.detachColor" = "Separar color de la selección" "selection.selectColor" = "Seleccionar capas con este color" "selection.setPaint" = "Cambiar color de la selección" diff --git a/crates/ui-egui/src/panels/selection_colors.rs b/crates/ui-egui/src/panels/selection_colors.rs index 92fcd941e..dbb6f16e4 100644 --- a/crates/ui-egui/src/panels/selection_colors.rs +++ b/crates/ui-egui/src/panels/selection_colors.rs @@ -4,9 +4,10 @@ use std::sync::Arc; use egui::{Id, Ui}; -use figcraft_color::Color; -use figcraft_doc::{Document, NodeId, StyleKind, VariableType}; -use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, bind_refusal, collect, in_scope}; +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Document, NodeId, PublishedLibrary, Style, StyleKind, VariableType}; +use figcraft_engine::library; +use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, bind_refusal, collect, create_refusal, in_scope}; use serde_json::json; use crate::a11y::Tip; @@ -65,13 +66,15 @@ pub(crate) fn toggle(app: &mut FigApp, targets: Vec, response: Opti } /// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity -/// edits, style/variable detach/apply/bind, and “select layers with this color”; disabled -/// during an interaction. +/// edits, style/variable detach/apply/bind, a style from this file or an enabled library, Create +/// style, and “select layers with this color”; disabled during an interaction. pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId]) { let Ok(ds) = app.session.ds() else { return }; let interacting = ds.interaction.is_some(); - let colors = colors(ui, ds.uid, doc, ids, interacting); + let uid = ds.uid; + let colors = colors(ui, uid, doc, ids, interacting); if colors.len() < 2 { + ui.data_mut(|d| d.remove::(create_form_id())); return; } let t = Tokens::get(ui.ctx()); @@ -92,7 +95,9 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId .as_ref() .and_then(|id| doc.style(id).map(|s| s.name.as_str())) .or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get_by_id(id).map(|v| v.name.as_str()))); - let width = ui.available_width() - 60.0; + // A linked row has a Detach button beside its Styles button. + let buttons = if linked_name.is_some() { 92.0 } else { 60.0 }; + let width = ui.available_width() - buttons; let mut paint = group.paint.clone(); if let Some(scrub) = &scrubbing && scrub.targets == group.targets @@ -159,29 +164,15 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId }); app.field_run_named(Some(edit), "Opacity", "selection.setPaint", |_| json!({"targets": group.targets, "paint": paint})); } - widgets::tight_end(ui, |ui| { - if linked_name.is_some() { - let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") }; - if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() { - app.run("selection.detachColor", json!({"targets": group.targets})); - } - } else { - let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles")); - crate::right::menu_popup(&styles).show(|ui| { - let mut any = false; - for style in &doc.styles { - if !matches!(style.kind, StyleKind::Paint { .. }) { - continue; - } - any = true; - if crate::right::menu_item(ui, &style.name, "") { - app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id})); - app.ui.picker = None; - ui.close(); - } - } - if !any { - widgets::label(ui, tr("No color styles"), t.text_secondary); + widgets::tight_end_n(ui, if linked_name.is_some() { 3 } else { 2 }, |ui| { + let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles")); + crate::right::menu_popup(&styles).show(|ui| { + style_items(app, ui, doc, group, &t); + if linked_name.is_none() { + ui.separator(); + if create_refusal(doc, &group.targets).is_none() && crate::right::menu_item(ui, tr("Create style"), "") { + open_create_form(app, ui, doc, group); + ui.close(); } let variables: Vec<_> = doc .variables @@ -204,7 +195,13 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId ui.close(); } } - }); + } + }); + if linked_name.is_some() { + let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") }; + if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() { + app.run("selection.detachColor", json!({"targets": group.targets})); + } } if icons::button(ui, Icon::SelectColor, 24.0, false, tr("Select layers with this color")).clicked() { app.run("selection.selectColor", json!({"targets": group.targets})); @@ -217,6 +214,106 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId }); ui.add_space(8.0); widgets::separator(ui); + create_form(app, ui.ctx(), uid); +} + +/// The paint styles a row's Styles menu offers: this file's (not library copies), then each enabled +/// library's published ones under its name (a library this session hasn't read offers none). A choice +/// applies the style to the row's targets. +fn style_items(app: &mut FigApp, ui: &mut Ui, doc: &Document, group: &SelectionColor, t: &Tokens) { + let paint = |s: &&Style| matches!(s.kind, StyleKind::Paint { .. }); + let local: Vec<&Style> = library::local_styles(doc).filter(paint).collect(); + let libs = crate::variable_ui::libraries(app, doc); + let offered: Vec<(&PublishedLibrary, Vec<&Style>)> = libs + .iter() + .map(|l| (l.as_ref(), l.doc.styles.iter().filter(|s| l.manifest.style(&s.id).is_some_and(|x| x.published)).filter(paint).collect::>())) + .filter(|(_, list)| !list.is_empty()) + .collect(); + if local.is_empty() && offered.is_empty() { + widgets::label(ui, tr("No color styles"), t.text_secondary); + } + for style in local { + if crate::right::menu_item(ui, &style.name, "") { + app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id})); + app.ui.picker = None; + ui.close(); + } + } + for (lib, list) in offered { + ui.separator(); + widgets::label(ui, &lib.manifest.name, t.text_secondary); + for style in list { + if crate::right::menu_item(ui, &style.name, "") { + app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id, "library": lib.manifest.id})); + app.ui.picker = None; + ui.close(); + } + } + } +} + +/// The Create style window's state: the document and row it was opened for, and the name typed. +#[derive(Clone)] +struct CreateForm { + document: u64, + targets: Vec, + paint: Paint, + name: String, +} + +fn create_form_id() -> Id { + Id::unique("selection_color_create_style") +} + +/// Open the Create style window for `group`, with the next free "Color n" name. +fn open_create_form(app: &mut FigApp, ui: &Ui, doc: &Document, group: &SelectionColor) { + let Ok(ds) = app.session.ds() else { return }; + let form = CreateForm { + document: ds.uid, + targets: group.targets.clone(), + paint: group.paint.clone(), + name: crate::style_picker::next_name(doc, "Color"), + }; + app.ui.picker = None; + ui.data_mut(|d| d.insert_temp(create_form_id(), form)); +} + +/// The Create style window: the row's paint, a name, Cancel and Create (Enter creates). Create runs +/// `selection.createColorStyle`; when it fails (a name in use, a row that changed) the window stays +/// open with the name for correction. +fn create_form(app: &mut FigApp, ctx: &egui::Context, document: u64) { + let Some(mut form) = ctx.data(|d| d.get_temp::(create_form_id())) else { return }; + if form.document != document { + ctx.data_mut(|d| d.remove::(create_form_id())); + return; + } + let (mut open, mut cancel, mut create) = (true, false, false); + crate::dialogs::window(tr("Create style"), ctx).id(create_form_id().with("window")).open(&mut open).show(ctx, |ui| { + ui.set_width(240.0); + ui.horizontal(|ui| { + widgets::swatch(ui, &form.paint, 24.0); + ui.label(widgets::paint_name(&form.paint)); + }); + ui.add_space(6.0); + let name = ui.add(egui::TextEdit::singleline(&mut form.name).hint_text(tr("Name")).desired_width(220.0)); + widgets::select_all_on_focus(ui, &name, &form.name); + if ui.memory(|m| m.focused().is_none()) { + name.request_focus(); + } + ui.add_space(10.0); + let ready = !form.name.trim().is_empty(); + ui.horizontal(|ui| { + cancel = widgets::button(ui, tr("Cancel"), false, true).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)); + let enter = name.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + create = (widgets::button(ui, tr("Create"), true, ready).clicked() || enter) && ready; + }); + }); + let made = create && app.run("selection.createColorStyle", json!({"targets": form.targets, "name": form.name.trim()})).is_some(); + if open && !cancel && !made { + ctx.data_mut(|d| d.insert_temp(create_form_id(), form)); + } else { + ctx.data_mut(|d| d.remove::(create_form_id())); + } } #[cfg(test)] @@ -546,4 +643,100 @@ mod tests { assert!(texts.iter().any(|(s, _)| s == "FF0000") && texts.iter().any(|(s, _)| s == "0000FF")); assert!(p.app.ui.last_error.is_none()); } + + fn text_at(texts: &[(String, Pos2)], s: &str) -> Pos2 { + texts.iter().rev().find(|(t, _)| t == s).unwrap_or_else(|| panic!("{s:?} isn't drawn")).1 + } + + #[test] + fn styles_menu_offers_an_enabled_librarys_published_color_styles() { + let dir = std::env::temp_dir().join(format!("figcraft-selection-colors-panel-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("library.figcraft"); + let mut publisher = Session::new(); + publisher.execute("style.create", &json!({"type":"paint","name":"Ocean","value":"#00ffff"})).unwrap(); + publisher.execute("style.create", &json!({"type":"paint","name":"_Draft","value":"#ffff00"})).unwrap(); + publisher.execute("library.publish", &json!({})).unwrap(); + publisher.execute("file.saveAs", &json!({"path":path})).unwrap(); + let (mut p, a, b) = Panel::new(); + p.app.run("library.add", json!({"path":path})); + let red = text_at(&p.frame(vec![]), "FF0000"); + p.click(Pos2::new(228.0, red.y)); + let texts = p.frame(vec![]); + let library_name = p.app.session.doc().unwrap().libraries.enabled[0].name.clone(); + assert!(texts.iter().any(|(s, _)| *s == library_name), "library styles are listed under the library's name"); + assert!(!texts.iter().any(|(s, _)| s == "_Draft"), "unpublished styles aren't offered"); + p.click(text_at(&texts, "Ocean")); + let _ = std::fs::remove_dir_all(&dir); + let doc = p.app.session.doc().unwrap(); + let copy = doc.node(a).unwrap().styles.fill.clone().unwrap(); + assert!(library::is_remote_style(doc, ©)); + assert_eq!(doc.node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FFFF"); + assert!(doc.node(b).unwrap().styles.fill.is_none()); + assert!(p.app.ui.last_error.is_none()); + // The copy is listed under its library, not as one of this file's styles. + let blue = text_at(&p.frame(vec![]), "0000FF"); + p.click(Pos2::new(228.0, blue.y)); + let texts = p.frame(vec![]); + assert_eq!(texts.iter().filter(|(s, _)| s == "Ocean").count(), 2, "the linked row's name and the library's entry"); + } + + #[test] + fn create_style_names_the_row_links_it_and_stays_open_on_a_taken_name() { + let (mut p, a, b) = Panel::new(); + let red = text_at(&p.frame(vec![]), "FF0000"); + p.click(Pos2::new(228.0, red.y)); + let create = text_at(&p.frame(vec![]), "Create style"); + p.click(create); + for _ in 0..2 { + p.frame(vec![]); + } + let form = p.ctx.data(|d| d.get_temp::(create_form_id())).expect("Create style opens its window"); + assert_eq!(form.name, "Color 1"); + // Another style takes the name first: Create fails and the window stays for a new name. + p.app.run("style.create", json!({"type":"paint","name":"Color 1","value":"#00ff00"})); + let button = text_at(&p.frame(vec![]), "Create"); + p.click(button); + assert!(p.ctx.data(|d| d.get_temp::(create_form_id())).is_some()); + assert!(p.app.session.doc().unwrap().node(a).unwrap().styles.fill.is_none()); + p.ctx.data_mut(|d| d.get_temp_mut_or_insert_with(create_form_id(), || form.clone()).name = "Brand".into()); + let before = p.app.session.undo_depth(); + let button = text_at(&p.frame(vec![]), "Create"); + p.click(button); + assert!(p.ctx.data(|d| d.get_temp::(create_form_id())).is_none()); + let doc = p.app.session.doc().unwrap(); + let brand = doc.style("Brand").unwrap().id.clone(); + assert_eq!(doc.node(a).unwrap().styles.fill.as_deref(), Some(brand.as_str())); + assert!(doc.node(b).unwrap().styles.fill.is_none()); + assert_eq!(p.app.session.undo_depth().0, before.0 + 1); + assert!(p.frame(vec![]).iter().any(|(s, _)| s == "Brand")); + } + + #[test] + fn create_style_is_left_out_for_a_row_whose_list_holds_other_paints() { + let (mut p, a, _) = Panel::new(); + p.app.run("fill.add", json!({"id":a,"paint":"#00ff00"})); + let red = text_at(&p.frame(vec![]), "FF0000"); + p.click(Pos2::new(228.0, red.y)); + let texts = p.frame(vec![]); + assert!(texts.iter().any(|(s, _)| s == "No color styles"), "the Styles menu opens"); + assert!(!texts.iter().any(|(s, _)| s == "Create style")); + } + + #[test] + fn a_linked_rows_styles_menu_switches_its_style() { + let (mut p, a, _) = Panel::new(); + p.app.run("style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"})); + p.app.run("style.create", json!({"type":"paint","name":"Accent","value":"#00ff00"})); + p.app.run("style.apply", json!({"id":a,"style":"Brand","target":"fill"})); + let row = text_at(&p.frame(vec![]), "Brand"); + p.click(Pos2::new(196.0, row.y)); + let texts = p.frame(vec![]); + assert!(!texts.iter().any(|(s, _)| s == "Create style"), "a linked row has a style already"); + p.click(text_at(&texts, "Accent")); + let doc = p.app.session.doc().unwrap(); + assert_eq!(doc.node(a).unwrap().styles.fill.as_deref(), doc.style("Accent").map(|s| s.id.as_str())); + assert!(p.app.ui.last_error.is_none()); + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0beaf48b1..2e4bacdf1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -963,7 +963,12 @@ pub fn section_header_resp(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], d /// of them ending at the section's 8 pt margin, whatever the row has before it. (The columns around /// them keep the 8 pt gutter.) pub fn tight_end(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { - let group = 24.0 * 2.0 + 4.0; + tight_end_n(ui, 2, add) +} + +/// `tight_end` for a group of `count` 24 pt buttons. +pub fn tight_end_n(ui: &mut Ui, count: usize, add: impl FnOnce(&mut Ui) -> R) -> R { + let group = 24.0 * count as f32 + 4.0 * count.saturating_sub(1) as f32; let avail = ui.available_rect_before_wrap(); let rect = Rect::from_min_max(pos2((avail.max.x - group).max(avail.min.x), avail.min.y), avail.max); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center))); diff --git a/crates/vectordrawable/src/paint.rs b/crates/vectordrawable/src/paint.rs index f66d0554a..0e8d01407 100644 --- a/crates/vectordrawable/src/paint.rs +++ b/crates/vectordrawable/src/paint.rs @@ -68,7 +68,7 @@ pub(crate) fn color_at(stops: &[(f64, Color)], t: f64) -> Color { /// the seam where the gradient starts over kept as a hard step. pub(crate) fn turn_stops(stops: &[(f64, Color)], shift: f64) -> Vec<(f64, Color)> { let d = shift.rem_euclid(1.0); - if !d.is_finite() || d < 1e-9 || d > 1.0 - 1e-9 || stops.is_empty() { + if !d.is_finite() || !(1e-9..=1.0 - 1e-9).contains(&d) || stops.is_empty() { return stops.to_vec(); } let seam = 1.0 - d; diff --git a/crates/vectordrawable/src/tests/gradients.rs b/crates/vectordrawable/src/tests/gradients.rs index ae58126e1..8f2a69edc 100644 --- a/crates/vectordrawable/src/tests/gradients.rs +++ b/crates/vectordrawable/src/tests/gradients.rs @@ -77,7 +77,7 @@ fn every_gradient_samples_where_figcraft_samples_it() { let stops: Vec<_> = g.sorted_stops().iter().map(|s| (f64::from(s.position), s.color)).collect(); let (a, b) = (color_at(&stops, want), color_at(&grad.stops, got)); // Away from the sweep's seam a turn's worth either side is the same colour. - let near_seam = kind == GradientKind::Angular && (want < 1e-3 || want > 1.0 - 1e-3); + let near_seam = kind == GradientKind::Angular && !want.is_nan() && !(1e-3..=1.0 - 1e-3).contains(&want); if !near_seam { assert!( (a.r - b.r).abs() < 1e-3 && (a.g - b.g).abs() < 1e-3 && (a.b - b.b).abs() < 1e-3, @@ -107,7 +107,7 @@ fn the_written_gradient_matches_the_rendered_pixels() { for x in (2..98).step_by(3) { let pt = Point::new(x as f64 + 0.5, y as f64 + 0.5); let ft = figcraft_t(kind, f, pt); - if kind == GradientKind::Angular && (ft < 0.03 || ft > 0.97 || (ft - 0.6).abs() < 0.01) { + if kind == GradientKind::Angular && !ft.is_nan() && (!(0.03..=0.97).contains(&ft) || (ft - 0.6).abs() < 0.01) { continue; } let (t, grad) = vd_t(&out, pt).unwrap(); @@ -149,7 +149,7 @@ fn turned_stops_keep_every_colour_where_it_was() { for i in 1..200 { let t = i as f64 / 200.0; let moved = (t + shift).rem_euclid(1.0); - if moved < 1e-6 || moved > 1.0 - 1e-6 { + if !moved.is_nan() && !(1e-6..=1.0 - 1e-6).contains(&moved) { continue; } let (a, b) = (color_at(&stops, t), color_at(&turned, moved)); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 65773ccd5..6defaa1a9 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-11-04 · **Change:** minor (a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); 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 @@ -187,6 +187,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | patch | M6, #1909: `.jam` import: a connector end whose magnet is `AUTO` (FigJam picks the side when it draws) or `CENTER` (the end aims at the layer's middle) left the side its stale `relativePosition` named, the left one in the reporter's file, so the connectors looped behind their shape or crossed it. It now leaves the side facing the other end, as a board connector drawn here does (`Side::Auto`), for straight, elbowed and curved connectors alike; a named magnet (`TOP`, `RIGHT`, `BOTTOM`, `LEFT`) is unchanged, and an end without a magnet still takes the edge its point lies on. The Plugin API lists the magnets (`ConnectorEndpoint.magnet`: "Which side of the node it attaches to"; NONE, AUTO, TOP, LEFT, BOTTOM, RIGHT, CENTER; "Straight connectors may use only CENTER or NONE magnets"). Test: `connectors_with_an_automatic_side_leave_the_side_facing_the_target`. Numbers unchanged. | | 2026-11-04 | minor | JPG export: `quality` (1 to 100, default 90) on `file.export`, `export.add` / `export.update` and MCP `export`, kept in the export setting; an sRGB file's JPGs embed the sRGB ICC profile, as Display P3 files' JPGs already embedded theirs, since an untagged JPG is only assumed to be sRGB (#2004). | | 2026-11-04 | minor | `.fig` and `.jam` import, #1910: an elbowed connector saved with its path keeps it. FigJam stores `connectorControlPoints`, one per run between the first and the last, each a point on its run (in the connector's parent's coordinates) with the run's axis; the first run leaves the start's side and the last arrives at the end's. They become the `ConnectorPath::Runs` a board connector reshaped by hand has (`figcraft_doc::route_along`), so a connector drawn around a layer in FigJam stays around it instead of being routed through it. Points that don't make such a path (off both axes, two runs in a row the same way, a first or last run sideways to its side or going back into its object, more runs than a hand could make) leave the connector to the router as before, and only connectors routed here count in the "follow FigCraft's own routing" note. A curved connector's `connectorBezierControlPoints` are still not read. Tests: `tail_tests::an_elbow_connector_saved_with_its_path_keeps_it`, `tail_tests::an_elbow_whose_saved_path_doesnt_fit_its_sides_is_routed_here`. | | 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d08ed4c60..ec609964d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -78,6 +78,8 @@ interaction feel not yet compared. | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). A ring at each layer's centre drags it to another place in a regular row, column or complete top-left-aligned grid: the others close up and make room around it with the gaps kept, and with Cmd/Ctrl held the two layers swap places instead, previewed live while dragging (`selection_reorder_tests`; no ring on a layer under 16 px on screen). Not covered: reordering irregular grids, and several marked layers moved together. Checked by its author on macOS and in the app on Windows (#402) | | Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes; Tab while the box shows, or the chord again, switches to pick select: a pink outline on the first selected layer (the page's top layer with nothing selected), Tab and Shift+Tab move it between sibling layers in Layers order, Enter into a frame's top child, Shift+Enter out to the parent, Space selects it, Escape or a press on the canvas ends it; `ui.key` takes the same keys (#1836) | +| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). A row's Styles menu also lists each enabled library's published color styles under the library's name (a library that can't be read offers none), and a linked row's menu switches it to another style; a library style is copied in, linked and read-only, in the same undo step (`selection.applyColorStyle` with `library`). Create style on an ordinary row asks for a name (the next free "Color n" first) and makes a style of the row's whole paint, gradients, opacity, blend mode and variables bound to gradient stops included, linked to every fill and stroke the row stands for in one undo step; a name in use keeps the window open (`selection.createColorStyle`). Text ranges with fills of their own take the style's value, without a link. No library variables from there, and no new variable from a row (#403) | +| Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | ## Tools