diff --git a/Cargo.lock b/Cargo.lock index b3ec5a0b9..302d30fd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6726,6 +6726,7 @@ name = "vectorcraft-scriptcheck" version = "0.24.0" dependencies = [ "proptest", + "unicode-general-category", ] [[package]] diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index fab468c39..57ab10359 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -486,7 +486,12 @@ fn info(args: &[String]) -> Result<(), String> { let base = h.call("engine.execute", json!({"command": "file.info", "params": {}}))?; let doc = h.session.doc().map_err(|e| e.to_string())?.doc.clone(); let mut kinds: std::collections::BTreeMap<&'static str, usize> = Default::default(); - doc.walk(|n| *kinds.entry(n.kind_label()).or_default() += 1); + // Ruler guides are not counted among the objects. + doc.walk(|n| { + if !n.is_ruler_guide() { + *kinds.entry(n.kind_label()).or_default() += 1; + } + }); let fonts = h.call("engine.execute", json!({"command": "text.fonts", "params": {}})).unwrap_or(Value::Null); let artboards: Vec = doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect(); diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 45ed99299..3faf94423 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -583,7 +583,8 @@ impl<'a> Scene<'a> { } fn node(&mut self, n: &Node, force: bool) { - if let NodeKind::Layer { template: true, .. } = n.kind { + // Ruler guides draw on screen only: no entity and no loss warning. + if n.is_ruler_guide() || n.is_template() { return; } // Symbols that hold symbols are written in full for each instance: depth alone doesn't @@ -676,6 +677,8 @@ impl<'a> Scene<'a> { self.node(c, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } self.opacity = opacity; } diff --git a/crates/doc/src/artboard.rs b/crates/doc/src/artboard.rs index ac5db48c7..fd06ea909 100644 --- a/crates/doc/src/artboard.rs +++ b/crates/doc/src/artboard.rs @@ -1,4 +1,4 @@ -use super::{Arc, Deserialize, LayerColor, Node, NodeId, NodeKind, Rect, Serialize, SymbolBoxes, skip}; +use super::{Arc, Deserialize, LayerColor, Node, NodeId, Rect, Serialize, SymbolBoxes, skip}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Artboard { @@ -204,12 +204,7 @@ impl LockedArtboards<'_> { /// [`LockedArtboards::holds`] for new art too: is `n` on a locked artboard, whether or not it /// is new art? pub fn overlaps(&self, n: &Node) -> bool { - if self.rects.is_empty() - || !n.visible - || n.is_layer() - || matches!(n.kind, NodeKind::Path { guide: true, .. }) - || self.pattern_art.contains(&n.id) - { + if self.rects.is_empty() || !n.visible || n.is_layer() || n.is_guide() || self.pattern_art.contains(&n.id) { return false; } if let Some(held) = self.held.borrow().get(&n.id) { diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs index 114235696..e4f267a8b 100644 --- a/crates/doc/src/canvas_range.rs +++ b/crates/doc/src/canvas_range.rs @@ -123,6 +123,24 @@ fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut }); } +/// Remove the ruler guides of layer `layer` and of its sublayers that `out` picks, adding how many +/// to `dropped`. Only layers holding one are copied. +fn drop_guides(layer: &mut Arc, out: &impl Fn(&Node) -> bool, dropped: &mut usize) { + fn holds(n: &Node, out: &impl Fn(&Node) -> bool) -> bool { + n.is_layer() && n.children().is_some_and(|ch| ch.iter().any(|c| out(c) || holds(c, out))) + } + if !holds(layer, out) { + return; + } + let Some(children) = Arc::make_mut(layer).children_mut() else { return }; + let before = children.len(); + children.retain(|c| !out(c)); + *dropped += before - children.len(); + for c in children.iter_mut().filter(|c| c.is_layer()) { + drop_guides(c, out, dropped); + } +} + impl Document { /// Remove what a read file has beyond the canvas ([`MAX_COORD`], or not finite): objects /// (see [`BeyondCanvas::objects`]; a top-level layer clipping by a path beyond it is emptied @@ -141,8 +159,9 @@ impl Document { for c in children.iter() { layers_in(c, &mut layers); } - gone.objects += children.len(); - children.clear(); + // The layer's own ruler guides stay; only its art goes. + gone.objects += children.iter().filter(|c| !c.is_ruler_guide()).count(); + children.retain(|c| c.is_ruler_guide()); *clip = false; } } else { @@ -161,9 +180,18 @@ impl Document { self.retire_artboard_id(*id); } gone.artboards = removed.len(); - let guides = self.guides.len(); - self.guides.retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l))); - gone.guides = guides - self.guides.len(); + // The saved list, when the guides are not nodes yet ([`Document::guides_into_nodes`]). + let guides = self.saved_guides.len(); + self.saved_guides + .retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l))); + gone.guides = guides - self.saved_guides.len(); + let out = |n: &Node| match n.kind { + NodeKind::RulerGuide { pos, artboard, .. } => !in_range(pos) || artboard.is_some_and(|a| removed.contains(&a)), + _ => false, + }; + for layer in &mut self.layers { + drop_guides(layer, &out, &mut gone.guides); + } if !(in_range(self.ruler_origin.x) && in_range(self.ruler_origin.y)) { self.ruler_origin = Point::ZERO; gone.ruler_origin = true; @@ -232,12 +260,18 @@ mod tests { let far = Artboard { id: 9, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() }; d.artboards.push(far); let guide = |pos: f64, artboard| Guide { artboard, ..Guide::new(false, pos) }; - d.guides = vec![guide(50.0, None), guide(1e17, None), guide(f64::INFINITY, None), guide(20.0, Some(9))]; + d.saved_guides = vec![guide(50.0, None), guide(1e17, None), guide(f64::INFINITY, None), guide(20.0, Some(9))]; d.ruler_origin = Point::new(1e17, 0.0); + // The same guides as nodes, as a document has them once read. + let mut nodes = d.clone(); + assert_eq!(nodes.guides_into_nodes(), 4); let gone = d.drop_beyond_canvas(); assert_eq!(gone, BeyondCanvas { objects: 0, artboards: 1, guides: 3, ruler_origin: true }); assert_eq!(d.artboards.len(), 1); - assert_eq!(d.guides, [guide(50.0, None)], "the far artboard's own guide goes with it"); + assert_eq!(d.saved_guides, [guide(50.0, None)], "the far artboard's own guide goes with it"); + assert_eq!(nodes.drop_beyond_canvas(), gone); + let left: Vec<(bool, f64, Option)> = nodes.ruler_guides().iter().map(|g| (g.vertical, g.pos, g.artboard)).collect(); + assert_eq!(left, [(false, 50.0, None)]); assert_eq!(d.ruler_origin, Point::ZERO); assert_eq!( gone.note().unwrap(), @@ -310,7 +344,8 @@ mod tests { let probe = Document::new(1.0, 1.0); let sub = sublayer(&probe, 40, vec![rect(41, 1e17), rect(42, 0.0)], true); let mut d = doc(vec![rect(10, 0.0), rect(11, 1e17), sub]); - d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }]; + d.saved_guides = + vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }]; d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into(); d.assets = vec![ crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)], format_3d: None }, @@ -320,7 +355,7 @@ mod tests { d.variables.bindings.insert(NodeId(10), ["w".to_string()].into()); let gone = d.drop_beyond_canvas(); assert_eq!((gone.objects, gone.guides), (2, 1), "the far rectangle, the clipping sublayer and its layer guide"); - assert_eq!(d.guides.len(), 1, "the guide of a layer that stays, stays"); + assert_eq!(d.saved_guides.len(), 1, "the guide of a layer that stays, stays"); assert_eq!(d.saved_selections[0].objects, [NodeId(10)]); assert_eq!(d.assets.iter().map(|a| (a.id, a.nodes.clone())).collect::>(), [(2, vec![NodeId(10)])]); assert_eq!(d.variables.bindings.keys().copied().collect::>(), [NodeId(10)]); diff --git a/crates/doc/src/depth.rs b/crates/doc/src/depth.rs index 5abba9c63..63b7e0ce7 100644 --- a/crates/doc/src/depth.rs +++ b/crates/doc/src/depth.rs @@ -132,7 +132,7 @@ fn limit(nodes: &mut Vec>, depth: usize) -> bool { out.push(a); continue; } - let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); + let clip = n.clips(); let (visible, locked) = (n.visible, n.locked); let mut held = n.children_mut().map(std::mem::take).unwrap_or_default(); if clip && !held.is_empty() { @@ -148,6 +148,13 @@ fn limit(nodes: &mut Vec>, depth: usize) -> bool { // What it held takes its place, and is looked at in its turn. left.extend(held.into_iter().rev()); } + // The ruler guides of a sublayer merged into its parent layer go above the parent's art, with + // the parent's own. + if out.iter().any(|c| c.is_ruler_guide()) { + let (guides, art): (Vec<_>, Vec<_>) = out.into_iter().partition(|c| c.is_ruler_guide()); + out = art; + out.extend(guides); + } *nodes = out; true } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index fecdc9bac..708d0aee0 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -10,7 +10,7 @@ use super::{ impl Document { /// Is everything but the layers the same in `other`, as the next version of the document after - /// an edit of its art? The id counter and the id index are left out, and art outside the layers + /// an edit of its art? The id counter and the caches are left out, and art outside the layers /// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it /// doesn't change: quick for every frame of an animation. pub fn same_outside_layers(&self, other: &Self) -> bool { @@ -38,7 +38,7 @@ impl Document { text_threads, symbols, graph_designs, - guides, + saved_guides, views, saved_selections, variables, @@ -77,6 +77,7 @@ impl Document { opened_artboard: _, units_from_file: _, index: _, + guide_cache: _, } = self; *version == other.version && *title == other.title @@ -98,7 +99,7 @@ impl Document { && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) && graph_designs.len() == other.graph_designs.len() && graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) - && *guides == other.guides + && *saved_guides == other.saved_guides && *views == other.views && *saved_selections == other.saved_selections && *variables == other.variables @@ -221,7 +222,7 @@ impl Document { text_threads: vec![], symbols: vec![], graph_designs: vec![], - guides: vec![], + saved_guides: vec![], views: vec![], saved_selections: Default::default(), variables: Default::default(), @@ -260,6 +261,7 @@ impl Document { opened_artboard: None, units_from_file: false, index: Default::default(), + guide_cache: Default::default(), }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); @@ -501,11 +503,13 @@ impl Document { } } - /// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top). + /// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top). In a layer, art goes + /// below the layer's ruler guides and a ruler guide among them ([`Node::insert_index`]). pub fn insert(&mut self, parent: Option, index: usize, node: Node) -> Result { let id = node.id; + self.check_ruler_guide_place(&node, parent)?; let ch = self.children_mut(parent)?; - let i = index.min(ch.len()); + let i = Node::insert_index(ch, index, &node); ch.insert(i, Arc::new(node)); Ok(id) } @@ -522,20 +526,33 @@ impl Document { Ok(ch.remove(idx)) } - /// Move a node to `parent` at `index` (index measured after removal). + /// Move a node to `parent` at `index` (index measured after removal), clamped as + /// [`Document::insert`] clamps it. pub fn move_node(&mut self, id: NodeId, parent: Option, index: usize) -> Result<(), DocError> { if let Some(p) = parent && self.ancestry(p).is_some_and(|a| a.contains(&id)) { return Err(DocError::Invalid("cannot move an object into itself".into())); } + if let Some(n) = self.node(id) { + self.check_ruler_guide_place(n, parent)?; + } let n = self.remove(id)?; let ch = self.children_mut(parent)?; - let i = index.min(ch.len()); + let i = Node::insert_index(ch, index, &n); ch.insert(i, n); Ok(()) } + /// An error when `n` is a ruler guide and `parent` is not a layer or sublayer: a ruler guide is + /// always a direct child of one ([`NodeKind::RulerGuide`]). + fn check_ruler_guide_place(&self, n: &Node, parent: Option) -> Result<(), DocError> { + if n.is_ruler_guide() && !parent.and_then(|p| self.node(p)).is_some_and(Node::is_layer) { + return Err(DocError::Invalid("a ruler guide goes only directly in a layer or sublayer".into())); + } + Ok(()) + } + /// Position of `id` within its parent: (parent, index, sibling count). pub fn position(&self, id: NodeId) -> Option<(Option, usize, usize)> { let parent = self.parent_of(id); @@ -739,7 +756,7 @@ impl Document { for c in n.children().into_iter().flatten() { if c.is_layer() { objects(c, out); - } else { + } else if !c.is_ruler_guide() { out.insert(c.id); } } @@ -757,7 +774,8 @@ impl Document { } /// The object of a layer or sublayer that holds node `id`: `id` itself when it is one, else - /// the group or other object around it right inside a layer. None for a layer or sublayer. + /// the group or other object around it right inside a layer. None for a layer, a sublayer or a + /// ruler guide. pub fn layer_object(&self, id: NodeId) -> Option<&Node> { self.layer_object_at(&self.index_path(id)?) } @@ -768,6 +786,9 @@ impl Document { let mut nodes = &self.layers; for &i in path { let n = nodes.get(i)?; + if n.is_ruler_guide() { + return None; + } if !n.is_layer() { return Some(n.as_ref()); } diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index ebb2fc44e..0f1ec463c 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -56,9 +56,12 @@ impl Document { if self.saved_selections.is_empty() { return; } + // A saved selection names objects; ruler guides are not among them. let mut ids = std::collections::HashSet::new(); self.walk(|n| { - ids.insert(n.id); + if !n.is_ruler_guide() { + ids.insert(n.id); + } }); let mut kept: Vec = vec![]; for s in std::mem::take(&mut self.saved_selections) { @@ -101,12 +104,13 @@ impl Document { /// Leave opacity-mask editing and symbol editing: drop the temporary editing layer, a working /// copy of art the mask or the symbol already holds (the engine syncs it after every edit). The /// layer is removed wherever it is, also as a sublayer of another layer; an id that names - /// anything other than a layer is left alone. + /// anything other than a layer is left alone. Ruler guides on the layer stay in the document + /// ([`Self::remove_edit_layer`]). pub fn drop_edit_modes(&mut self) { let layers = [self.mask_edit.take().map(|me| me.layer), self.symbol_edit.take().map(|se| se.layer)]; for id in layers.into_iter().flatten() { if self.node(id).is_some_and(Node::is_layer) { - let _ = self.remove(id); + let _ = self.remove_edit_layer(id); } } } diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index 510de3a4d..ca6b5ed88 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -1,6 +1,8 @@ use super::document_ext::free_id; use super::guides::Guide; +use super::limits::MAX_NEXT_ID; use super::{Affine, Arc, Document, Node, NodeId, NodeKind, PatternDef, Point, Rect, Selection, SymbolBoxes, Vec2, artboard_holds}; +use std::collections::{BTreeMap, HashSet}; impl Document { /// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol @@ -151,7 +153,7 @@ impl Document { rest.iter().any(|m| has(m, s, inside, true)) } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect, exact)), - NodeKind::Path { guide: true, .. } => false, + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => false, _ => art_box_meets(c, s, rect, exact), } } @@ -171,6 +173,195 @@ impl Document { art } + /// Put the ruler guide nodes a file holds where ruler guides go: directly in a layer or + /// sublayer, above its art. A ruler guide below art in a layer moves up to the layer's other + /// guides, keeping their order. A ruler guide anywhere else (at the top level, in a group or + /// another object, in opacity-mask art, in a symbol, a graph design or a pattern) is removed. + /// Returns how many were removed. + pub fn tidy_ruler_guides(&mut self) -> usize { + let mut gone = self.layers.len(); + self.layers.retain(|l| !l.is_ruler_guide()); + gone -= self.layers.len(); + for l in &mut self.layers { + if layer_untidy(l) { + gone += tidy_layer(Arc::make_mut(l)); + } + } + let others = self + .symbols + .iter_mut() + .map(|s| &mut s.art) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in others { + if root.is_ruler_guide() { + *root = Arc::new(Node::group(root.id, vec![])); + gone += 1; + } else if any_guide(root) { + gone += drop_guides_below(Arc::make_mut(root)); + } + } + gone + } + + /// Make the saved ruler guides (read from a file: [`Document::guides_to_saved`]'s list) into + /// [`NodeKind::RulerGuide`] nodes, each at the top of its layer, in list order. A guide on a + /// layer or sublayer the document has goes on it; one on no layer, or on a layer that is gone, + /// goes on the top visible, unlocked layer ([`Self::default_layer`]), or on a new "Layer 1" + /// when there is none. A guide keeps its saved id when no other node, slice or guide has it, + /// and its name, lock and visibility. The saved list is emptied, so running this again does + /// nothing. Returns how many guides became nodes. + pub fn guides_into_nodes(&mut self) -> usize { + let saved = std::mem::take(&mut self.saved_guides); + if saved.is_empty() { + return 0; + } + let mut used = HashSet::new(); + self.walk(|n| { + used.insert(n.id); + }); + used.extend(self.slices.iter().map(|s| s.id)); + used.extend(self.assets.iter().map(|a| NodeId(a.id))); + let kept: Vec> = saved.iter().map(|g| g.id.filter(|id| id.0 > 0 && id.0 < MAX_NEXT_ID && used.insert(*id))).collect(); + if let Some(max) = kept.iter().flatten().map(|id| id.0).max() { + self.raise_next_id(max.saturating_add(1)); + } + let mut home = None; + // The guides of each layer, in list order. + let mut by_layer: BTreeMap>> = BTreeMap::new(); + let mut order = vec![]; + for (g, id) in saved.iter().zip(kept) { + let layer = match g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) { + Some(l) => l, + None => match home { + Some(h) => h, + None => { + let h = self.default_layer().unwrap_or_else(|| self.add_layer(None)); + home = Some(h); + h + } + }, + }; + let id = id.unwrap_or_else(|| self.alloc_id()); + let mut n = Node::new(id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }); + n.name = g.name.clone(); + n.locked = g.locked; + n.visible = !g.hidden; + if !by_layer.contains_key(&layer) { + order.push(layer); + } + by_layer.entry(layer).or_default().push(Arc::new(n)); + } + for layer in order { + let guides = by_layer.remove(&layer).unwrap_or_default(); + if let Ok(ch) = self.children_mut(Some(layer)) { + ch.extend(guides); + } + } + saved.len() + } + + /// Take the ruler guide nodes out of the layers and put them in the saved list, by ascending + /// id, each naming its layer, after any entries not yet made into nodes: the form a file keeps + /// them in, readable by versions from before ruler guides were nodes. For saving a copy of + /// the document; [`Self::guides_into_nodes`] reverses it. + pub fn guides_to_saved(&mut self) { + let guides = self.ruler_guides(); + if guides.is_empty() { + return; + } + for l in &mut self.layers { + if l.holds_ruler_guides() { + Arc::make_mut(l).strip_ruler_guides(); + } + } + self.saved_guides.extend(guides); + } + + /// Every ruler guide, by ascending id, with the layer or sublayer it is on, its id, name and + /// its own lock and visibility. A guide's place in this list is its index in the guide + /// commands. + pub fn ruler_guides(&self) -> Vec { + if let Some(listed) = self.guide_cache.get(&self.layers) { + return listed.as_ref().clone(); + } + fn visit(layer: &Node, out: &mut Vec) { + for c in layer.children().into_iter().flatten() { + if let Some(g) = guide_of(c, layer.id) { + out.push(g); + } else if c.is_layer() { + visit(c, out); + } + } + } + let mut out = vec![]; + for l in &self.layers { + visit(l, &mut out); + } + out.sort_by_key(|g| g.id); + self.guide_cache.set(&self.layers, &out); + out + } + + /// Ruler guide `id`, as [`Self::ruler_guides`] lists it. None when `id` is not a ruler guide + /// on a layer. + pub fn ruler_guide(&self, id: NodeId) -> Option { + let layer = self.parent_of(id)?; + guide_of(self.node(id)?, layer) + } + + /// Change ruler guide `id` with `f` (its line, its artboard; the layer, id, name, lock and + /// visibility fields are not read back). Returns false when `id` is not a ruler guide. + pub fn edit_ruler_guide(&mut self, id: NodeId, f: impl FnOnce(&mut Guide)) -> bool { + let Some(mut g) = self.ruler_guide(id) else { return false }; + f(&mut g); + let Some(n) = self.node_mut(id) else { return false }; + n.kind = NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }; + true + } + + /// Add ruler guide `g` as a node with a new id at the top of its layer: `g.layer` when the + /// document has that layer or sublayer, else the top visible, unlocked layer + /// ([`Self::default_layer`]), or a new "Layer 1" when there is none. Its name, lock and + /// visibility come from `g`. Returns the new id. + pub fn add_ruler_guide(&mut self, g: &Guide) -> NodeId { + let layer = match g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) { + Some(l) => l, + None => self.default_layer().unwrap_or_else(|| self.add_layer(None)), + }; + let id = self.alloc_id(); + let mut n = Node::new(id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }); + n.name = g.name.clone(); + n.locked = g.locked; + n.visible = !g.hidden; + if let Ok(ch) = self.children_mut(Some(layer)) { + ch.push(Arc::new(n)); + } + id + } + + /// Remove node `id`, the temporary layer of opacity-mask, symbol or pattern editing, keeping the + /// ruler guides on it and on its sublayers: they go to the top of the top visible, unlocked + /// layer left ([`Self::default_layer`]), or of a new "Layer 1" when no layer is left, where a + /// saved guide whose layer is gone opens. Returns the removed node, without the guides. + pub fn remove_edit_layer(&mut self, id: NodeId) -> Result, crate::DocError> { + let mut layer = self.remove(id)?; + if !layer.holds_ruler_guides() { + return Ok(layer); + } + let mut guides = vec![]; + layer.walk(&mut |c| { + if c.is_ruler_guide() { + guides.push(Arc::new(c.clone())); + } + }); + Arc::make_mut(&mut layer).strip_ruler_guides(); + let home = self.default_layer().unwrap_or_else(|| self.add_layer(None)); + self.children_mut(Some(home))?.extend(guides); + Ok(layer) + } + /// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its /// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose /// artboard is gone. @@ -179,79 +370,39 @@ impl Document { Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }) } + /// The ruler guide node `g` names, if this document has it. + fn guide_node(&self, g: &Guide) -> Option { + g.id.filter(|id| self.node(*id).is_some_and(Node::is_ruler_guide)) + } + /// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has. fn guide_layer(&self, g: &Guide) -> Option { g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) } - /// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is - /// visible? A guide whose layer is gone shows as one on no layer. + /// Is ruler guide `g` shown: its node, and the layers it is on, visible? A guide that is not a + /// node of this document shows unless hidden itself or on a hidden layer. pub fn guide_shown(&self, g: &Guide) -> bool { - self.guide_layer(g).is_none_or(|l| self.is_visible(l)) - } - - /// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked? - pub fn guide_editable(&self, g: &Guide) -> bool { - self.guide_layer(g).is_none_or(|l| self.is_editable(l)) - } - - /// Put the ruler guides on no layer this document has (from files saved before guides had - /// layers, or of layers merged into `layer`) on layer `layer`. Returns how many. - pub fn rehome_guides(&mut self, layer: NodeId) -> usize { - let lost: Vec = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect(); - let mut n = 0; - for (g, lost) in self.guides.iter_mut().zip(lost) { - if lost { - g.layer = Some(layer); - n += 1; - } + match self.guide_node(g) { + Some(id) => self.is_visible(id), + None => !g.hidden && self.guide_layer(g).is_none_or(|l| self.is_visible(l)), } - n } - /// Put the ruler guides on no layer (from files saved before guides had layers), or on one that - /// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many. - pub fn adopt_guides(&mut self) -> usize { - self.default_layer().map_or(0, |home| self.rehome_guides(home)) + /// Can ruler guide `g` be picked, moved and deleted: shown, and neither it nor its layers + /// locked? + pub fn guide_editable(&self, g: &Guide) -> bool { + match self.guide_node(g) { + Some(id) => self.is_editable(id), + None => !g.hidden && !g.locked && self.guide_layer(g).is_none_or(|l| self.is_editable(l)), + } } - /// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in - /// step. Guides on no layer stay. Returns how many went. - pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize { - let gone: Vec = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect(); - self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false)) - } - - /// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made - /// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies - /// the objects on it. Returns how many. - pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize { - let layers = |n: &Node| { - let mut out = vec![]; - n.walk(&mut |c| { - if c.is_layer() { - out.push(c.id); - } - }); - out - }; - let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect(); - let copies: Vec = self - .guides - .iter() - .filter_map(|g| { - let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1; - Some(Guide { layer: Some(to), ..g.clone() }) - }) - .collect(); - let n = copies.len(); - self.guides.extend(copies); - n - } - - /// The ruler guides on layer `layer` (not its sublayers'), by index. - pub fn guides_on(&self, layer: NodeId) -> impl Iterator + '_ { - self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer)) + /// The ruler guides directly on layer `layer` (not its sublayers'), by ascending id. + pub fn guides_on(&self, layer: NodeId) -> Vec { + let mut out: Vec = self.children(Some(layer)).into_iter().flatten().filter_map(|c| guide_of(c, layer)).collect(); + out.sort_by_key(|g| g.id); + out } /// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)? @@ -260,45 +411,86 @@ impl Document { self.guide_span(g).is_none_or(|(a, b)| along >= a - tol && along <= b + tol) } - /// Keep the ruler guides `keep` accepts (by index), the selected ones left keeping their - /// place among the rest (the selected art stays selected). Returns how many went. - pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(usize, &Guide) -> bool) -> usize { - let kept: Vec = self.guides.iter().enumerate().map(|(i, g)| keep(i, g)).collect(); - // Where each kept guide ends up. - let (mut to, mut n) = (Vec::with_capacity(kept.len()), 0); - for k in &kept { - to.push(k.then_some(n)); - n += usize::from(*k); + /// Keep the ruler guides `keep` accepts, deleting the others; `sel` forgets the deleted ones + /// (the selected art stays selected). Returns how many went. + pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(&Guide) -> bool) -> usize { + let gone: HashSet = self.ruler_guides().into_iter().filter(|g| !keep(g)).filter_map(|g| g.id).collect(); + if gone.is_empty() { + return 0; + } + fn drop(layer: &mut Arc, gone: &HashSet) { + fn holds(n: &Node, gone: &HashSet) -> bool { + n.is_layer() && n.children().is_some_and(|ch| ch.iter().any(|c| gone.contains(&c.id) || holds(c, gone))) + } + if !holds(layer, gone) { + return; + } + let Some(ch) = Arc::make_mut(layer).children_mut() else { return }; + ch.retain(|c| !(c.is_ruler_guide() && gone.contains(&c.id))); + for c in ch.iter_mut().filter(|c| c.is_layer()) { + drop(c, gone); + } + } + for l in &mut self.layers { + drop(l, &gone); + } + sel.guides.retain(|id| !gone.contains(id)); + if sel.key_guide.is_some_and(|k| gone.contains(&k)) { + sel.key_guide = None; + } + gone.len() + } + + /// Change the line of every ruler guide on artboard `id` with `f` (whether it is vertical, its + /// position). + fn edit_artboard_guides(&mut self, id: u32, f: &mut impl FnMut(bool, &mut f64)) { + fn visit(layer: &mut Arc, id: u32, f: &mut impl FnMut(bool, &mut f64)) { + fn holds(n: &Node, id: u32) -> bool { + n.is_layer() + && n.children().is_some_and(|ch| { + ch.iter().any(|c| matches!(c.kind, NodeKind::RulerGuide { artboard: Some(a), .. } if a == id) || holds(c, id)) + }) + } + if !holds(layer, id) { + return; + } + let Some(ch) = Arc::make_mut(layer).children_mut() else { return }; + for c in ch.iter_mut() { + if matches!(c.kind, NodeKind::RulerGuide { artboard: Some(a), .. } if a == id) { + if let NodeKind::RulerGuide { vertical, pos, .. } = &mut Arc::make_mut(c).kind { + f(*vertical, pos); + } + } else if c.is_layer() { + visit(c, id, f); + } + } + } + for l in &mut self.layers { + visit(l, id, f); } - sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect(); - sel.key_guide = sel.key_guide.and_then(|i| to.get(i).copied().flatten()); - let before = self.guides.len(); - let mut flags = kept.into_iter(); - self.guides.retain(|_| flags.next().unwrap_or(true)); - before - self.guides.len() } /// Move the guides of artboard `id` by `d` (along with their artboard). pub fn move_artboard_guides(&mut self, id: u32, d: Vec2) { - for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { - *g = g.moved(d); - } + self.edit_artboard_guides(id, &mut |vertical, pos| *pos += if vertical { d.x } else { d.y }); } /// Map the guides of artboard `id` through `xf`, an axis-aligned scale and move (Scale Artwork /// with Artboard). pub fn map_artboard_guides(&mut self, id: u32, xf: Affine) { - for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { - g.pos = if g.vertical { (xf * Point::new(g.pos, 0.0)).x } else { (xf * Point::new(0.0, g.pos)).y }; - } + self.edit_artboard_guides(id, &mut |vertical, pos| { + *pos = if vertical { (xf * Point::new(*pos, 0.0)).x } else { (xf * Point::new(0.0, *pos)).y }; + }); } /// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their - /// artboard). + /// artboard), each on its guide's layer with a new id. pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) { let copies: Vec = - self.guides.iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect(); - self.guides.extend(copies); + self.ruler_guides().into_iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect(); + for g in &copies { + self.add_ruler_guide(g); + } } /// An id for a new artboard: one no artboard has, above the ids given out before and the ids of @@ -367,3 +559,122 @@ impl Document { n } } + +/// Does `n`, or anything below it (opacity-mask art included), hold a ruler guide? +fn any_guide(n: &Node) -> bool { + let mut any = false; + crate::swatches::walk_with_masks(n, &mut |m| any |= m.is_ruler_guide()); + any +} + +/// Remove every ruler guide below `n`, in its members and its opacity-mask art (`n` itself +/// stays). Mask art that is a ruler guide becomes an empty group. Returns how many went. +fn drop_guides_below(n: &mut Node) -> usize { + let mut gone = drop_mask_guides(n); + if let Some(ch) = n.children_mut() { + let before = ch.len(); + ch.retain(|c| !c.is_ruler_guide()); + gone += before - ch.len(); + for c in ch.iter_mut().filter(|c| any_guide(c)) { + gone += drop_guides_below(Arc::make_mut(c)); + } + } + gone +} + +/// Remove every ruler guide in the opacity-mask art of `n`. Mask art that is a ruler guide +/// becomes an empty group. Returns how many went. +fn drop_mask_guides(n: &mut Node) -> usize { + let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) else { return 0 }; + if mask.art.is_ruler_guide() { + mask.art = Arc::new(Node::group(mask.art.id, vec![])); + 1 + } else if any_guide(&mask.art) { + drop_guides_below(Arc::make_mut(&mut mask.art)) + } else { + 0 + } +} + +/// Does layer `l` hold a ruler guide out of place: below art, in an object or in mask art, or so +/// in one of its sublayers? +fn layer_untidy(l: &Node) -> bool { + let Some(ch) = l.children() else { return false }; + let start = Node::guide_run_start(ch); + l.extras().mask.as_deref().is_some_and(|m| any_guide(&m.art)) + || ch.iter().take(start).any(|c| c.is_ruler_guide() || if c.is_layer() { layer_untidy(c) } else { any_guide(c) }) +} + +/// [`Document::tidy_ruler_guides`] for layer `l`. Returns how many guides were removed. +fn tidy_layer(l: &mut Node) -> usize { + let mut gone = drop_mask_guides(l); + let Some(ch) = l.children_mut() else { return gone }; + for c in ch.iter_mut() { + if c.is_layer() { + if layer_untidy(c) { + gone += tidy_layer(Arc::make_mut(c)); + } + } else if !c.is_ruler_guide() && any_guide(c) { + gone += drop_guides_below(Arc::make_mut(c)); + } + } + if ch.iter().take(Node::guide_run_start(ch)).any(|c| c.is_ruler_guide()) { + let (guides, art): (Vec<_>, Vec<_>) = std::mem::take(ch).into_iter().partition(|c| c.is_ruler_guide()); + *ch = art; + ch.extend(guides); + } + gone +} + +/// Node `n`, on layer `layer`, as a [`Guide`] value, if it is a ruler guide. +fn guide_of(n: &Node, layer: NodeId) -> Option { + let NodeKind::RulerGuide { vertical, pos, artboard } = n.kind else { return None }; + Some(Guide { vertical, pos, artboard, layer: Some(layer), id: Some(n.id), name: n.name.clone(), locked: n.locked, hidden: !n.visible }) +} + +/// The list [`Document::ruler_guides`] last made, with weak pointers to the top-level layers it was +/// made from. While a weak pointer to a layer's allocation is held, nothing else can be at that +/// address, and the layer can't be changed in place (`Arc::get_mut` refuses it and `Arc::make_mut` +/// moves it), so any edit inside a layer, and any layer added, removed or replaced, gives a list of +/// layers the cache does not match. Clones of a document share the cached list. +#[derive(Default)] +pub(crate) struct GuideCache(std::sync::RwLock>); + +/// The top-level layers a guide list was made from, and the list. +type Listed = (Vec>, Arc>); + +impl GuideCache { + /// The cached list, when it was made from `layers`. + fn get(&self, layers: &[Arc]) -> Option>> { + let g = self.0.read().ok()?; + let (from, listed) = g.as_ref()?; + let same = from.len() == layers.len() && from.iter().zip(layers).all(|(w, l)| std::ptr::eq(w.as_ptr(), Arc::as_ptr(l))); + same.then(|| listed.clone()) + } + + /// Keep `listed`, made from `layers`. + fn set(&self, layers: &[Arc], listed: &[Guide]) { + if let Ok(mut g) = self.0.write() { + *g = Some((layers.iter().map(Arc::downgrade).collect(), Arc::new(listed.to_vec()))); + } + } +} + +impl Clone for GuideCache { + fn clone(&self) -> Self { + GuideCache(std::sync::RwLock::new(self.0.read().ok().and_then(|g| g.clone()))) + } +} + +/// A cache, not document content: two documents are equal whatever their caches hold. +impl PartialEq for GuideCache { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for GuideCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("GuideCache") + } +} diff --git a/crates/doc/src/guides.rs b/crates/doc/src/guides.rs index 08f828391..a3fb9969e 100644 --- a/crates/doc/src/guides.rs +++ b/crates/doc/src/guides.rs @@ -1,5 +1,8 @@ use super::{Deserialize, NodeId, Serialize, Vec2}; +/// A ruler guide as a value: an entry of the saved `guides` list, or a ruler guide node of a +/// document as [`crate::Document::ruler_guides`] lists it. In a document, ruler guides are +/// [`crate::NodeKind::RulerGuide`] nodes on their layers. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Guide { /// true = vertical guide at `pos` (x), false = horizontal at `pos` (y). @@ -14,12 +17,25 @@ pub struct Guide { /// from before guides had layers, and from files that don't say), always there. #[serde(default, skip_serializing_if = "Option::is_none")] pub layer: Option, + /// The id of its node. None in an entry written before ruler guides were nodes: it gets a new + /// id when the document is read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// The name it shows in the Layers panel, if it was given one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Locked on its own (its layer's lock is separate). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub locked: bool, + /// Hidden on its own (its layer's visibility is separate). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub hidden: bool, } impl Guide { /// A canvas guide. pub fn new(vertical: bool, pos: f64) -> Self { - Self { vertical, pos, artboard: None, layer: None } + Self { vertical, pos, artboard: None, layer: None, id: None, name: None, locked: false, hidden: false } } /// Moved by `d` (a vertical guide across, a horizontal one down). diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 3412a7ff4..94fbb5ff5 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -588,7 +588,8 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols if !c.visible || editable_only && (c.locked || c.is_template()) { return false; } - if opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) { + // A ruler guide is picked by the guide commands, never by an object hit test. + if c.is_ruler_guide() || opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) { return false; } // (An envelope's content sits where it was, not where the envelope draws it.) @@ -1256,7 +1257,7 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh // marquee touches none of it, as clicks don't (#5309). Some(s) if !doc.is_editable(s.id) => vec![], Some(s) => match s.children() { - Some(c) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), + Some(c) => c.iter().enumerate().filter(|(i, c)| !c.is_ruler_guide() && !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), // A single object isolated: only it. None => doc .parent_of(s.id) @@ -1285,7 +1286,7 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh if c.visible && !c.locked && !c.is_template() { out.extend(layer_objects(c, r)); } - } else { + } else if !c.is_ruler_guide() { out.push((c, r)); } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1f555b74e..41a684abd 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -207,8 +207,12 @@ pub struct Document { /// Object › Graph › Design…: art graphs can draw as markers. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub graph_designs: Vec, - #[serde(default)] - pub guides: Vec, + /// The saved form of the ruler guides: read from a file, then made into + /// [`NodeKind::RulerGuide`] nodes on their layers ([`Document::guides_into_nodes`]), and + /// written from those nodes when saving ([`Document::guides_to_saved`]). Empty in between. + /// Each entry read counts as an object against the read's node budget. + #[serde(default, rename = "guides", deserialize_with = "crate::node::counted_list")] + saved_guides: Vec, /// View → New View… (at most [`SavedView::MAX`]); saved with the document. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub views: Vec, @@ -351,6 +355,9 @@ pub struct Document { /// Where each node is in the tree, for lookups by id ([`id_index`]): a cache, never saved. #[serde(skip)] index: id_index::IdIndex, + /// The ruler guides as [`Document::ruler_guides`] lists them: a cache, never saved. + #[serde(skip)] + guide_cache: document_guides::GuideCache, } fn ppi72() -> f64 { diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 66d20f27b..1cc8ba086 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -140,6 +140,8 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node { } // Its art is plain, so it warps at this depth. NodeKind::PlacedDocument(_) => return map_node(&expanded_group_hooks(n, w.hooks), w), + // A ruler guide has no shape to warp. + NodeKind::RulerGuide { .. } => {} } out } diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index e431fc9d1..e2d27650b 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -88,7 +88,7 @@ impl Document { } let mut n = 0; n += vec_bytes(&self.artboards) + self.artboards.iter().map(|a| a.name.capacity()).sum::(); - n += vec_bytes(&self.guides) + vec_bytes(&self.views) + vec_bytes(&self.slices) + vec_bytes(&self.assets); + n += vec_bytes(&self.saved_guides) + vec_bytes(&self.views) + vec_bytes(&self.slices) + vec_bytes(&self.assets); n += vec_bytes(&self.text_threads) + self.text_threads.iter().map(vec_bytes).sum::(); n += self.images.iter().map(|(k, b)| 4 * size_of::() + k.capacity() + b.mime.capacity() + size_of_val(b)).sum::(); n += map_bytes(&self.export_settings) + map_bytes(&self.extra) + self.print_setup.as_ref().map_or(0, json_bytes); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 774e82439..eeec6ab16 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -33,6 +33,7 @@ pub use live_shape::{LiveShape, Scaling}; pub use opacity::{Knockout, OpacityMask, whiten}; pub use ops::{flattens, keeps_gradient_fit}; use wire::NodeWire; +pub(crate) use wire::counted_list; pub use wire::{MAX_READ_NODES, with_node_budget}; fn yes() -> bool { diff --git a/crates/doc/src/node/clip.rs b/crates/doc/src/node/clip.rs index 208bdda7f..cb66ba114 100644 --- a/crates/doc/src/node/clip.rs +++ b/crates/doc/src/node/clip.rs @@ -19,7 +19,7 @@ impl Node { fn push_clip_shapes(&self, text: Outliner, out: &mut Vec<(BezPath, FillRule)>) { match &self.kind { - NodeKind::Path { guide: true, .. } | NodeKind::SymbolInstance { .. } => {} + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } | NodeKind::SymbolInstance { .. } => {} NodeKind::Path { path, rule, .. } => out.push((path.to_bezpath(), *rule)), NodeKind::Compound { children, rule } => { let mut bp = BezPath::new(); diff --git a/crates/doc/src/node/kind.rs b/crates/doc/src/node/kind.rs index 22470cbf3..7f9e21a0f 100644 --- a/crates/doc/src/node/kind.rs +++ b/crates/doc/src/node/kind.rs @@ -122,6 +122,17 @@ pub enum NodeKind { Intertwine { children: Vec>, }, + /// A ruler guide: a straight line across the canvas, or across one artboard, that draws on + /// screen only. It is always a direct child of a layer or sublayer, above the layer's other + /// children, and has no bounds. + RulerGuide { + /// true: a vertical guide at x = `pos`; false: a horizontal one at y = `pos`. + vertical: bool, + pos: f64, + /// The [`crate::Artboard::id`] of the artboard it runs across; None: across the canvas. + #[serde(default, skip_serializing_if = "Option::is_none")] + artboard: Option, + }, } /// Reading a [`NodeKind`]. Derived, an internally tagged enum buffers each kind (with every @@ -215,6 +226,12 @@ mod kind_de { Intertwine { children: Vec>, }, + RulerGuide { + vertical: bool, + pos: f64, + #[serde(default)] + artboard: Option, + }, } impl<'de> Deserialize<'de> for NodeKind { diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index eb7e0d39b..415b2946e 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -80,6 +80,16 @@ impl Node { matches!(self.kind, NodeKind::Layer { .. }) } + /// A ruler guide ([`NodeKind::RulerGuide`]). + pub fn is_ruler_guide(&self) -> bool { + matches!(self.kind, NodeKind::RulerGuide { .. }) + } + + /// A guide of either kind: a ruler guide, or a path made into a guide. + pub fn is_guide(&self) -> bool { + matches!(self.kind, NodeKind::RulerGuide { .. } | NodeKind::Path { guide: true, .. }) + } + /// A template layer (Layer Options → Template). pub fn is_template(&self) -> bool { matches!(self.kind, NodeKind::Layer { template: true, .. }) @@ -91,7 +101,7 @@ impl Node { pub fn layer_art(&self, editable: bool) -> Vec { fn visit(n: &Node, editable: bool, out: &mut Vec) { for c in n.children().into_iter().flatten() { - if editable && (!c.visible || c.locked || c.is_template()) { + if c.is_ruler_guide() || (editable && (!c.visible || c.locked || c.is_template())) { continue; } if c.is_layer() { @@ -142,6 +152,7 @@ impl Node { NodeKind::Repeat(r) => r.kind.label(), NodeKind::PlacedDocument(_) => "Placed Document", NodeKind::Intertwine { .. } => "Intertwine", + NodeKind::RulerGuide { .. } => "Guide", } } @@ -310,6 +321,8 @@ impl Node { NodeKind::PlacedDocument(p) => Some(p.bounds()), // The members drawn over the others are drawn where they are. NodeKind::Intertwine { children } => union(children), + // A ruler guide runs across the canvas (or an artboard): it adds nothing to a box. + NodeKind::RulerGuide { .. } => None, } } @@ -522,6 +535,9 @@ impl Node { } NodeKind::Mesh(m) => m.transform(a), NodeKind::Repeat(r) => r.transform(a, *sc), + // A ruler guide stays where it is when the art around it (its layer) is transformed: + // the guide commands move it. + NodeKind::RulerGuide { .. } => {} } // The frames of the objects attached to a path move with them, and the distances along the // path follow the path as it is now (a live shape's path is regenerated). @@ -557,10 +573,10 @@ impl Node { } } - /// Count of nodes in this subtree. + /// Count of nodes in this subtree, ruler guides left out. pub fn count(&self) -> usize { let mut n = 0; - self.walk(&mut |_| n += 1); + self.walk(&mut |c| n += usize::from(!c.is_ruler_guide())); n } @@ -569,7 +585,7 @@ impl Node { pub fn count_with_masks(&self) -> usize { let mut n = 0; self.walk(&mut |c| { - n += 1; + n += usize::from(!c.is_ruler_guide()); if let Some(m) = &c.extras().mask { n += m.art.count_with_masks(); } @@ -688,7 +704,7 @@ impl Node { /// groups ([`Self::passes_knockout_through`]) contribute their children instead of themselves. pub fn knockout_elements(children: &[Arc]) -> Vec<&Arc> { fn push<'a>(children: &'a [Arc], out: &mut Vec<&'a Arc>) { - for c in children.iter().filter(|c| c.visible) { + for c in children.iter().filter(|c| c.visible && !c.is_ruler_guide()) { match c.children() { Some(ch) if c.passes_knockout_through() => push(ch, out), _ => out.push(c), @@ -701,8 +717,62 @@ impl Node { } /// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest. + /// A layer whose first child is a ruler guide holds no art, so a guide never clips it. pub fn clips(&self) -> bool { - matches!(self.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }) + match &self.kind { + NodeKind::Group { clip, .. } => *clip, + NodeKind::Layer { clip, children, .. } => *clip && children.first().is_none_or(|c| !c.is_ruler_guide()), + _ => false, + } + } + + /// Where the ruler guides at the end of `children` (a layer's) begin: the index past the last + /// child that is not one. Art goes below this index and ruler guides at or above it. + pub fn guide_run_start(children: &[Arc]) -> usize { + children.iter().rposition(|c| !c.is_ruler_guide()).map_or(0, |i| i + 1) + } + + /// Remove the ruler guides of this layer and of its sublayers. Other nodes hold none. + pub fn strip_ruler_guides(&mut self) { + if !self.holds_ruler_guides() { + return; + } + if let Some(ch) = self.children_mut() { + ch.retain(|c| !c.is_ruler_guide()); + for c in ch.iter_mut().filter(|c| c.holds_ruler_guides()) { + Arc::make_mut(c).strip_ruler_guides(); + } + } + } + + /// Call `f` on the position (along the axis it crosses) of each ruler guide of this layer and + /// of its sublayers, with whether the guide is vertical. Only layers that hold one are copied. + pub fn map_ruler_guide_positions(&mut self, f: &mut impl FnMut(bool, &mut f64)) { + if !self.holds_ruler_guides() { + return; + } + if let Some(ch) = self.children_mut() { + for c in ch.iter_mut().filter(|c| c.is_ruler_guide() || c.holds_ruler_guides()) { + let c = Arc::make_mut(c); + match &mut c.kind { + NodeKind::RulerGuide { vertical, pos, .. } => f(*vertical, pos), + _ => c.map_ruler_guide_positions(f), + } + } + } + } + + /// Whether this layer or one of its sublayers holds a ruler guide. + pub fn holds_ruler_guides(&self) -> bool { + self.is_layer() && self.children().is_some_and(|ch| ch.iter().any(|c| c.is_ruler_guide() || c.holds_ruler_guides())) + } + + /// The index that an insert into `children` at `index` uses for `node`: art stays below the + /// layer's ruler guides and a ruler guide goes among them. + pub fn insert_index(children: &[Arc], index: usize, node: &Node) -> usize { + let start = Self::guide_run_start(children); + let i = index.min(children.len()); + if node.is_ruler_guide() { i.max(start) } else { i.min(start) } } /// Make this group or layer clip its children by its first child, or stop it. diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index b54d3f4b8..2722e7bee 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -145,9 +145,10 @@ fn count_one() -> Result<(), E> { } /// A list read from a file (a member's Intertwine areas of tier 2 and up, an instance's symbol -/// overrides), each item counting against [`with_node_budget`] as an object does: a compressed -/// file of repeated empty items stays small while they take memory once read. -fn counted_list<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { +/// overrides, a document's saved ruler guides), each item counting against [`with_node_budget`] +/// as an object does: a compressed file of repeated empty items stays small while they take +/// memory once read. +pub(crate) fn counted_list<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { struct Items(std::marker::PhantomData); impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Items { type Value = Vec; diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 2a98df62c..d1333bef0 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -584,6 +584,8 @@ impl<'a> NestedCount<'a> { (NodeKind::Repeat(r), _) => r.in_all(self), (NodeKind::SymbolInstance { symbol, .. }, _) => Cost { instances: self.symbol(symbol), copies: 1 }, (NodeKind::Path { path, .. }, _) => Cost { instances: 0, copies: path.anchor_count().saturating_add(1) }, + // A ruler guide draws nothing, so a repeat makes no copies of it. + (NodeKind::RulerGuide { .. }, _) => Cost::default(), (_, Some(c)) => self.nodes(c).plus(Cost { instances: 0, copies: 1 }), _ => Cost { instances: 0, copies: 1 }, }; diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 643f81c36..c514aab99 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -39,16 +39,16 @@ pub struct Selection { /// clears them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub slices: Vec, - /// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. A new + /// Ruler guides selected with a selection tool, by node id ([`Document::ruler_guides`]). A new /// selection ([`Self::set`]) clears them; Shift-adding or -removing objects keeps them, so /// guides and objects can be selected together, in either order. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub guides: Vec, - /// A selected ruler guide that is the key object for Align instead of [`Self::key`] (an index - /// into [`Document::guides`], one of [`Self::guides`]): the objects line up on it. Never set - /// with `key`; [`Self::prune`] clears it when no object is selected to align to it. + pub guides: Vec, + /// A selected ruler guide that is the key object for Align instead of [`Self::key`] (one of + /// [`Self::guides`]): the objects line up on it. Never set with `key`; [`Self::prune`] clears + /// it when no object is selected to align to it. #[serde(default, skip_serializing_if = "Option::is_none")] - pub key_guide: Option, + pub key_guide: Option, /// The new art on a locked artboard that this selection keeps editable /// ([`Document::new_art`]). Undo, Redo and a failed batch restore it with the selection, and /// the engine then sets the document's from it. Editing state, never saved. @@ -139,10 +139,10 @@ impl Selection { } } } - /// Select ruler guides `indexes` (see [`Selection::guides`]) and nothing else. - pub fn set_guides(&mut self, indexes: impl IntoIterator) { + /// Select ruler guides `ids` (see [`Selection::guides`]) and nothing else. + pub fn set_guides(&mut self, ids: impl IntoIterator) { self.clear(); - for i in indexes { + for i in ids { if !self.guides.contains(&i) { self.guides.push(i); } @@ -150,7 +150,7 @@ impl Selection { } /// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a /// Shift-click on a guide). - pub fn toggle_guide(&mut self, i: usize) { + pub fn toggle_guide(&mut self, i: NodeId) { self.symbol_part = None; if let Some(k) = self.guides.iter().position(|g| *g == i) { self.guides.remove(k); @@ -201,7 +201,9 @@ impl Selection { pub fn prune(&mut self, doc: &Document) { // Every id this looks up, found in one walk of the tree. let ids = self.objects.iter().chain(self.anchors.keys()).chain(self.segments.keys()).copied().chain(self.key).chain(self.target); - let found = doc.index_paths(ids); + let mut found = doc.index_paths(ids); + // A ruler guide is selected in `guides` only, never as an object, an anchor or the key. + found.retain(|_, p| !doc.node_at(p).is_some_and(|n| n.is_ruler_guide())); self.objects.retain(|id| found.contains_key(id)); self.anchors.retain(|id, _| found.contains_key(id)); self.segments.retain(|id, refs| { @@ -209,8 +211,12 @@ impl Selection { refs.retain(|&(si, seg)| path.subpaths.get(si).is_some_and(|sp| seg < sp.segment_count())); !refs.is_empty() && self.objects.contains(id) && self.anchors.contains_key(id) }); - // Guides on a hidden or locked layer can't stay selected. - self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g))); + // Guides that are gone, or hidden or locked (themselves or by their layer), can't stay + // selected. + if !self.guides.is_empty() { + let editable = doc.editable(); + self.guides.retain(|id| doc.node(*id).is_some_and(|n| n.is_ruler_guide()) && editable(*id)); + } // The key object stays while another selected object lies outside it, or while ruler // guides are selected for it to line up (#2143). let keep = self.key.and_then(|k| found.get(&k)).and_then(|p| doc.node_at(p)).is_some_and(|n| { diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs index 7882fef73..d0d81faee 100644 --- a/crates/doc/src/selection/tests.rs +++ b/crates/doc/src/selection/tests.rs @@ -20,25 +20,30 @@ fn basic_ops() { fn guides_are_selected_on_their_own() { let mut s = Selection::default(); s.add(NodeId(1)); - s.set_guides([2, 0, 2]); - assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0])); + let (g0, g2) = (NodeId(10), NodeId(12)); + s.set_guides([g2, g0, g2]); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![g2, g0])); assert!(s.has_objects_or_guides() && s.is_empty()); // Shift-adding an object keeps them; a new selection doesn't. s.add(NodeId(1)); - assert_eq!(s.guides, vec![2, 0]); + assert_eq!(s.guides, vec![g2, g0]); s.set([NodeId(1)]); assert!(s.guides.is_empty()); // Toggled, a guide joins (or leaves) the selected objects. - s.toggle_guide(0); - assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0])); - s.toggle_guide(0); + s.toggle_guide(g0); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![g0])); + s.toggle_guide(g0); assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![])); - // Pruning drops guides the document no longer has. + // Pruning drops guides the document no longer has, ids of other objects, and guides that are + // locked or hidden themselves. let mut d = Document::new(100.0, 100.0); - d.guides.push(crate::Guide::new(true, 10.0)); - s.set_guides([0, 1]); + let layer = d.layers[0].id; + let kept = d.add_ruler_guide(&crate::Guide::new(true, 10.0)); + let locked = d.add_ruler_guide(&crate::Guide { locked: true, ..crate::Guide::new(true, 20.0) }); + let hidden = d.add_ruler_guide(&crate::Guide { hidden: true, ..crate::Guide::new(true, 30.0) }); + s.set_guides([kept, NodeId(9999), layer, locked, hidden]); s.prune(&d); - assert_eq!(s.guides, vec![0]); + assert_eq!(s.guides, vec![kept]); } /// A picked object of a dynamic symbol's instance stays picked while it and the groups and layers @@ -131,16 +136,26 @@ fn guides_show_and_lock_with_their_layer() { assert!(d.guide_shown(&crate::Guide::new(false, 5.0))); let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) }; assert!(d.guide_shown(&gone) && d.guide_editable(&gone)); - d.guides.push(g); - assert_eq!(d.guides_on(layer).count(), 1); - // Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer. + // As a node, a guide shows and locks with its layer and on its own. + let id = d.add_ruler_guide(&g); + let node = d.ruler_guide(id).unwrap(); + assert_eq!(d.guides_on(layer), std::slice::from_ref(&node)); + assert!(!d.guide_shown(&node) && !d.guide_editable(&node), "its layer is hidden"); d.node_mut(layer).unwrap().visible = true; d.node_mut(layer).unwrap().locked = false; - d.guides.push(crate::Guide::new(false, 5.0)); - d.guides.push(gone); - assert_eq!(d.adopt_guides(), 2); - assert!(d.guides.iter().all(|g| g.layer == Some(layer))); - assert_eq!(d.adopt_guides(), 0, "once"); + assert!(d.guide_shown(&node) && d.guide_editable(&node)); + d.node_mut(id).unwrap().locked = true; + let node = d.ruler_guide(id).unwrap(); + assert!(d.guide_shown(&node) && !d.guide_editable(&node), "locked itself"); + d.node_mut(id).unwrap().visible = false; + assert!(!d.guide_shown(&node), "hidden itself"); + // Saved guides on no layer, or a gone one, go on the top visible, unlocked layer. + d.saved_guides.push(crate::Guide::new(false, 5.0)); + d.saved_guides.push(gone); + assert_eq!(d.guides_into_nodes(), 2); + assert!(d.ruler_guides().iter().all(|g| g.layer == Some(layer))); + assert_eq!(d.ruler_guides().len(), 3); + assert_eq!(d.guides_into_nodes(), 0, "once"); // A guide saved before guides had layers reads as one on no layer. let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap(); assert_eq!(old.layer, None); @@ -267,7 +282,7 @@ fn random_selection(r: &mut u64, ids: &[NodeId]) -> Selection { s.key = s.objects.get(below(r, s.objects.len() as u64) as usize).copied(); s.target = ids.get(below(r, ids.len() as u64) as usize).copied().filter(|_| below(r, 2) == 0); s.slices = ids.iter().copied().take(below(r, 3) as usize).collect(); - s.guides = vec![1, 0]; + s.guides = vec![NodeId(1), NodeId(0)]; s } diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 995445f57..6949aa86f 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -492,7 +492,8 @@ impl NineSliceMap { /// [`Self::most_placed`] for a container placed by [`Self::container`]. fn most_container(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 { let own = self.most_mask(n, boxes, depth).saturating_add(1); - n.children().into_iter().flatten().fold(own, |sum, c| sum.saturating_add(self.most_at(c, boxes, depth + 1))) + // As [`Self::container`] places it: a layer's ruler guides left out. + n.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).fold(own, |sum, c| sum.saturating_add(self.most_at(c, boxes, depth + 1))) } /// [`Self::most_placed`] for the opacity mask of `n` as [`Self::place_mask`] places it: a @@ -560,7 +561,8 @@ impl NineSliceMap { let kids = out.children_mut().map(std::mem::take).unwrap_or_default(); out.transform(center, false); if let Some(children) = out.children_mut() { - *children = kids.iter().map(|c| Arc::new(self.place_at(c, boxes, depth + 1))).collect(); + // A ruler guide of a layer is not art: it is not placed. + *children = kids.iter().filter(|c| !c.is_ruler_guide()).map(|c| Arc::new(self.place_at(c, boxes, depth + 1))).collect(); } self.place_mask(n, &mut out, boxes, depth); out diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 21fe8d7ce..dcbcd8702 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -91,6 +91,268 @@ fn an_inch_ruler_steps_in_binary_fractions() { assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0)); } +/// Ruler guide nodes on a layer and a sublayer are not art: Select All, a layer's target circle, +/// the object count, the art's bounds, clicks, the marquee and a knockout layer's elements are +/// the same as without them, and transforming the layer leaves them where they are. +#[test] +fn ruler_guide_nodes_are_not_art() { + use crate::hit::{HitOptions, hit_test, marquee, objects_at}; + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(2))).unwrap(); + let s1 = d.alloc_id(); + d.insert(Some(sub), usize::MAX, Node::path(s1, shapes::rectangle(Rect::new(40.0, 0.0, 50.0, 10.0)), Appearance::default_art())).unwrap(); + let opt = HitOptions::default(); + let all = Rect::new(-100.0, -100.0, 700.0, 900.0); + let before = ( + d.selectable_art(), + d.layers[0].layer_art(false), + d.node_count(), + d.art_bounds(), + marquee(&d, all, None, false), + hit_test(&d, Point::new(5.0, 5.0), opt).map(|h| h.top_object(None)), + ); + let guide = |id, vertical, pos| Node::new(id, NodeKind::RulerGuide { vertical, pos, artboard: None }); + let (g1, g2, g3) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(layer), usize::MAX, guide(g1, true, 5.0)).unwrap(); + d.insert(Some(layer), usize::MAX, guide(g2, false, 5.0)).unwrap(); + d.insert(Some(sub), usize::MAX, guide(g3, true, 45.0)).unwrap(); + let after = ( + d.selectable_art(), + d.layers[0].layer_art(false), + d.node_count(), + d.art_bounds(), + marquee(&d, all, None, false), + hit_test(&d, Point::new(5.0, 5.0), opt).map(|h| h.top_object(None)), + ); + assert_eq!(after, before); + assert_eq!(after.0, [a, b, s1]); + for p in [Point::new(5.0, 300.0), Point::new(300.0, 5.0), Point::new(45.0, 300.0)] { + assert!(hit_test(&d, p, opt).is_none(), "nothing to click on the guide at {p:?}"); + assert!(objects_at(&d, p, opt, None).is_empty()); + } + let mut sel = Selection::default(); + sel.set_target(&d, layer); + assert_eq!(sel.objects, [a, b, s1]); + let children = d.layers[0].children().unwrap().clone(); + assert_eq!(Node::knockout_elements(&children).iter().map(|c| c.id).collect::>(), [a, b, s1]); + assert!(d.node(g1).is_some_and(Node::is_ruler_guide) && d.node(g1).is_some_and(Node::is_guide)); + assert_eq!(d.node(g1).map(Node::geometric_bounds), Some(None)); + let mut moved = (*d.layers[0]).clone(); + moved.transform(Affine::translate((10.0, 10.0)), true); + let g = moved.children().unwrap().iter().find(|c| c.id == g1).unwrap(); + assert!(matches!(g.kind, NodeKind::RulerGuide { pos, .. } if pos == 5.0)); +} + +/// Art inserted or moved onto a layer goes below the layer's ruler guides, a ruler guide goes +/// among them, and a clipped layer whose art is gone does not clip by a guide. +#[test] +fn layer_art_stays_below_its_ruler_guides() { + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + let guide = |id, pos| Node::new(id, NodeKind::RulerGuide { vertical: true, pos, artboard: None }); + let (g1, g2) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(layer), usize::MAX, guide(g1, 5.0)).unwrap(); + // Asked for the bottom, a guide still goes above the art. + d.insert(Some(layer), 0, guide(g2, 7.0)).unwrap(); + let ids = |d: &Document| d.layers[0].children().unwrap().iter().map(|c| c.id).collect::>(); + assert_eq!(ids(&d), [a, b, g2, g1]); + let c = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(c, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Appearance::default_art())).unwrap(); + assert_eq!(ids(&d), [a, b, c, g2, g1]); + d.move_node(a, Some(layer), usize::MAX).unwrap(); + assert_eq!(ids(&d), [b, c, a, g2, g1]); + d.move_node(g1, Some(layer), 0).unwrap(); + assert_eq!(ids(&d), [b, c, a, g1, g2]); + assert!(d.layer_object(g1).is_none()); + assert_eq!(d.layer_object(a).map(|n| n.id), Some(a)); + + // A clipped layer clips by its first art object; with only guides left it clips nothing. + if let Some(NodeKind::Layer { clip, .. }) = d.node_mut(layer).map(|n| &mut n.kind) { + *clip = true; + } + assert!(d.node(layer).is_some_and(Node::clips)); + for id in [a, b, c] { + d.remove(id).unwrap(); + } + assert!(!d.node(layer).is_some_and(Node::clips)); + + // Reading a file that clips a layer by a path beyond the canvas empties the layer of its art + // only: its guides stay. + let far = d.alloc_id(); + d.insert(Some(layer), 0, Node::path(far, shapes::rectangle(Rect::new(0.0, 0.0, 1e9, 10.0)), Appearance::default_art())).unwrap(); + assert!(d.node(layer).is_some_and(Node::clips)); + let gone = d.drop_beyond_canvas(); + assert_eq!(gone.objects, 1); + assert_eq!(ids(&d), [g1, g2]); +} + +/// `ruler_guides` keeps its list while the layers are unchanged, and lists again after any change +/// inside a layer, made in place or on a shared copy, and after layers are added, removed or +/// reordered. +#[test] +fn ruler_guides_follow_every_edit() { + let (mut d, _, _) = doc_with_rects(); + let layer = d.layers[0].id; + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let g1 = d.add_ruler_guide(&Guide { layer: Some(layer), ..Guide::new(true, 5.0) }); + let g2 = d.add_ruler_guide(&Guide { layer: Some(sub), ..Guide::new(false, 6.0) }); + let line = |d: &Document| d.ruler_guides().iter().map(|g| (g.id, g.pos, g.hidden, g.layer)).collect::>(); + assert_eq!(line(&d), [(Some(g1), 5.0, false, Some(layer)), (Some(g2), 6.0, false, Some(sub))]); + // Changed in place: the document is the only holder of its layers. + assert!(d.edit_ruler_guide(g2, |g| g.pos = 9.0)); + assert_eq!(line(&d)[1], (Some(g2), 9.0, false, Some(sub))); + d.node_mut(g1).unwrap().visible = false; + assert_eq!(line(&d)[0], (Some(g1), 5.0, true, Some(layer))); + // A copy shares the list until one of them changes. + let copy = d.clone(); + assert_eq!(line(©), line(&d)); + d.remove(g1).unwrap(); + assert_eq!(line(&d), [(Some(g2), 9.0, false, Some(sub))]); + assert_eq!(line(©).len(), 2); + // A new layer with a guide, then the layers reordered and one removed. + let top = d.add_layer(None); + let g3 = d.add_ruler_guide(&Guide { layer: Some(top), ..Guide::new(true, 1.0) }); + assert_eq!(line(&d).len(), 2); + d.layers.reverse(); + assert_eq!(line(&d), [(Some(g2), 9.0, false, Some(sub)), (Some(g3), 1.0, false, Some(top))]); + d.layers.retain(|l| l.id != layer); + assert_eq!(line(&d), [(Some(g3), 1.0, false, Some(top))]); +} + +#[test] +fn strip_ruler_guides_leaves_a_layers_art() { + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + let guide = |id| Node::new(id, NodeKind::RulerGuide { vertical: false, pos: 3.0, artboard: None }); + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let (g1, g2) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(layer), usize::MAX, guide(g1)).unwrap(); + d.insert(Some(sub), usize::MAX, guide(g2)).unwrap(); + let mut n = (*d.layers[0]).clone(); + assert!(n.holds_ruler_guides()); + n.strip_ruler_guides(); + assert!(!n.holds_ruler_guides()); + let mut left = vec![]; + n.walk(&mut |c| left.push(c.id)); + assert_eq!(left, [layer, a, b, sub]); + // Other nodes hold none and are left as they are. + let mut p = d.node(a).unwrap().clone(); + p.strip_ruler_guides(); + assert_eq!(&p, d.node(a).unwrap()); +} + +/// The saved `guides` list becomes ruler guide nodes and is written back from them unchanged: +/// guides on a layer, a sublayer, no layer and a deleted layer, with an artboard guide, keep their +/// order, ids, names and their own lock and visibility; a saved id that another node has is +/// replaced; and the saved form of an older entry still reads. +#[test] +fn saved_guides_become_nodes_and_are_saved_back_the_same() { + let (mut d, a, _) = doc_with_rects(); + let layer = d.layers[0].id; + let top = d.add_layer(Some("Top")); + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let json = serde_json::json!([ + {"vertical": true, "pos": 10.0, "layer": layer}, + {"vertical": false, "pos": 20.0, "layer": sub, "artboard": 1}, + {"vertical": true, "pos": 30.0}, + {"vertical": false, "pos": 40.0, "layer": 9999}, + {"vertical": true, "pos": 50.0, "layer": layer, "id": 500, "name": "Edge", "locked": true, "hidden": true}, + {"vertical": true, "pos": 60.0, "layer": layer, "id": a}, + ]); + d.saved_guides = serde_json::from_value(json).unwrap(); + let before = d.peek_next_id(); + assert_eq!(d.guides_into_nodes(), 6); + assert!(d.saved_guides.is_empty()); + let guides = d.ruler_guides(); + let lines: Vec<(f64, Option)> = guides.iter().map(|g| (g.pos, g.layer)).collect(); + // A guide on no layer, or on a deleted one, goes on the top visible, unlocked layer. + assert_eq!(lines[..4], [(50.0, Some(layer)), (10.0, Some(layer)), (20.0, Some(sub)), (30.0, Some(top))], "by id: the kept id first"); + assert_eq!(lines[4..], [(40.0, Some(top)), (60.0, Some(layer))]); + assert_eq!(guides[0].id, Some(NodeId(500))); + assert!(guides[1..].iter().all(|g| g.id.unwrap().0 > 500), "new ids start above a kept one"); + assert_ne!(guides[5].id, Some(a), "an id another object has is not reused"); + assert!(d.peek_next_id() > 500 && before <= 500); + let edge = d.node(NodeId(500)).unwrap(); + assert!(edge.locked && !edge.visible && edge.name.as_deref() == Some("Edge")); + assert_eq!(guides[2].artboard, Some(1)); + // Each layer's guides sit at its top, above its art and its sublayer. + let kids: Vec = d.node(layer).unwrap().children().unwrap().iter().map(|c| c.is_ruler_guide()).collect(); + assert_eq!(kids, [false, false, false, true, true, true]); + assert_eq!(d.guides_into_nodes(), 0, "a second run does nothing"); + + // Saved back: the guides leave the layers and the list names each one's layer and id. + let mut saved = d.clone(); + saved.guides_to_saved(); + assert!(saved.ruler_guides().is_empty()); + assert_eq!(saved.node_count(), d.node_count()); + assert_eq!(saved.saved_guides, guides); + let json = serde_json::to_value(&saved).unwrap(); + assert_eq!(json["guides"].as_array().map(Vec::len), Some(6)); + assert_eq!( + json["guides"][0], + serde_json::json!({"vertical": true, "pos": 50.0, "layer": layer, "id": 500, "name": "Edge", "locked": true, "hidden": true}) + ); + // Read again, the same guides come back with the same ids. + let mut back: Document = serde_json::from_value(json).unwrap(); + back.fix_next_id(); + back.guides_into_nodes(); + assert_eq!(back.ruler_guides(), guides); + // An older reader of the list ignores the new fields. + #[derive(serde::Deserialize, PartialEq, Debug)] + struct Old { + vertical: bool, + pos: f64, + #[serde(default)] + artboard: Option, + #[serde(default)] + layer: Option, + } + let old: Vec = serde_json::from_value(serde_json::to_value(&saved.saved_guides).unwrap()).unwrap(); + assert_eq!(old[0], Old { vertical: true, pos: 50.0, artboard: None, layer: Some(layer) }); + + // A document with no layer gets a "Layer 1" for its guides. + let mut empty = Document::new(10.0, 10.0); + empty.layers.clear(); + empty.saved_guides = vec![Guide::new(true, 1.0)]; + assert_eq!(empty.guides_into_nodes(), 1); + assert_eq!(empty.layers.len(), 1); + assert_eq!(empty.layers[0].name.as_deref(), Some("Layer 1")); + assert_eq!(empty.ruler_guides().len(), 1); +} + +/// Editing, deleting and the artboard moves of ruler guide nodes, and the selection keeping step. +#[test] +fn ruler_guide_nodes_are_edited_by_id() { + let (mut d, a, _) = doc_with_rects(); + let layer = d.layers[0].id; + let g1 = d.add_ruler_guide(&Guide { layer: Some(layer), ..Guide::new(true, 10.0) }); + let g2 = d.add_ruler_guide(&Guide { artboard: Some(1), ..Guide::new(false, 20.0) }); + assert!(d.edit_ruler_guide(g1, |g| g.pos = 15.0)); + assert!(!d.edit_ruler_guide(a, |g| g.pos = 15.0), "not a guide"); + assert_eq!(d.ruler_guide(g1).map(|g| g.pos), Some(15.0)); + d.move_artboard_guides(1, Vec2::new(5.0, 7.0)); + assert_eq!(d.ruler_guide(g2).map(|g| g.pos), Some(27.0)); + d.map_artboard_guides(1, Affine::scale(2.0)); + assert_eq!(d.ruler_guide(g2).map(|g| g.pos), Some(54.0)); + assert_eq!(d.ruler_guide(g1).map(|g| g.pos), Some(15.0), "a canvas guide stays"); + d.copy_artboard_guides(1, 2, Vec2::new(0.0, 100.0)); + let copies: Vec<(f64, Option)> = d.ruler_guides().iter().filter(|g| g.artboard == Some(2)).map(|g| (g.pos, g.artboard)).collect(); + assert_eq!(copies, [(154.0, Some(2))]); + let mut sel = Selection::default(); + sel.set_guides([g1, g2]); + sel.key_guide = Some(g2); + assert_eq!(d.retain_guides(&mut sel, |g| g.artboard.is_none()), 2); + assert_eq!(sel.guides, [g1]); + assert_eq!(sel.key_guide, None); + assert_eq!(d.ruler_guides().iter().map(|g| g.id).collect::>(), [Some(g1)]); + assert_eq!(d.retain_guides(&mut sel, |_| true), 0); +} + fn doc_with_rects() -> (Document, NodeId, NodeId) { let mut d = Document::new(612.0, 792.0); let layer = d.layers[0].id; diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index be3e99522..5d27ac60a 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -107,7 +107,8 @@ fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind { /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option { // A cancelled export leaves the rest of the document as it is: the caller drops the result. - if !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { + // A ruler guide draws nothing and stays as it is, last in its layer. + if n.is_ruler_guide() || !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { return None; } // Colour adjustments: the object recoloured (inside it too), then baked as it is. diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 73d7266c3..99e9d9e6f 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -455,6 +455,17 @@ pub fn pathfinder_children(doc: Option<&Document>, group: &Node, hook: Option] = group.children()?; + // A layer's ruler guides take no part in the operation: they stay above its result. + let art = Node::guide_run_start(source); + if art < source.len() { + let mut without = group.clone(); + if let Some(ch) = without.children_mut() { + ch.truncate(art); + } + let mut out = pathfinder_children(doc, &without, hook)?; + out.extend(source.iter().skip(art).cloned()); + return Some(out); + } let mut stack: Vec> = if group.is_compound_shape() { // The group's own appearance paints the area ([`evaluate_container`]), so the art has none. match compound_shape_area(doc, group, hook) { @@ -727,9 +738,11 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { return None; } let mut m = n.clone(); + // A layer's ruler guides take no part: they go back above the evaluated art at the end. + let guides = m.children_mut().map(|ch| ch.split_off(Node::guide_run_start(ch))).unwrap_or_default(); if pathfinder { let hook = |c: &Node| crate::outline_object(doc, c); - let result = pathfinder_children(doc, n, Some(&hook)).unwrap_or_default(); + let result = pathfinder_children(doc, &m, Some(&hook)).unwrap_or_default(); if let Some(ch) = m.children_mut() { *ch = result; } @@ -762,6 +775,9 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } m.appearance.items.clear(); m.appearance.contents_index = None; + if let Some(ch) = m.children_mut() { + ch.extend(guides); + } Some(m) } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 94456c7d5..bf702b273 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -820,3 +820,45 @@ fn nested_compound_shapes_take_part_as_their_area() { assert!((area(&out[0]) - 50.0).abs() < 0.01); assert_eq!(out[0].appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "styled like the compound shape"); } + +/// The ruler guides of a layer with a Pathfinder effect, a Transform effect that copies its art, or +/// fills and strokes of its own take no part: they stay last in the layer, once and unchanged. +#[test] +fn a_layers_ruler_guides_stay_last_through_its_effects() { + use vectorcraft_doc::LayerColor; + let guide = |id, pos| Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: true, pos, artboard: None })); + let layer = |effect: &str, params: serde_json::Value| { + let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = l.children_mut() { + *ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0)), guide(20, 3.0), guide(21, 8.0)]; + } + l.appearance.effects.push(Effect { id: effect.into(), params, visible: true }); + l + }; + let tail = |n: &Node| { + let ch = n.children().unwrap(); + assert_eq!(ch.iter().filter(|c| c.is_ruler_guide()).count(), 2, "each guide once"); + ch.iter().rev().take(2).map(|c| c.kind.clone()).collect::>() + }; + let guides = vec![guide(21, 8.0).kind.clone(), guide(20, 3.0).kind.clone()]; + + let add = layer("pathfinder.add", serde_json::json!({})); + let out = pathfinder_children(None, &add, None).unwrap(); + assert_eq!(out.len(), 3, "the united area, then the two guides"); + assert!((area(&out[0]) - 150.0).abs() < 0.01); + assert!(out[1].is_ruler_guide() && out[2].is_ruler_guide()); + assert_eq!(tail(&evaluate_container(None, &add).unwrap()), guides); + + let copies = layer("distort.transform", serde_json::json!({"moveH": 30, "copies": 2})); + let art = evaluate_container(None, &copies).unwrap(); + assert_eq!(tail(&art), guides); + assert_eq!(art.children().unwrap().len(), 5, "three groups of the art, then the two guides"); + let mut art = copies.clone(); + crate::reshape::reshape_leaves(&mut art, &copies.appearance.effects.iter().collect::>(), &copies.appearance.effects, None); + assert_eq!(tail(&art), guides); + assert_eq!(art.children().unwrap().len(), 5, "reshaped as members too"); + + let mut painted = layer("distort.transform", serde_json::json!({"moveH": 30})); + painted.appearance.items = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).items; + assert_eq!(tail(&evaluate_container(None, &painted).unwrap()), guides); +} diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 0bdc13462..c629cc10c 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -974,7 +974,7 @@ fn effects_cost(leaf: &Node, path: &PathData) -> Budget { /// Remove the guides anywhere under `n`. fn without_guides(n: &mut Node) { let Some(children) = n.children_mut() else { return }; - children.retain(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. })); + children.retain(|c| !c.is_guide()); children.iter_mut().for_each(|c| without_guides(Arc::make_mut(c))); } @@ -984,7 +984,9 @@ fn without_guides(n: &mut Node) { /// [`crate::distort::MAX_COPY_OBJECTS`] nodes in all, the art of opacity masks counted. Returns what /// the art costs after. fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget { - let Some(members) = art.children().cloned() else { return art_cost(art) }; + let Some(mut members) = art.children().cloned() else { return art_cost(art) }; + // A layer's ruler guides are not copied: they stay above the copies, once. + let guides = members.split_off(Node::guide_run_start(&members)); let original = Node::group(art.id, members); let mut made = copy_cost(&original); let mut drawn = original.clone(); @@ -1014,6 +1016,7 @@ fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc:: // The last copy is the lowest. placements.reverse(); placements.push(Arc::new(original)); + placements.extend(guides); if let Some(children) = art.children_mut() { *children = placements; } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 95ef265cc..be6c68ba7 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -348,7 +348,7 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { fn walk(n: &Node, opacity: f32, scopes: &[Scope], w: &mut Walk) -> Result<(), String> { let label = w.label; for c in n.children().into_iter().flatten() { - if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { + if !c.visible || c.is_guide() { continue; } let opacity = opacity * c.opacity; diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index ad9495cf0..66ca2c1d7 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -191,7 +191,7 @@ impl Resolver<'_> { let mut changed = false; for c in children { self.spend(1)?; - if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { + if !c.visible || c.is_guide() { continue; } if matches!(c.kind, NodeKind::Image(_)) { @@ -279,7 +279,7 @@ impl Resolver<'_> { kids = nest(n, cut, 0); } else { for c in n.children().into_iter().flatten() { - if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { + if !c.visible || c.is_guide() { continue; } if let Some(c) = self.clipped(c, clip, depth + 1)? { diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index c76492f45..72e33b899 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -519,17 +519,23 @@ pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str } /// The objects a command acts on: `ids` when given (every one checked, and those inside another -/// given one dropped), else the selected objects. +/// given one dropped), else (`ids` left out or null) the selected objects. pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> Result> { - if p.get("ids").is_some() { Ok(edit::outermost(&s.doc()?.doc, checked_ids_param(s, p, "ids", command)?)) } else { edit::selected_roots(s) } + if p.get("ids").is_some_and(|v| !v.is_null()) { + Ok(edit::outermost(&s.doc()?.doc, checked_ids_param(s, p, "ids", command)?)) + } else { + edit::selected_roots(s) + } } pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result { let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?; - if s.doc()?.doc.node(id).is_none() { - return Err(bad(command, format!("no such object id {value}"))); + match s.doc()?.doc.node(id) { + None => Err(bad(command, format!("no such object id {value}"))), + // The guide commands take ruler guides by index. + Some(n) if n.is_ruler_guide() => Err(bad(command, format!("id {value} is a ruler guide, not an object"))), + Some(_) => Ok(id), } - Ok(id) } /// `v` as exactly `N` finite numbers (`[x, y]`, `[x, y, width, height]`…): `None` when it isn't an @@ -689,10 +695,11 @@ pub(crate) fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result> { let compound = |id: NodeId| d.parent_of(id).filter(|p| d.node(*p).is_some_and(owns)); let mut out = vec![]; for id in ids { - let Some(n) = d.node(*id) else { continue }; + // A ruler guide has no appearance to paint, given or in a layer. + let Some(n) = d.node(*id).filter(|n| !n.is_ruler_guide()) else { continue }; match &n.kind { NodeKind::Group { .. } | NodeKind::Layer { .. } if !n.is_compound_shape() => n.walk(&mut |c| { - if !c.is_container() || owns(c) { + if (!c.is_container() && !c.is_ruler_guide()) || owns(c) { out.push(c.id) } }), @@ -742,6 +749,7 @@ pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap< /// The most presets one presets file imports: each is looked up among the saved ones. pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; +pub use docmenu::guide_indexes; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned}; diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs index dfb450fae..4fd1f1ce9 100644 --- a/crates/engine/src/cmd/addons/cleanup/checks.rs +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -185,7 +185,7 @@ fn objects<'a>(d: &'a Document, scope: &Scope) -> Vec<(&'a Node, Place)> { } fn visit<'a>(n: &'a Node, place: Place, out: &mut Vec<(&'a Node, Place)>) { - if !n.visible || n.locked || out.len() >= MAX_OBJECTS { + if !n.visible || n.locked || n.is_ruler_guide() || out.len() >= MAX_OBJECTS { return; } let inner = |clip: bool, i: usize, top: bool| Place { clips: clip && i == 0, top, in_compound: false }; @@ -216,7 +216,7 @@ fn is_generated(n: &Node) -> bool { } fn is_guide_or_clip(n: &Node) -> bool { - matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. }) + n.is_guide() || matches!(n.kind, NodeKind::Path { clipping: true, .. }) } fn is_shape(n: &Node) -> bool { @@ -484,7 +484,7 @@ fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result Result { }; // The characters a Type tool has selected (after Select All in threaded text, all the text of // every frame of the story), else all the text of the selected type. - let editing = if p.get("ids").is_some() { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) }; + let editing = if p.get("ids").is_some_and(|v| !v.is_null()) { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) }; let story = if editing.is_some() { editing_story(s) } else { vec![] }; let d = &s.doc()?.doc; // Each type object with the breaks that go from it. diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index 1916d9dd2..0af24be36 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -288,6 +288,18 @@ fn remove_breaks_in_the_characters_a_type_tool_selected() { assert_eq!(obj(&s, id).plain_text(), "a b\nc"); } +/// `ids: null` is `ids` left out: the characters the Type tool selected lose their breaks, and +/// the rest of the text keeps them. +#[test] +fn remove_breaks_with_ids_null_in_the_characters_a_type_tool_selected() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb\nc", "size": 12})); + select(&mut s, &[id]); + edit_text(&mut s, id, 0, 2); + s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": null})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "a b\nc"); +} + #[test] fn split_at_the_cursor_and_around_a_selection() { for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] { diff --git a/crates/engine/src/cmd/addons/color/print_fixes.rs b/crates/engine/src/cmd/addons/color/print_fixes.rs index 7a4cfee9c..a0dd332fe 100644 --- a/crates/engine/src/cmd/addons/color/print_fixes.rs +++ b/crates/engine/src/cmd/addons/color/print_fixes.rs @@ -107,7 +107,7 @@ impl Fixes { let white = usize::from(self.remove_white_overprint && self.unset_white(n)); let blacks = self.overprint_black > 0.0 && (!self.text_only || matches!(n.kind, NodeKind::Text(_))); let mut counts = (white, usize::from(blacks && black.apply(n, spots))); - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { let (w, b) = self.overprint(Arc::make_mut(c), black, spots); counts = (counts.0 + w, counts.1 + b); } diff --git a/crates/engine/src/cmd/addons/document.rs b/crates/engine/src/cmd/addons/document.rs index fafdfc07d..654138a1d 100644 --- a/crates/engine/src/cmd/addons/document.rs +++ b/crates/engine/src/cmd/addons/document.rs @@ -164,11 +164,11 @@ fn export_all_open(s: &mut Session, p: &Value) -> Result { } /// `n` with each layer in it, and itself, made a group of the same art and clipping (a symbol holds -/// no layers). +/// no layers, and no ruler guides: a layer's are left out). fn without_layers(n: &Node) -> Node { let mut n = n.clone(); if let NodeKind::Layer { children, clip, .. } = &n.kind { - n.kind = NodeKind::Group { children: children.clone(), clip: *clip }; + n.kind = NodeKind::Group { children: children.iter().filter(|c| !c.is_ruler_guide()).cloned().collect(), clip: *clip }; } if let Some(children) = n.children_mut() { for c in children.iter_mut().filter(|c| matches!(c.kind, NodeKind::Layer { .. })) { @@ -203,8 +203,10 @@ fn symbols_from_layers(s: &mut Session, p: &Value) -> Result { let (mut made, mut skipped) = (vec![], vec![]); for layer in layers.iter().filter_map(|id| d.layers.iter().find(|l| l.id == *id)) { let name = layer.display_name(); - let roots: Vec = layer.children().map(|c| c.iter().map(|n| n.id).collect()).unwrap_or_default(); - let art = match layer.children().map(Vec::as_slice).unwrap_or_default() { + // The layer's ruler guides stay where they are. + let children: Vec> = layer.children().into_iter().flatten().filter(|n| !n.is_ruler_guide()).cloned().collect(); + let roots: Vec = children.iter().map(|n| n.id).collect(); + let art = match children.as_slice() { [] => None, [one] => Some(without_layers(one)), many => Some(without_layers(&Node::group(NodeId(0), many.to_vec()))), diff --git a/crates/engine/src/cmd/addons/pathtools/shape.rs b/crates/engine/src/cmd/addons/pathtools/shape.rs index 4c1ba7e2d..fc8844299 100644 --- a/crates/engine/src/cmd/addons/pathtools/shape.rs +++ b/crates/engine/src/cmd/addons/pathtools/shape.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Edit Parametric Shape", [], None, - "{id, kind?, center?, width?, height?, rotation?, count?, a?, b?, radius?} change a parametric shape's numbers (as addon.pathtools.shape.create takes them; a new kind takes its own defaults for the values not given), as one undo step; with none, or the same ones, nothing changes → {id, shape (its numbers now)}", + "{id, kind?, center?, width?, height?, rotation?: degrees clockwise (0: upright), count?, a?, b?, radius?} change a parametric shape's numbers (as addon.pathtools.shape.create takes them; a new kind takes its own defaults for the values not given), as one undo step; with none, or the same ones, nothing changes → {id, shape (its numbers now)}", has_doc, set ), diff --git a/crates/engine/src/cmd/addons/shapes.rs b/crates/engine/src/cmd/addons/shapes.rs index c40173390..24d0e4c3a 100644 --- a/crates/engine/src/cmd/addons/shapes.rs +++ b/crates/engine/src/cmd/addons/shapes.rs @@ -26,7 +26,7 @@ pub fn specs() -> Vec { "Rectangle", [], None, - "{x, y, width, height, radius?: pt, radii?: [tl,tr,br,bl], startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}", + "{x, y, width, height, radius?: pt, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top-left corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, rectangle ), @@ -35,7 +35,7 @@ pub fn specs() -> Vec { "Ellipse", [], None, - "{x, y, width, height, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}", + "{x, y, width, height, startAngle?, endAngle?: degrees (counter-clockwise from 3 o'clock, as object.setLiveShape's pieStart and pieEnd; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, ellipse ), @@ -44,7 +44,7 @@ pub fn specs() -> Vec { "Polygon", [], None, - "{cx, cy, radius, sides=6, rounded=0, randomized=0, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}", + "{cx, cy, radius, sides=6, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a corner straight up)} → {id}", has_doc, polygon ), @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Star", [], None, - "{cx, cy, radius1, radius2?, points=5, rounded=0, randomized=0, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}", + "{cx, cy, radius1, radius2?, points=5, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top point; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a point straight up)} → {id}", has_doc, star ), @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "3D Box", [], None, - "{x, y, width, height, angleX=30, angleY=30, angleZ=0, parallelX=false, parallelY=true, parallelZ=false} → {id}", + "{x, y, width, height, angleX=30 (degrees about the level axis: positive tips the top away from you), angleY=30 (degrees about the upright axis: positive turns the right side away from you), angleZ=0 (degrees clockwise in the page), parallelX=false, parallelY=true, parallelZ=false} → {id}", has_doc, box3d ), @@ -80,7 +80,7 @@ pub fn specs() -> Vec { "Configure Shape", [], None, - "{ids?, radius?, radii?: [tl,tr,br,bl], startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, sides?, isStar?, spokeRatio?, rounded?, randomized?, turns?, divergence?, innerRadius?, angleX?, angleY?, angleZ?, parallelX?, parallelY?, parallelZ?, reset?: bool} → {updated}", + "{ids?, radius?, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (as addon.shapes.rectangle, ellipse, polygon and star take them: an ellipse's counter-clockwise from 3 o'clock, the others' clockwise along the outline), arcMode?: whole|pie|chord|arc, sides?, isStar?, spokeRatio?, rounded?, randomized?, turns?, divergence?, innerRadius?, angleX?, angleY?: degrees (as addon.shapes.box3d: positive tips the top, or turns the right side, away from you), angleZ?: degrees clockwise in the page, parallelX?, parallelY?, parallelZ?, reset?: bool} → {updated}", has_selection, configure ), diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 024358adf..2dfeb497d 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -1189,7 +1189,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result { .iter() .filter_map(|id| d.node(*id)) .filter(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })) - .flat_map(|n| n.children().into_iter().flatten().filter(|c| c.visible && !c.locked).map(|c| c.id)) + .flat_map(|n| n.children().into_iter().flatten().filter(|c| c.visible && !c.locked && !c.is_ruler_guide()).map(|c| c.id)) .collect(); if ids.is_empty() { return Err(bad("appearance.targetContents", "select a group or layer with visible, unlocked members")); diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 0b57565af..1ebde5c02 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -116,13 +116,15 @@ pub(crate) fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut V let Some(n) = doc.node(id) else { return }; if n.is_layer() { if let Some(kids) = n.children() { - for c in kids { + for c in kids.iter().filter(|c| !c.is_ruler_guide()) { art_under(doc, c.id, out); } } return; } - out.push(id); + if !n.is_ruler_guide() { + out.push(id); + } } fn add(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index b58af91ff..8de9dd5c8 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -172,6 +172,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails }, + BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams }, ]; /// Built-in library `id`, made once. @@ -416,6 +417,35 @@ fn game_trails() -> BrushLibrary { built("Game Trails", Document::new(100.0, 100.0), brushes) } +/// Energy Beams: art brushes for laser, plasma and charge strokes (original game VFX art). +fn energy_beams() -> BrushLibrary { + let laser = { + let n = 32; + let half = |x: f64| 1.6 * (1.0 - (x / 100.0 - 0.5).abs() * 1.6).max(0.12); + let xs = |i: usize| 100.0 * i as f64 / n as f64; + let mut pts: Vec = (0..=n).map(xs).map(|x| Point::new(x, -half(x))).collect(); + pts.extend((0..n).rev().map(xs).map(|x| Point::new(x, half(x)))); + PathData::single(SubPath::polyline(&pts, true)) + }; + let plasma = band(100.0, 2.2, 48, |x| 4.0 * (x / 100.0 * std::f64::consts::PI).sin()); + let charge = band(100.0, 1.6, 56, |x| 3.5 * (x / 100.0 * std::f64::consts::TAU).sin()); + let spark = group(vec![ + black(shapes::rounded_rectangle(Rect::new(0.0, -1.2, 10.0, 1.2), 0.8)), + black(shapes::rounded_rectangle(Rect::new(18.0, -0.9, 28.0, 0.9), 0.7)), + black(shapes::rounded_rectangle(Rect::new(36.0, -1.4, 48.0, 1.4), 0.9)), + black(shapes::rounded_rectangle(Rect::new(56.0, -0.8, 66.0, 0.8), 0.6)), + black(shapes::rounded_rectangle(Rect::new(74.0, -1.1, 86.0, 1.1), 0.8)), + black(shapes::rounded_rectangle(Rect::new(92.0, -0.7, 100.0, 0.7), 0.5)), + ]); + let brushes = vec![ + art("Laser Bolt", black(laser), Colorization::Tints), + art("Plasma Arc", black(plasma), Colorization::Tints), + art("Charge Wave", black(charge), Colorization::Tints), + art("Spark Trail", spark, Colorization::Tints), + ]; + built("Energy Beams", Document::new(100.0, 100.0), brushes) +} + // ---------- commands ---------- fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo { diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 85c1412f0..1677d27f9 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index 32edc44f8..888e45008 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -137,7 +137,7 @@ fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { /// stroke has a Convert to Shape or Outline Object effect. fn paint_refusal(n: &Node, key: &str, cmd: &str) -> Option { Some(match &n.kind { - NodeKind::Path { guide: true, .. } => bad(cmd, format!("{key} cannot be set on a guide")), + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => bad(cmd, format!("{key} cannot be set on a guide")), NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => return None, NodeKind::Image(_) => { let effects = if key == "fill" { n.appearance.fill().map(|f| &f.effects) } else { n.appearance.stroke().map(|s| &s.effects) }; diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 5ff09dc28..9e52454de 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -322,7 +322,18 @@ impl Clipboard { /// Every object of `src` (each top-level layer's contents) with its resources, for Place and /// pasting outside SVG. Their layers don't count for Paste Remembers Layers. pub fn from_document(src: &Document) -> Self { - let nodes = src.layers.iter().flat_map(|l| l.children().into_iter().flatten()).map(|n| (**n).clone()).collect(); + // Ruler guides are not objects: the ones of `src`'s layers and sublayers stay behind. + let nodes = src + .layers + .iter() + .flat_map(|l| l.children().into_iter().flatten()) + .filter(|n| !n.is_ruler_guide()) + .map(|n| { + let mut n = (**n).clone(); + n.strip_ruler_guides(); + n + }) + .collect(); Self::gather(src, nodes) } diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index ac082d7a0..65f105689 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -462,7 +462,7 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) - pub(crate) fn left_out(d: &Document, iso: Option, roots: &[NodeId]) -> Vec<(NodeId, bool)> { fn visit(n: &Node, hidden: bool, locked: bool, all: &HashSet, board_locked: &dyn Fn(&Node) -> bool, out: &mut Vec<(NodeId, bool)>) { for c in n.children().into_iter().flatten() { - if c.is_template() { + if c.is_template() || c.is_ruler_guide() { continue; } let (hidden, locked) = (hidden || !c.visible, locked || c.locked); diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 03b171de7..da7dc42ec 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -582,7 +582,7 @@ fn map_more_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proo } } if let Some(ch) = n.children_mut() { - for c in ch.iter_mut() { + for c in ch.iter_mut().filter(|c| !c.is_ruler_guide()) { map_more_colors(Arc::make_mut(c), f); } } diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 407b20a1a..973c82af5 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -17,19 +17,43 @@ pub fn specs() -> Vec { "Rectangle", [], None, - "{x, y, width, height, radius?: pt, rotation?: deg (about centre)} → {id}", + "{x, y, width, height, radius?: pt, rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, rectangle ), - cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height, rotation?: deg (about centre)} → {id}", has_doc, ellipse), - cmd!("shape.polygon", "Polygon", [], None, "{cx, cy, radius, sides=6, rotation?: deg} → {id}", has_doc, polygon), - cmd!("shape.star", "Star", [], None, "{cx, cy, radius1, radius2, points=5, rotation?: deg} → {id}", has_doc, star), + cmd!( + "shape.ellipse", + "Ellipse", + [], + None, + "{x, y, width, height, rotation?: deg clockwise about the centre (0: upright)} → {id}", + has_doc, + ellipse + ), + cmd!( + "shape.polygon", + "Polygon", + [], + None, + "{cx, cy, radius, sides=6, rotation?: deg clockwise (0: a corner straight up)} → {id}", + has_doc, + polygon + ), + cmd!( + "shape.star", + "Star", + [], + None, + "{cx, cy, radius1, radius2, points=5, rotation?: deg clockwise (0: a point straight up)} → {id}", + has_doc, + star + ), cmd!( "shape.flare", "Flare", [], None, - "{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg), seed?} → {id}", + "{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg counter-clockwise from 3 o'clock: where the rings run from the centre), seed?} → {id}", has_doc, flare ), @@ -49,7 +73,7 @@ pub fn specs() -> Vec { "Rectangular Grid", [], None, - "{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none), rotation?: deg (about centre)} → {id}", + "{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none), rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, rect_grid ), @@ -58,7 +82,7 @@ pub fn specs() -> Vec { "Polar Grid", [], None, - "{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none), rotation?: deg (about centre)} → {id}", + "{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none), rotation?: deg clockwise about the centre (0: upright)} → {id}", has_doc, polar_grid ), @@ -85,7 +109,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index bcaa59426..17638477a 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "Liquify", [], None, - "{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure?: bool (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut?: bool (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}", + "{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle? (deg clockwise: the brush's width axis from level), intensity? (0..1 or %), usePressure?: bool (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °, positive turns clockwise), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut?: bool (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}", has_doc, liquify ), @@ -80,7 +80,7 @@ pub fn specs() -> Vec { "Puppet Warp", [], None, - "{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool, auto?: bool (as object.puppetWarp.pins reports; ignored)} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)", + "{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, clockwise, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool, auto?: bool (as object.puppetWarp.pins reports; ignored)} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)", has_doc, puppet_warp ), @@ -89,7 +89,7 @@ pub fn specs() -> Vec { "Puppet Warp Pins", [], None, - "{id?|ids? (default: selection), expand?: pt} → {ids, pins, moved, angles, expand, rest: true, auto} the Puppet Warp pins on the art: pins = where each sits on the shape they started from, moved = where it is now, angles = the turn it holds (deg, null = free); auto: none placed yet (the tool's automatic pins: the centre and the end of each limb). Change moved/angles (or add/remove pins) and pass it back to object.puppetWarp", + "{id?|ids? (default: selection), expand?: pt} → {ids, pins, moved, angles, expand, rest: true, auto} the Puppet Warp pins on the art: pins = where each sits on the shape they started from, moved = where it is now, angles = the turn it holds (deg clockwise, null = free); auto: none placed yet (the tool's automatic pins: the centre and the end of each limb). Change moved/angles (or add/remove pins) and pass it back to object.puppetWarp", has_doc, puppet_pins ), diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index e6e75c1f3..cb1bb86fb 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -370,7 +370,8 @@ fn info(s: &mut Session, p: &Value) -> Result { return; } let k = match &n.kind { - NodeKind::Layer { .. } => None, + // The Guides row counts paths made into guides; ruler guides are not objects. + NodeKind::Layer { .. } | NodeKind::RulerGuide { .. } => None, NodeKind::Group { clip: true, .. } => Some("clipGroups"), NodeKind::Group { .. } => Some("groups"), NodeKind::Path { guide: true, .. } => Some("guides"), diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 5f8508f4e..db792ab1c 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -666,7 +666,7 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { if (index.is_some() || ids.is_some()) && s.menu.guides_locked { return Err(EngineError::Other("guides are locked".into())); } - let (rulers, paths): (Vec, Vec) = if index.is_some() || ids.is_some() { + let (rulers, paths): (Vec, Vec) = if index.is_some() || ids.is_some() { let rulers = match index { Some(v) => vec![editable_guide_index(s, Some(v), C)?], None => vec![], @@ -689,10 +689,10 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let all = guide_paths(&st.doc); let sel: Vec = all.iter().copied().filter(|id| st.selection.contains(*id)).collect(); - let rulers: Vec = if s.menu.guides_locked { + let rulers: Vec = if s.menu.guides_locked { vec![] } else { - st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect() + st.selection.guides.iter().copied().filter(|id| st.doc.ruler_guide(*id).is_some_and(|g| st.doc.guide_editable(&g))).collect() }; let editable = st.doc.editable(); if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| editable(*id)).collect()) } else { (rulers, sel) } @@ -702,8 +702,8 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { let artboards = st.doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)); let mut lines = Vec::with_capacity(rulers.len()); for &i in &rulers { - let Some(g) = st.doc.guides.get(i) else { continue }; - let span = st.doc.guide_span(g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })); + let Some(g) = st.doc.ruler_guide(i) else { continue }; + let span = st.doc.guide_span(&g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })); let Some((a, b)) = span else { continue }; let (a, b) = if g.vertical { (Point::new(g.pos, a), Point::new(g.pos, b)) } else { (Point::new(a, g.pos), Point::new(b, g.pos)) }; let parent = match g.layer.filter(|l| st.doc.node(*l).is_some_and(Node::is_layer)) { @@ -729,7 +729,7 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { d.insert(parent, usize::MAX, Node::path(id, vectorcraft_geom::shapes::line(a, b), look))?; made.push(id); } - d.retain_guides(sel, |i, _| !lines.iter().any(|l| l.0 == i)); + d.retain_guides(sel, |g| !lines.iter().any(|l| Some(l.0) == g.id)); sel.set(paths.iter().copied().chain(made)); Ok(()) })?; @@ -745,12 +745,12 @@ fn lock_guides(s: &mut Session, p: &Value) -> Result { fn clear_guides(s: &mut Session, _: &Value) -> Result { let st = s.doc()?; let paths = guide_paths(&st.doc); - let n = paths.len() + st.doc.guides.len(); + let n = paths.len() + st.doc.ruler_guides().len(); if n == 0 { return Ok(json!({ "count": 0 })); } - s.edit("Clear Guides", |d, _| { - d.guides.clear(); + s.edit("Clear Guides", |d, sel| { + d.retain_guides(sel, |_| false); let mut gone = Beside::replacing(d, &paths); for id in &paths { if d.node(*id).is_some() { @@ -780,17 +780,29 @@ fn guide_add(s: &mut Session, p: &Value) -> Result { // Where new art goes (refused when no layer is shown and unlocked), or that group's layer. None => st.target_parent()?.and_then(|p| if st.doc.node(p).is_some_and(Node::is_layer) { Some(p) } else { st.doc.layer_containing(p) }), }; - let i = s.edit("New Guide", |d, _| { - d.guides.push(Guide { artboard, layer, ..Guide::new(vertical, pos) }); - Ok(d.guides.len() - 1) - })?; + let id = s.edit("New Guide", |d, _| Ok(d.add_ruler_guide(&Guide { artboard, layer, ..Guide::new(vertical, pos) })))?; + let i = guide_indexes(&s.doc()?.doc, &[id]).first().copied(); Ok(json!({ "index": i })) } +/// The ids of the active document's ruler guides by index: the order of `guide.list` (ascending +/// id). +pub(crate) fn guide_ids(d: &vectorcraft_doc::Document) -> Vec { + d.ruler_guides().into_iter().filter_map(|g| g.id).collect() +} + +/// The `guide.list` indexes of ruler guides `ids`, in the order of `ids` (an id that is not a ruler +/// guide is left out). +pub fn guide_indexes(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec { + let all = guide_ids(d); + ids.iter().filter_map(|id| all.binary_search(id).ok()).collect() +} + fn guide_list(s: &mut Session, _: &Value) -> Result { let st = s.doc()?; let row = |(i, g): (usize, &Guide)| { - let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": st.selection.guides.contains(&i) }); + let selected = g.id.is_some_and(|id| st.selection.guides.contains(&id)); + let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": selected }); if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) { r["artboard"] = json!(ab); } @@ -801,31 +813,38 @@ fn guide_list(s: &mut Session, _: &Value) -> Result { r["editable"] = json!(st.doc.guide_editable(g)); r }; - Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect())) + Ok(Value::Array(st.doc.ruler_guides().iter().enumerate().map(row).collect())) } -/// Ruler guide `index` of the active document (`index` validated). -fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { +/// The id of ruler guide `index` in `ids` ([`guide_ids`]; `index` validated). +fn guide_index_in(ids: &[NodeId], v: Option<&Value>, cmd: &str) -> Result { let i = v.and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "an index must be a whole number"))?; - if i >= s.doc()?.doc.guides.len() { - return Err(bad(cmd, format!("no guide {i}"))); + ids.get(i).copied().ok_or_else(|| bad(cmd, format!("no guide {i}"))) +} + +/// The id of ruler guide `index` of the active document (`index` validated). +fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { + guide_index_in(&guide_ids(&s.doc()?.doc), v, cmd) +} + +/// Is ruler guide `id` one that can be moved or deleted: not on a hidden or locked layer? +fn check_editable_guide(s: &Session, id: NodeId) -> Result { + let d = &s.doc()?.doc; + if !d.ruler_guide(id).is_some_and(|g| d.guide_editable(&g)) { + return Err(EngineError::Other("the guide is on a hidden or locked layer".into())); } - Ok(i) + Ok(id) } /// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer. -fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { - let i = guide_index(s, v, cmd)?; - let d = &s.doc()?.doc; - if !d.guides.get(i).is_some_and(|g| d.guide_editable(g)) { - return Err(EngineError::Other("the guide is on a hidden or locked layer".into())); - } - Ok(i) +fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { + check_editable_guide(s, guide_index(s, v, cmd)?) } fn guide_select(s: &mut Session, p: &Value) -> Result { let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?; - let picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::>>()?; + let ids = guide_ids(&s.doc()?.doc); + let picked = list.iter().map(|v| guide_index_in(&ids, Some(v), "guide.select")).collect::>>()?; if bool_or(p, "toggle", false) { // Added to (or taken from) what is selected, the art included. s.select(|_, sel| { @@ -836,12 +855,13 @@ fn guide_select(s: &mut Session, p: &Value) -> Result { } else { s.select(|_, sel| sel.set_guides(picked))?; } - Ok(json!({ "selected": s.doc()?.selection.guides })) + let st = s.doc()?; + Ok(json!({ "selected": guide_indexes(&st.doc, &st.selection.guides) })) } /// Delete ruler guide `index`, else the selected guides, in one undo step → {count}. pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { - let gone: Vec = match p.get("index") { + let gone: Vec = match p.get("index") { Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?], None => s.doc()?.selection.guides.clone(), }; @@ -850,7 +870,7 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { } let label = if gone.len() == 1 { "Delete Guide" } else { "Delete Guides" }; s.edit(label, |d, sel| { - d.retain_guides(sel, |i, _| !gone.contains(&i)); + d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.contains(&id))); Ok(()) })?; Ok(json!({ "count": gone.len() })) @@ -866,18 +886,22 @@ fn guide_set_layer(s: &mut Session, p: &Value) -> Result { if super::layer::locked_within(d, layer) { return Err(EngineError::Other("the layer or group is locked".into())); } - let moving: Vec = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) { + let moving: Vec = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) { (Some(v), _) => vec![editable_guide_index(s, Some(v), C)?], - (None, Some(list)) => list.iter().take(10_000).map(|v| editable_guide_index(s, Some(v), C)).collect::>>()?, + (None, Some(list)) => { + let ids = guide_ids(&s.doc()?.doc); + list.iter().take(10_000).map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::>>()? + } (None, None) => s.doc()?.selection.guides.clone(), }; if moving.is_empty() { return Err(bad(C, "no guide selected (or give an index)")); } s.edit("Move Guide to Layer", |d, _| { - for i in &moving { - if let Some(g) = d.guides.get_mut(*i) { - g.layer = Some(layer); + // Each keeps its id (and so its index), at the top of the layer. + for id in &moving { + if d.parent_of(*id) != Some(layer) && d.ruler_guide(*id).is_some() { + d.move_node(*id, Some(layer), usize::MAX)?; } } Ok(()) @@ -891,9 +915,7 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result { let i = editable_guide_index(s, Some(v), "guide.move")?; let pos = f64_req(p, "pos", "guide.move")?; s.edit("Move Guide", |d, _| { - if let Some(g) = d.guides.get_mut(i) { - g.pos = pos; - } + d.edit_ruler_guide(i, |g| g.pos = pos); Ok(()) })?; return ok(); @@ -917,18 +939,14 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result { s.edit(if copy { "Copy Guide" } else { "Move Guide" }, |d, sel| { let mut copies = vec![]; for &i in &moving { - let Some(g) = d.guides.get_mut(i) else { continue }; - let moved = g.moved(Vec2::new(dx, dy)); if copy { - copies.push(moved); + copies.extend(d.ruler_guide(i).map(|g| d.add_ruler_guide(&g.moved(Vec2::new(dx, dy))))); } else { - *g = moved; + d.edit_ruler_guide(i, |g| *g = g.moved(Vec2::new(dx, dy))); } } if copy { - let first = d.guides.len(); - d.guides.extend(copies); - sel.set_guides(first..d.guides.len()); + sel.set_guides(copies); } Ok(()) })?; diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 1d2f006f1..8946aa435 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -92,7 +92,7 @@ pub fn specs() -> Vec { "Eraser", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one", + "{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one", has_doc, erase_region ), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3558f1b56..7a8a91f0a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -991,8 +991,8 @@ fn copy(s: &mut Session, p: &Value) -> Result { .selection .guides .iter() - .filter_map(|i| st.doc.guides.get(*i)) - .map(|g| vectorcraft_doc::Guide { layer: None, artboard: None, ..g.clone() }) + .filter_map(|i| st.doc.ruler_guide(*i)) + .map(|g| vectorcraft_doc::Guide { layer: None, artboard: None, id: None, ..g }) .collect(); let roots = if st.selection.is_empty() && !guides.is_empty() { vec![] } else { targets }; let mut clip = Clipboard::copy(st, &roots, direct); @@ -1004,7 +1004,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { // Guides alone: the artboard the first one belongs to, or crosses, is where in-place pastes // measure from, as the objects' artboard is. if clip.nodes.is_empty() - && let Some(g) = st.selection.guides.first().and_then(|i| st.doc.guides.get(*i)) + && let Some(g) = st.selection.guides.first().and_then(|i| st.doc.ruler_guide(*i)) { let board = g.artboard.and_then(|id| st.doc.artboards.iter().find(|a| a.id == id)).or_else(|| { st.doc @@ -1053,7 +1053,7 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { s.edit("Cut", |d, selection| { // The selected ruler guides were copied too (#3226): they go in the same undo step. let gone = selection.guides.clone(); - d.retain_guides(selection, |i, _| !gone.contains(&i)); + d.retain_guides(selection, |g| !g.id.is_some_and(|id| gone.contains(&id))); let mut survivors = vec![]; let mut pieces = super::pathops::Beside::replacing(d, roots); for id in roots { @@ -1153,7 +1153,7 @@ fn clear(s: &mut Session, p: &Value) -> Result { } if with_guides { let gone = sel.guides.clone(); - d.retain_guides(sel, |i, _| !gone.contains(&i)); + d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.contains(&id))); } if is_explicit { for id in &ids { @@ -1438,8 +1438,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho for g in &clip.guides { let pos = g.pos + if g.vertical { dv.x } else { dv.y }; if pos.is_finite() { - sel.guides.push(d.guides.len()); - d.guides.push(vectorcraft_doc::Guide { pos, layer: guide_layer, ..g.clone() }); + sel.guides.push(d.add_ruler_guide(&vectorcraft_doc::Guide { pos, layer: guide_layer, ..g.clone() })); } } } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 89d4f5011..30d927a96 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -889,6 +889,10 @@ fn expand_pieces( let mut seen = std::collections::HashSet::from([id]); let mut expanded = std::collections::HashMap::new(); for c in m.children_mut().into_iter().flatten() { + // A layer's ruler guides are not art: they keep their ids and expand to nothing. + if c.is_ruler_guide() { + continue; + } let c = Arc::make_mut(c); let mut listed = None; if all || c.id == id { @@ -1084,6 +1088,11 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br // The place of each member in the walk of `n`: after `n` and the members before it. let mut at = 1; for c in n.children_mut().into_iter().flatten() { + if c.is_ruler_guide() { + // A layer's ruler guide expands to nothing, but the walk of `n` still visits it. + at += 1; + continue; + } let from = out.len(); match members.remove(&c.id) { Some(listed) => out.extend(listed), @@ -1097,7 +1106,8 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br for (_, place) in &mut out[from..] { *place += at; } - at += c.count(); + // Every node the walk visits, ruler guides in a sublayer too (`Node::count` leaves them out). + c.walk(&mut |_| at += 1); } } changed.then_some(n) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index ffb58d9d4..8dce25b11 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -534,7 +534,7 @@ fn expand_piece_leaves( } return; } - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { expand_piece_leaves(d, Arc::make_mut(c), left, expanded, expand); } } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 1b217e6c2..58fff3cad 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -306,10 +306,10 @@ pub(super) fn outlined_if_asked(mut doc: Document, warnings: Vec, opts: (doc, warnings) } -/// Does `doc` hold no art: only layers, with nothing in them? +/// Does `doc` hold no art: only layers, with nothing in them but ruler guides? fn draws_nothing(doc: &Document) -> bool { let mut art = false; - doc.walk(|n| art |= !n.is_layer()); + doc.walk(|n| art |= !n.is_layer() && !n.is_ruler_guide()); !art } diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index 409db3f1b..c1366dba3 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -621,7 +621,7 @@ pub(crate) fn flatten_expanded(doc: &Document, o: &FlattenOptions) -> Result>()); + // A sublayer that becomes flat art gives its ruler guides to the layer that held it. + let guides = held_guides(d, plan.flat.iter().flat_map(|f| f.roots.iter().copied())); for f in plan.flat { // A group always has objects. let Some(top) = place_of(d, &f.roots) else { continue }; @@ -703,6 +705,9 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan if !flat.is_empty() { d.insert_beside(flat, true)?; } + for (layer, guide) in guides { + d.insert(Some(layer), usize::MAX, guide)?; + } let brushes = if o.strokes_to_outlines { vectorcraft_brush::library(d) } else { vec![] }; for mut id in plan.kept { if o.text_to_outlines { @@ -747,6 +752,23 @@ fn place_of(d: &Document, roots: &[NodeId]) -> Option { Some(place.map_or(top, |(r, _)| *r)) } +/// The ruler guides of each of `roots` that is a sublayer (in its sublayers too), with the layer +/// that holds it, in order. Other objects hold none. +fn held_guides(d: &Document, roots: impl IntoIterator) -> Vec<(NodeId, Node)> { + let mut out = vec![]; + for id in roots { + let (Some(n), Some(parent)) = (d.node(id), d.parent_of(id)) else { continue }; + if n.holds_ruler_guides() { + n.walk(&mut |c| { + if c.is_ruler_guide() { + out.push((parent, c.clone())); + } + }); + } + } + out +} + /// An image of `r` (in a clip group when it is clipped). fn raster_node(d: &mut Document, r: Raster) -> Node { let key = unique_key(d, "flattened"); @@ -829,3 +851,30 @@ fn drop_overprints(n: &mut Node) { } } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::Session; + + #[test] + fn a_flattened_sublayer_gives_its_ruler_guides_to_its_layer() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + let sub = NodeId(s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()["id"].as_u64().unwrap()); + s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + s.execute("transparency.set", &json!({"ids": [r], "opacity": 50})).unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let g = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 5.0, artboard: None }); + let g = d.insert(Some(sub), usize::MAX, g).unwrap(); + let flat = flatten_document(&s.doc().unwrap().doc, &FlattenOptions::default()).unwrap().unwrap(); + assert!(flat.node(sub).is_none(), "the sublayer became flat art"); + assert!(flat.node(g).is_some_and(Node::is_ruler_guide), "its guide is kept"); + assert_eq!(flat.parent_of(g), Some(layer)); + assert_eq!(flat.children(Some(layer)).and_then(|c| c.last()).map(|c| c.id), Some(g)); + } +} diff --git a/crates/engine/src/cmd/flatten/report.rs b/crates/engine/src/cmd/flatten/report.rs index 3cac1ffe8..41b08b7a0 100644 --- a/crates/engine/src/cmd/flatten/report.rs +++ b/crates/engine/src/cmd/flatten/report.rs @@ -12,7 +12,9 @@ pub(super) fn target_roots(s: &Session, p: &Value) -> Result> { let mut ids = vec![]; for id in targets(s, p)? { match d.node(id) { - Some(n) if n.is_layer() => ids.extend(n.children().into_iter().flatten().map(|c| c.id)), + // Ruler guides are not art: they stay where they are. + Some(n) if n.is_layer() => ids.extend(n.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).map(|c| c.id)), + Some(n) if n.is_ruler_guide() => {} Some(_) => ids.push(id), None => {} } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 8a3485c49..c7926ddf8 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear or radial, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear or radial, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right), aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", has_doc, edit_gradient ), diff --git a/crates/engine/src/cmd/halftone.rs b/crates/engine/src/cmd/halftone.rs index bd6afd466..0c1d36d35 100644 --- a/crates/engine/src/cmd/halftone.rs +++ b/crates/engine/src/cmd/halftone.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "Vector Halftone…", ["Object"], None, - "{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"; #rrggbbaa and #rgba are refused), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg clockwise (45; 0: level rows of dots; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"; #rrggbbaa and #rgba are refused), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", has_selection, halftone )] diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 07571ad32..49b1015bf 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -243,7 +243,8 @@ pub(crate) fn accepts(d: &Document, parent: Option, n: &Node) -> bool { None => n.is_layer(), Some(p) => match d.node(p).map(|x| &x.kind) { Some(NodeKind::Layer { .. }) => true, - Some(NodeKind::Group { .. }) => !n.is_layer(), + // A ruler guide stays directly in a layer or sublayer. + Some(NodeKind::Group { .. }) => !n.is_layer() && !n.is_ruler_guide(), _ => false, }, } @@ -478,8 +479,12 @@ fn clip_toggle(s: &mut Session, p: &Value) -> Result { super::object::release_clip(d, id); return Ok(false); } - let top = - n.children().and_then(|c| c.last()).map(|c| c.id).ok_or_else(|| EngineError::Other("the layer has no objects to clip by".into()))?; + // The topmost object: a layer's ruler guides above its art do not clip. + let top = n + .children() + .and_then(|c| c.iter().rev().find(|c| !c.is_ruler_guide())) + .map(|c| c.id) + .ok_or_else(|| EngineError::Other("the layer has no objects to clip by".into()))?; super::object::make_clipping_path(d, top)?; d.move_node(top, Some(id), 0)?; if let Some(n) = d.node_mut(id) { @@ -556,9 +561,8 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("a document needs at least one layer".into())); } s.edit("Delete Selection", |d, sel| { - d.remove_many(&ids)?; // The guides on the deleted layers go with them. - d.drop_guides_of_deleted_layers(sel); + d.remove_many(&ids)?; sel.prune(d); Ok(()) })?; @@ -577,10 +581,10 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result { return Err(bad("layer.duplicate", "no such rows")); } let copies = s.edit("Duplicate Selection", |d, _| { - super::edit::copy_above(d, &ids, |d, n, c| { + // A copied layer's ruler guides are copied with it. + super::edit::copy_above(d, &ids, |_, n, c| { if n.is_layer() { c.name = Some(format!("{} copy", n.display_name())); - d.copy_layer_guides(n, c); } }) })?; @@ -706,6 +710,9 @@ fn select_art(s: &mut Session, p: &Value) -> Result { vec![] } else if n.is_layer() { n.layer_art(true) + } else if n.is_ruler_guide() { + // A ruler guide row selects the guide through `guide.select`, not as art. + vec![] } else { vec![id] }; @@ -784,11 +791,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { let mut nodes = vec![]; for id in &ids { let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?; - let c = d.reid(&n); - if n.is_layer() { - d.copy_layer_guides(&n, &c); - } - nodes.push(Arc::new(c)); + // A copied layer's ruler guides are copied with it. + nodes.push(Arc::new(d.reid(&n))); } nodes } else { @@ -810,6 +814,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { let ch = d.children_mut(parent)?; let index = index.min(ch.len()); ch.splice(index..index, nodes); + // A layer's ruler guides stay above its art, each in the order it now has. + ch.sort_by_key(|c| c.is_ruler_guide()); // Copied type whose layout caches `reid` dropped has them again. if copy { super::typecmd::refresh_bounds_of(d, &out); @@ -972,7 +978,7 @@ fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indi for &i in indices.iter().rev() { let id = d.artboards.remove(i).id; d.retire_artboard_id(id); - d.retain_guides(sel, |_, g| g.artboard != Some(id)); + d.retain_guides(sel, |g| g.artboard != Some(id)); } Ok(()) } diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index 0a81a947e..95c152e95 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -255,9 +255,9 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> { let mut all = std::mem::take(ch); all.extend(taken); all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id))); + // Ruler guides (the merged layers' among them) stay above the art, each kind in that order. + all.sort_by_key(|c| c.is_ruler_guide()); *ch = all; - // The merged layers' guides go to it too. - d.rehome_guides(into); Ok(()) } @@ -318,9 +318,8 @@ fn flatten(s: &mut Session, p: &Value) -> Result { let others: Vec = d.layers.iter().filter(|l| l.id != into && l.visible && !l.is_template()).map(|l| l.id).collect(); let hidden: Vec = d.layers.iter().filter(|l| l.id != into && !l.visible && !l.is_template()).map(|l| l.id).collect(); s.edit("Flatten Artwork", |d, sel| { - d.remove_many(&hidden)?; // Hidden layers are discarded with their guides. - d.drop_guides_of_deleted_layers(sel); + d.remove_many(&hidden)?; merge_into(d, into, &others)?; sel.prune(d); sel.deselect_uneditable(d); @@ -349,8 +348,8 @@ fn release(s: &mut Session, p: &Value) -> Result { NodeKind::Group { .. } => true, _ => return Err(bad(C, "release a layer or a group")), }; - // Its objects, bottom first (sublayers stay as they are). - let objects: Vec = n.children().into_iter().flatten().filter(|c| !c.is_layer()).map(|c| c.id).collect(); + // Its objects, bottom first (sublayers and ruler guides stay as they are). + let objects: Vec = n.children().into_iter().flatten().filter(|c| !c.is_layer() && !c.is_ruler_guide()).map(|c| c.id).collect(); if objects.is_empty() { return Err(EngineError::Other("there are no objects to release".into())); } @@ -386,7 +385,8 @@ fn release(s: &mut Session, p: &Value) -> Result { } let new: Vec = layers.iter().map(|l| l.id).collect(); let ch = d.children_mut(Some(parent))?; - let at = index.min(ch.len()); + // Below the parent's ruler guides. + let at = index.min(Node::guide_run_start(ch)); ch.splice(at..at, layers); // Copied type whose layout caches `reid` dropped has them again. if build { diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index f2d322a39..debd0db68 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -259,10 +259,12 @@ pub(crate) fn follow_history(st: &mut crate::DocState, was: Option) { /// mode when the object or the layer is gone (e.g. deleted). pub(crate) fn sync(d: &mut Document) { let Some(me) = d.mask_edit else { return }; - let art: Option>> = d.node(me.layer).and_then(|l| l.children().cloned()); + // The layer's art: ruler guides on it are not part of the mask. + let art: Option>> = + d.node(me.layer).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()); let (Some(art), true) = (art, d.node(me.object).is_some_and(|n| n.extras().mask.is_some())) else { d.mask_edit = None; - let _ = d.remove(me.layer); + let _ = d.remove_edit_layer(me.layer); return; }; let new_art = match art.as_slice() { diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index b32275d1b..c3a289857 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -102,15 +102,15 @@ fn items_bounds(d: &Document, ids: &[NodeId], m: Measure) -> Vec<(NodeId, Rect)> ids.iter().filter_map(|id| Some((*id, m.of(d, *id)?))).collect() } -/// Move each object of `moves` by its offset and each ruler guide of `guides` (index, position) +/// Move each object of `moves` by its offset and each ruler guide of `guides` (id, position) /// to its position, as undo step `label`; pattern tiles move too when [`transform_patterns`] says /// so for `ids`. When every move is a rounding error (1e-9 pt or less on both axes), the document /// and its undo history stay as they are. -fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(usize, f64)]) -> Result { +fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(NodeId, f64)]) -> Result { let sc = Scaling { patterns: transform_patterns(s, p, ids)?, ..Scaling::default() }; - let guides: Vec<(usize, f64)> = { + let guides: Vec<(NodeId, f64)> = { let d = &s.doc()?.doc; - guides.iter().copied().filter(|&(i, pos)| d.guides.get(i).is_some_and(|g| (g.pos - pos).abs() > 1e-9)).collect() + guides.iter().copied().filter(|&(i, pos)| d.ruler_guide(i).is_some_and(|g| (g.pos - pos).abs() > 1e-9)).collect() }; if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) && guides.is_empty() { return ok(); @@ -124,9 +124,7 @@ fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: & super::pixelgrid::keep_compound_aligned(d, *id, sc); } for &(i, pos) in &guides { - if let Some(g) = d.guides.get_mut(i) { - g.pos = pos; - } + d.edit_ruler_guide(i, |g| g.pos = pos); } Ok(()) })?; @@ -143,7 +141,7 @@ fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> { /// [`vectorcraft_doc::Selection::key_guide`]): its position, and whether it is vertical. fn key_guide(s: &Session, p: &Value) -> Option<(f64, bool)> { let st = s.doc().ok()?; - let g = st.doc.guides.get(st.selection.key_guide?)?; + let g = st.doc.ruler_guide(st.selection.key_guide?)?; (align_to(s, p) == Some("key")).then_some((g.pos, g.vertical)) } @@ -164,7 +162,7 @@ fn align_reference(s: &Session, p: &Value, ids: &[NodeId], m: Measure, own: Opti /// Bounds of the selected ruler guides (each guide is a zero-thickness line), for Align to /// Selection when no objects are selected. fn selected_guides_bounds(st: &crate::DocState) -> Option { - st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option, g| { + st.selection.guides.iter().filter_map(|&i| st.doc.ruler_guide(i)).fold(None, |acc: Option, g| { let r = if g.vertical { Rect::new(g.pos, 0.0, g.pos, 0.0) } else { Rect::new(0.0, g.pos, 0.0, g.pos) }; Some(acc.map(|a| a.union(r)).unwrap_or(r)) }) @@ -264,7 +262,7 @@ pub(super) fn align(s: &mut Session, p: &Value) -> Result { /// `v` names. Align to the key object (or key guide) moves them too, and the key guide stays put. /// Align to the artboard moves them (#3463). Align to the selection moves them when only guides are /// selected; with objects in the selection they stay put. -fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result> { +fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result> { let st = s.doc()?; let to = align_to(s, p); let move_guides = match to { @@ -290,7 +288,7 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str> .guides .iter() .filter(|&&i| !skip_key || Some(i) != st.selection.key_guide) - .filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?))) + .filter_map(|&i| Some((i, at(st.doc.ruler_guide(i)?.vertical)?))) .collect()) } @@ -361,7 +359,7 @@ pub(super) fn can_distribute(s: &Session) -> std::result::Result<(), String> { #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Spaced { Object(NodeId), - Guide(usize), + Guide(NodeId), } /// The selected objects `ids` with their bounds (measured by `m`), and the selected @@ -371,7 +369,7 @@ fn spaced_items(s: &Session, ids: &[NodeId], m: Measure, horiz: bool) -> Result< let st = s.doc()?; let objects = items_bounds(&st.doc, ids, m).into_iter().map(|(id, r)| (Spaced::Object(id), r)); let guides = st.selection.guides.iter().filter_map(|&i| { - let g = st.doc.guides.get(i).filter(|g| g.vertical == horiz)?; + let g = st.doc.ruler_guide(i).filter(|g| g.vertical == horiz)?; Some((Spaced::Guide(i), Rect::new(g.pos, g.pos, g.pos, g.pos))) }); Ok(objects.chain(guides).collect()) @@ -404,7 +402,7 @@ fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz: for &(it, delta) in deltas { match it { Spaced::Object(id) => moves.push((id, along(delta))), - Spaced::Guide(i) => guides.extend(d.guides.get(i).map(|g| (i, g.pos + delta))), + Spaced::Guide(i) => guides.extend(d.ruler_guide(i).map(|g| (i, g.pos + delta))), } } apply_moves(s, label, p, ids, &moves, &guides) diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index 7aef60fca..ff5b950d0 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -39,7 +39,9 @@ pub(super) fn arrange(s: &mut Session, how: Arrange) -> Result { group.retain(|id| d.position(*id).is_some_and(|(_, idx, _)| idx >= floor)); } for (k, id) in group.iter().enumerate() { - let Some((_, idx, len)) = d.position(*id) else { continue }; + let Some((_, idx, _)) = d.position(*id) else { continue }; + // The parent's art, without the ruler guides that stay above it. + let len = d.children(par).map_or(0, |ch| Node::guide_run_start(ch)); if len <= floor { continue; } @@ -73,12 +75,16 @@ pub(super) fn send_to_current_layer(s: &mut Session, _: &Value) -> Result } // When the selected objects are already the topmost in the current layer, in paint order, the // document and its undo history stay as they are. - if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) { + // (The layer's ruler guides stay above its art.) + let art = |c: &&std::sync::Arc| !c.is_ruler_guide(); + if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().filter(art).map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) { return ok(); } s.edit("Send to Current Layer", |d, _| { let moved = d.remove_many(&ids)?; - d.children_mut(Some(layer))?.extend(moved); + let ch = d.children_mut(Some(layer))?; + let at = Node::guide_run_start(ch); + ch.splice(at..at, moved); Ok(()) })?; ok() @@ -176,7 +182,7 @@ fn selected_lock_hide_roots(s: &Session) -> Result> { fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec { let mut v = vec![]; d.walk(|n| { - if !n.is_layer() && f(n) { + if !n.is_layer() && !n.is_ruler_guide() && f(n) { v.push(n.id) } }); @@ -198,7 +204,7 @@ fn collect_flag_in_scope(s: &Session, cmd: &str, p: &Value, f: impl Fn(&Node) -> if let Some(node) = d.node(root) { for child in node.children().into_iter().flatten() { child.walk(&mut |n| { - if !n.is_layer() && f(n) { + if !n.is_layer() && !n.is_ruler_guide() && f(n) { ids.push(n.id); } }); @@ -222,7 +228,7 @@ fn collect_flag_in_scope(s: &Session, cmd: &str, p: &Value, f: impl Fn(&Node) -> } for c in d.node(*g).and_then(Node::children).into_iter().flatten() { c.walk(&mut |n| { - if !n.is_layer() && f(n) { + if !n.is_layer() && !n.is_ruler_guide() && f(n) { v.push(n.id) } }); diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 2f0aa742d..eb019a88e 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -61,7 +61,7 @@ pub fn specs() -> Vec { "Shear…", ["Object", "Transform"], None, - "{ids?: [id…] (selected first; default: the selection), angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{ids?: [id…] (selected first; default: the selection), angle: deg (positive turns the vertical edges clockwise with the horizontal axis, the top to the right; the horizontal edges clockwise with the vertical axis, the right side down), axis?: \"horizontal\"|\"vertical\", origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, shear ), @@ -297,7 +297,7 @@ pub fn specs() -> Vec { "Live Shape Properties", [], None, - "{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Relative rounding, as in the reference app: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)", + "{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Relative rounding, as in the reference app: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counter-clockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counter-clockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)", has_selection, set_live_shape ), diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index 618486097..d10206894 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -145,16 +145,14 @@ fn apply_transform_moving_guides( } None if copy => { let guide_copies = if with_guides { - let copies: Vec<_> = sel.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect(); + let copies: Vec<_> = sel.guides.iter().filter_map(|&i| d.ruler_guide(i)).collect(); (!copies.is_empty()).then_some(copies) } else { None }; let new_ids = duplicate_in(d, sel, &ids, Affine::IDENTITY)?; if let Some(copies) = guide_copies { - let first = d.guides.len(); - d.guides.extend(copies); - sel.guides = (first..d.guides.len()).collect(); + sel.guides = copies.iter().map(|g| d.add_ruler_guide(g)).collect(); } new_ids } @@ -177,9 +175,7 @@ fn apply_transform_moving_guides( } if with_guides && (delta.x != 0.0 || delta.y != 0.0) { for i in sel.guides.clone() { - if let Some(g) = d.guides.get_mut(i) { - *g = g.moved(delta); - } + d.edit_ruler_guide(i, |g| *g = g.moved(delta)); } } Ok(targets) @@ -224,10 +220,8 @@ pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: boo let ids = duplicate_in(Arc::make_mut(&mut doc), &mut selection, roots, Affine::IDENTITY)?; if has_guide_copies { let d = Arc::make_mut(&mut doc); - let first = d.guides.len(); - let copies: Vec<_> = st.selection.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect(); - d.guides.extend(copies); - selection.guides = (first..d.guides.len()).collect(); + let copies: Vec<_> = st.selection.guides.iter().filter_map(|&i| d.ruler_guide(i)).collect(); + selection.guides = copies.iter().map(|g| d.add_ruler_guide(g)).collect(); } let k = Arc::new(crate::PreviewCopies { from: it.doc.clone(), roots: roots.to_vec(), doc, selection, ids }); it.copies = Some(k.clone()); diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index 7906a68e8..1e8d9ced6 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -214,7 +214,7 @@ fn attributes(s: &Session, p: &Value) -> Result { /// Overprint Black on `n` and its descendants; returns the objects changed. fn overprint_black_tree(n: &mut Node, op: &OverprintBlack, spots: &[String]) -> usize { let mut changed = usize::from(op.apply(n, spots)); - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { changed += overprint_black_tree(Arc::make_mut(c), op, spots); } changed diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 4cc4bbc2e..b68378ba9 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -242,6 +242,10 @@ fn same_in_proxy(a: &Paint, b: &Paint) -> bool { /// change ([`leaf_targets`]): groups and layers stand for their contents (and so do blends and /// other containers inside them), a compound path for its parts. pub(crate) fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) { + // A ruler guide has no paints. + if n.is_ruler_guide() { + return; + } let expand = match n.kind { NodeKind::Group { .. } | NodeKind::Layer { .. } => true, NodeKind::Compound { .. } => false, diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 9f50507af..b8bdd24d0 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", has_doc, set_format ), @@ -352,7 +352,7 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result { /// The text between the braces of the object `doc` starts with and what follows it, or why it /// has none. -fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> { +pub(crate) fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> { // (Named, not written in the `Err`: these are for the tests of the descriptions, not messages // the app shows, which the message catalogs would have to translate.) let (no_object, not_closed) = ("the description doesn't start with the params object `{…}`", "the params object `{…}` isn't closed"); @@ -78,7 +78,7 @@ fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> { } /// `s` split at each `sep` that is outside brackets, quotes and backticks. -fn split_top(s: &str, sep: char) -> Vec<&str> { +pub(crate) fn split_top(s: &str, sep: char) -> Vec<&str> { let (mut out, mut depth, mut quote, mut start) = (Vec::new(), 0usize, None, 0); for (i, c) in s.char_indices() { match (quote, c) { @@ -98,7 +98,7 @@ fn split_top(s: &str, sep: char) -> Vec<&str> { out } -fn is_key(s: &str) -> bool { +pub(crate) fn is_key(s: &str) -> bool { let mut chars = s.chars(); chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index d00ba7192..712ca8a17 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -289,9 +289,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge } if is_translation && (delta.x != 0.0 || delta.y != 0.0) { for &i in &sel.guides { - if let Some(g) = doc.guides.get_mut(i) { - *g = g.moved(delta); - } + doc.edit_ruler_guide(i, |g| *g = g.moved(delta)); } } Ok(()) @@ -1232,7 +1230,7 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { s.edit("Clear", |d, selection| { if with_guides { let gone = selection.guides.clone(); - d.retain_guides(selection, |i, _| !gone.contains(&i)); + d.retain_guides(selection, |g| !g.id.is_some_and(|id| gone.contains(&id))); } let targets: BTreeSet = whole.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer())).collect(); let whole_ids: Vec = targets diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 3da73c038..5b9e25d35 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -1351,7 +1351,9 @@ fn split_into_grid(s: &mut Session, p: &Value) -> Result { let node = shape_node(d, c, Some(&top)); out.push(d.insert(par, idx + k, node)?); } - d.guides.extend(edges.iter().map(|&(vertical, pos)| vectorcraft_doc::Guide { layer, ..vectorcraft_doc::Guide::new(vertical, pos) })); + for &(vertical, pos) in &edges { + d.add_ruler_guide(&vectorcraft_doc::Guide { layer, ..vectorcraft_doc::Guide::new(vertical, pos) }); + } sel.set(out.iter().copied()); Ok(out) })?; diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index ef7820b71..4075676ca 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -116,7 +116,7 @@ pub fn specs() -> Vec { "Mirror", ["Object", "Repeat"], None, - "{ids?, angle?: deg (90 = vertical axis), offset?: pt (10)} mirror repeat of the selection → {id}", + "{ids?, angle?: deg clockwise from level (the mirror axis; 90 = vertical), offset?: pt (10)} mirror repeat of the selection → {id}", has_selection, repeat_mirror ), @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "Options…", ["Object", "Repeat"], None, - "{instances?, radius?, reverseOverlap?, startAngle?, endAngle?, center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, brickOffset?, flipRows?, flipCols? | angle?} set the options of the selected repeats", + "{instances?, radius?, reverseOverlap?, startAngle?, endAngle?: deg clockwise (0: where the source art sits), center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, brickOffset?, flipRows?, flipCols? | angle?: deg clockwise from level (the mirror axis; 90 = vertical)} set the options of the selected repeats", has_repeat, repeat_options ), @@ -271,8 +271,9 @@ fn enter_edit(d: &mut Document, sel: &mut Selection, name: &str, original: Optio /// Leave editing mode: drop the layer, return the edit record. fn leave_edit(d: &mut Document, sel: &mut Selection) -> Result<(PatternEdit, Vec>)> { let pe = d.pattern_edit.take().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?; - let art = match d.remove(pe.layer) { - Ok(l) => l.children().cloned().unwrap_or_default(), + // The tile art: ruler guides on the edit layer are not part of it and stay in the document. + let art = match d.remove_edit_layer(pe.layer) { + Ok(l) => l.children().into_iter().flatten().cloned().collect(), Err(_) => vec![], }; sel.clear(); @@ -427,7 +428,8 @@ fn pattern_save_copy(s: &mut Session, p: &Value) -> Result { let name = s.edit("Save a Copy", |d, _| { let pe = d.pattern_edit.clone().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?; let mut def = d.pattern(&pe.pattern).cloned().ok_or_else(|| EngineError::Other("pattern missing".into()))?; - let art: Vec> = d.node(pe.layer).and_then(|l| l.children().cloned()).unwrap_or_default(); + let art: Vec> = + d.node(pe.layer).into_iter().flat_map(|l| l.children().into_iter().flatten()).filter(|c| !c.is_ruler_guide()).cloned().collect(); let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern.trim()))); def.name = name.clone(); def.art = copies(d, &art).into_iter().map(Arc::new).collect(); @@ -481,7 +483,8 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { let out_name = new_name.clone().unwrap_or(name.clone()); s.edit("Pattern Options", |d, _| { // While editing, the tile art is the edit layer's. - let live_art: Option>> = edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children().cloned()); + let live_art: Option>> = + edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()); let def = d.pattern_mut(&name).ok_or_else(|| EngineError::Other("pattern missing".into()))?; def.tile_type = tt; def.overlap = overlap; diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index db9bbf67d..1ded346d8 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -377,7 +377,7 @@ fn placeable(l: &Node) -> bool { fn art_of<'a>(nodes: impl Iterator>) -> Vec { nodes .filter_map(|c| match &c.kind { - NodeKind::Path { guide: true, .. } => None, + _ if c.is_guide() => None, NodeKind::Layer { children, clip, .. } => placeable(c).then(|| { let mut g = (**c).clone(); g.kind = NodeKind::Group { children: art_of(children.iter()).into_iter().map(Arc::new).collect(), clip: *clip }; diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 76e09641f..9fe4665a5 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Print Setup", [], None, - "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution, or lower for a page larger than 64 Mpx or 32,768 px a side), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", + "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (screen angle, degrees counter-clockwise as PostScript setscreen takes it; default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution, or lower for a page larger than 64 Mpx or 32,768 px a side), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", has_doc, setup ), diff --git a/crates/engine/src/cmd/printadvanced.rs b/crates/engine/src/cmd/printadvanced.rs index 537658447..8e1c3f5be 100644 --- a/crates/engine/src/cmd/printadvanced.rs +++ b/crates/engine/src/cmd/printadvanced.rs @@ -48,12 +48,13 @@ pub(crate) fn prepare(cmd: &str, doc: &Document, set: &PrintSettings, saved: &[F Ok(Some(flatten_document(&printed, &o)?.unwrap_or_else(|| printed.into_owned()))) } -/// Leave guide paths out of `nodes` (the renderer draws them; they never print). +/// Leave guides out of `nodes`: guide paths (the renderer draws them; they never print) and ruler +/// guides. fn drop_guides(nodes: &mut Vec>) { fn has_guides(n: &Node) -> bool { - matches!(n.kind, NodeKind::Path { guide: true, .. }) || n.children().is_some_and(|c| c.iter().any(|c| has_guides(c))) + n.is_guide() || n.children().is_some_and(|c| c.iter().any(|c| has_guides(c))) } - nodes.retain(|n| !matches!(n.kind, NodeKind::Path { guide: true, .. })); + nodes.retain(|n| !n.is_guide()); for n in nodes.iter_mut().filter(|n| has_guides(n)) { if let Some(children) = Arc::make_mut(n).children_mut() { drop_guides(children); diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index d41a4529b..566a16dbf 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -263,7 +263,7 @@ fn retain_effects(n: &mut Node, members: bool, keep: &dyn Fn(&Effect) -> bool) { it.effects_mut().retain(keep); } if members { - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { retain_effects(Arc::make_mut(c), true, keep); } } @@ -367,7 +367,9 @@ pub(crate) fn replace_with_image(mut v: Node, image: Node) -> Node { v.appearance = Default::default(); v.set_clips(false); if let Some(ch) = v.children_mut() { - *ch = vec![Arc::new(image)]; + // Its ruler guides stay above the image. + let guides = ch.split_off(Node::guide_run_start(ch)); + *ch = std::iter::once(Arc::new(image)).chain(guides).collect(); } v } diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 493ba68db..6f64528cc 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Run Script", [], None, - "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text) and $.includePath the #includepath list; alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine and #engine are refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text) and $.includePath the #includepath list; alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine and #engine are refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 4ef1e6bc6..25bf6db54 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -442,15 +442,16 @@ fn key(s: &mut Session, p: &Value) -> Result { if id.is_some() { return Err(bad("select.key", "give `id` or `guide`, not both")); } - if !sel.guides.contains(&g) { + let gid = super::docmenu::guide_ids(&s.doc()?.doc).get(g).copied(); + let Some(gid) = gid.filter(|id| sel.guides.contains(id)) else { return Err(bad("select.key", format!("guide {g} is not selected"))); - } + }; if sel.is_empty() { return Err(bad("select.key", "select objects to align to the guide")); } s.select(|_, sel| { sel.key = None; - sel.key_guide = Some(g); + sel.key_guide = Some(gid); })?; return key_result(s); } @@ -472,9 +473,9 @@ fn key(s: &mut Session, p: &Value) -> Result { /// `{key}`, with `guide` when a guide is the key. fn key_result(s: &Session) -> Result { - let sel = &s.doc()?.selection; - let mut out = json!({ "key": sel.key }); - if let Some(g) = sel.key_guide { + let st = s.doc()?; + let mut out = json!({ "key": st.selection.key }); + if let Some(g) = st.selection.key_guide.and_then(|id| super::docmenu::guide_indexes(&st.doc, &[id]).first().copied()) { out["guide"] = json!(g); } Ok(out) diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index 5792c44fd..06ec06793 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -340,8 +340,9 @@ fn release(s: &mut Session, _: &Value) -> Result { fn from_guides(s: &mut Session, _: &Value) -> Result { let d = &s.doc()?.doc; let region = d.slice_clip().ok_or_else(|| EngineError::Other("Create from Guides: the document has no artboard".into()))?; + let guides = d.ruler_guides(); let cuts = |vertical: bool, lo: f64, hi: f64| -> Vec { - let mut v: Vec = d.guides.iter().filter(|g| g.vertical == vertical && g.pos > lo && g.pos < hi).map(|g| g.pos).collect(); + let mut v: Vec = guides.iter().filter(|g| g.vertical == vertical && g.pos > lo && g.pos < hi).map(|g| g.pos).collect(); v.extend([lo, hi]); v.sort_by(f64::total_cmp); v.dedup(); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d73f01eaa..d31031d5d 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -153,7 +153,8 @@ fn descends(n: &Node, top: bool) -> bool { /// The objects whose appearance a style applied to `n` sets: `n`, or its painted contents. fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) { match n.children() { - Some(ch) if descends(n, top) => ch.iter().for_each(|c| painted(c, false, out)), + // A layer's ruler guides have no appearance to set. + Some(ch) if descends(n, top) => ch.iter().filter(|c| !c.is_ruler_guide()).for_each(|c| painted(c, false, out)), _ => out.push(n), } } diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index 22cfc74e9..a5f677f5d 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -248,7 +248,7 @@ pub(crate) fn leave(s: &mut Session, _: &Value) -> Result { /// the instance editing was entered from. The symbol already holds the layer's art ([`sync`]). pub(crate) fn finish(d: &mut Document, sel: &mut Selection) { let Some(se) = d.symbol_edit.take() else { return }; - let _ = d.remove(se.layer); + let _ = d.remove_edit_layer(se.layer); match se.instance.filter(|i| d.node(*i).is_some()) { Some(i) => sel.set([i]), None => sel.clear(), @@ -480,18 +480,24 @@ fn follow_objects(d: &mut Document, name: &str, old: (&[Arc], bool), new: /// an instance of the symbol itself. pub(crate) fn sync(before: &Document, d: &mut Document) -> Result<()> { let Some(se) = d.symbol_edit.clone() else { return Ok(()) }; - let art: Option>> = d.node(se.layer).and_then(|l| l.children().cloned()); + // The layer's art: ruler guides on it are not part of the symbol. + let art_of = |d: &Document| -> Option>> { + d.node(se.layer).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()) + }; + let art = art_of(d); let (Some(art), Some(index)) = (art, d.symbols.iter().position(|x| x.name == se.symbol)) else { d.symbol_edit = None; - let _ = d.remove(se.layer); + let _ = d.remove_edit_layer(se.layer); return Ok(()); }; // Entering, and edits that left the layer's art alone, write nothing. let same = |b: &[Arc]| b.len() == art.len() && b.iter().zip(&art).all(|(a, b)| Arc::ptr_eq(a, b)); let (old, old_group) = match before.symbol_edit.as_ref().filter(|b| b.layer == se.layer) { None => return Ok(()), - Some(_) if before.node(se.layer).and_then(|l| l.children()).is_some_and(|b| same(b)) => return Ok(()), - Some(b) => (before.node(se.layer).and_then(|l| l.children().cloned()).unwrap_or_default(), b.group), + Some(b) => match art_of(before) { + Some(old) if same(&old) => return Ok(()), + old => (old.unwrap_or_default(), b.group), + }, }; if art.iter().any(|n| holds_symbol(d, n, &se.symbol, &mut HashSet::new())) { return Err(EngineError::Other("a symbol can't hold an instance of itself".into())); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 79dcee713..3d1e0edd8 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -49,7 +49,7 @@ pub fn specs() -> Vec { "Touch Type", [], None, - "{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)", + "{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright)} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)", has_doc, touch_type ), diff --git a/crates/engine/src/cmd/views.rs b/crates/engine/src/cmd/views.rs index f4db493d6..560da3942 100644 --- a/crates/engine/src/cmd/views.rs +++ b/crates/engine/src/cmd/views.rs @@ -23,7 +23,9 @@ static NEW_VIEW: ParamSchema = ParamSchema::new( Param::new("name", Type::String).default_note("the first free \"View N\"").null_omitted(), Param::new("center", Type::Point), Param::new("zoom", Type::Number { min: Some(Bound::Exclusive(0.0)), max: None, unit: None }).doc("1 = 100%"), - Param::new("rotation", Type::Number { min: None, max: None, unit: Some("deg") }).defaults_to("0"), + Param::new("rotation", Type::Number { min: None, max: None, unit: Some("deg") }) + .defaults_to("0") + .doc("clockwise on screen, as view.rotate turns the view"), ], "save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once)", ) @@ -46,7 +48,8 @@ static EDIT_VIEWS: ParamSchema = ParamSchema::new( "rename or delete saved views, all in one undo step; names are those before the edit and must stay unique; no params → {views}", ); -static LIST_VIEWS: ParamSchema = ParamSchema::new(&[], "").returns("{views: [{name, center, zoom, rotation}]}").writes(Writes::Nothing); +static LIST_VIEWS: ParamSchema = + ParamSchema::new(&[], "").returns("{views: [{name, center, zoom, rotation (degrees clockwise)}]}").writes(Writes::Nothing); static TRANSPARENCY_GRID: ParamSchema = ParamSchema::new( &[Param::new("on", Type::Boolean).default_note("toggle").null_omitted()], @@ -86,7 +89,7 @@ pub fn specs() -> Vec { "New View…", ["View"], None, - "{name?: string (default: the first free \"View N\"), center: [x, y], zoom: number > 0 (1 = 100%), rotation?: number deg (default: 0)} save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once) → {name, index}", + "{name?: string (default: the first free \"View N\"), center: [x, y], zoom: number > 0 (1 = 100%), rotation?: number deg (default: 0) (clockwise on screen, as view.rotate turns the view)} save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once) → {name, index}", has_doc, new_view, schema: &NEW_VIEW @@ -101,7 +104,7 @@ pub fn specs() -> Vec { edit_views, schema: &EDIT_VIEWS ), - cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation}]}", has_doc, list_views, schema: &LIST_VIEWS), + cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation (degrees clockwise)}]}", has_doc, list_views, schema: &LIST_VIEWS), cmd!( query "view.transparencyGrid", "Show Transparency Grid", diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs index 6e89a7544..c79ec0b86 100644 --- a/crates/engine/src/cmd/wand.rs +++ b/crates/engine/src/cmd/wand.rs @@ -137,7 +137,7 @@ fn paintable(doc: &Document) -> Vec { if matches!(n.kind, NodeKind::Compound { .. }) { compounds.push(n.id); out.push(n.id); - } else if !n.is_container() { + } else if !n.is_container() && !n.is_ruler_guide() { out.push(n.id); } }); diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 3f0a641e2..27ebc9c40 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -430,6 +430,133 @@ impl ScriptScope { } Ok((real, bytes)) } + + /// The first granted folder, as people read it (`None` for a script given as text). + pub(crate) fn folder(&self) -> Option { + self.roots.first().map(|r| crate::cmd::findfiles::plain(r.clone())) + } + + /// Does the absolute path `path` name a granted folder or a place inside one, by its names + /// alone, without regard to case? A path that does not is refused before the file system is + /// asked anything about it. + fn names_inside(&self, path: &Path) -> bool { + let lower = |c: Component| c.as_os_str().to_string_lossy().to_lowercase(); + path.is_absolute() + && self.roots.iter().any(|r| { + let root = crate::cmd::findfiles::plain(r.clone()); + let mut names = path.components(); + root.components().all(|c| names.next().is_some_and(|n| lower(n) == lower(c))) + }) + } + + /// The real path of the file or folder at `path`, which must be absolute: refused as + /// [`ScriptScope::read`] refuses a path, and also when its names alone place it outside every + /// granted folder, in which case the file system is not asked about it. + pub(crate) fn locate(&self, path: &Path) -> Result { + if self.roots.is_empty() || Self::refused_by_name(path) || !self.names_inside(path) { + return Err(ScriptRead::Refused); + } + probed(); + if check_read(&path.to_string_lossy()).is_err() { + return Err(ScriptRead::Refused); + } + let real = match std::fs::canonicalize(path) { + Ok(real) => real, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(if std::fs::symlink_metadata(path).is_ok() { ScriptRead::Refused } else { ScriptRead::Missing }); + } + Err(e) => return Err(ScriptRead::Failed(e.to_string())), + }; + if !self.roots.iter().any(|r| inside(&real, r)) { + return Err(ScriptRead::Refused); + } + Ok(real) + } + + /// What the file or folder at `path` is, located as [`ScriptScope::locate`] locates it. + pub(crate) fn stat(&self, path: &Path) -> Result { + let real = self.locate(path)?; + let meta = std::fs::metadata(&real).map_err(|e| ScriptRead::Failed(e.to_string()))?; + let link = std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()); + #[cfg(windows)] + let hidden = std::os::windows::fs::MetadataExt::file_attributes(&meta) & 2 != 0; + #[cfg(not(windows))] + let hidden = real.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')); + Ok(ScriptEntry { + dir: meta.is_dir(), + file: meta.is_file(), + len: meta.len(), + created: meta.created().ok(), + modified: meta.modified().ok(), + hidden, + readonly: meta.permissions().readonly(), + link, + }) + } + + /// The names in the folder at `path` that `keep` keeps, with whether each is a folder, in the + /// order of their names. A name whose links lead out of every granted folder, or out of the + /// automation read roots in force, is left out. More + /// than `max` names kept, or more than [`MAX_LISTED`] names looked at, is + /// [`ScriptRead::TooLarge`]. + pub(crate) fn list(&self, path: &Path, max: usize, keep: impl Fn(&str) -> bool) -> Result, ScriptRead> { + let real = self.locate(path)?; + let entries = std::fs::read_dir(&real).map_err(|e| ScriptRead::Failed(e.to_string()))?; + let mut out = Vec::new(); + for (n, entry) in entries.enumerate() { + if n >= MAX_LISTED { + return Err(ScriptRead::TooLarge); + } + let Ok(entry) = entry else { continue }; + let name = entry.file_name().to_string_lossy().into_owned(); + if !keep(&name) { + continue; + } + let Ok(target) = std::fs::canonicalize(entry.path()) else { continue }; + if !self.roots.iter().any(|r| inside(&target, r)) || check_read(&entry.path().to_string_lossy()).is_err() { + continue; + } + if out.len() >= max { + return Err(ScriptRead::TooLarge); + } + out.push((name, target.is_dir())); + } + out.sort(); + Ok(out) + } +} + +/// The most names [`ScriptScope::list`] looks at in one folder. +pub(crate) const MAX_LISTED: usize = 65_536; + +/// A file or folder as [`ScriptScope::stat`] gives it. +#[derive(Clone, Debug)] +pub(crate) struct ScriptEntry { + pub dir: bool, + /// A regular file. + pub file: bool, + pub len: u64, + pub created: Option, + pub modified: Option, + pub hidden: bool, + pub readonly: bool, + /// The path itself is a symbolic link. + pub link: bool, +} + +thread_local! { + /// How many paths [`ScriptScope::locate`] has asked the file system about on this thread. + static PROBES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn probed() { + PROBES.with(|p| p.set(p.get().saturating_add(1))); +} + +/// How many paths [`ScriptScope::locate`] has asked the file system about on this thread. +#[cfg(test)] +pub(crate) fn script_probes() -> usize { + PROBES.with(std::cell::Cell::get) } #[cfg(test)] diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index c474e2575..52ef35060 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -112,6 +112,8 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts, symbols: &SymbolBoxes) -> _ => {} } if let Some(ch) = n.children() { + // A layer's ruler guides are listed by `guide.list`, not as its objects. + let ch: Vec<&std::sync::Arc> = ch.iter().filter(|c| !c.is_ruler_guide()).collect(); let total = ch.len(); let take_n = opts.child_limit.map_or(total, |l| l.min(total as u64) as usize); if opts.depth.is_none_or(|d| d > 0) { @@ -203,6 +205,10 @@ fn ancestors(d: &Document, id: vectorcraft_doc::NodeId) -> Vec { } fn collect_kind<'a>(n: &'a Node, kind: &str, path: &mut Vec, out: &mut Vec>) { + // Ruler guides are not objects (`guide.list` lists them). + if n.is_ruler_guide() { + return; + } if n.kind_label().eq_ignore_ascii_case(kind) { out.push((n, path.clone())); } @@ -295,7 +301,7 @@ fn document_base(s: &Session) -> Value { // The key object Align aligns to and Distribute Spacing spaces from (`select.key`). "key": st.selection.key.map(|k| k.0), // The ruler guide that is the key instead (`select.key {guide}`): an index into `guide.list`. - "keyGuide": st.selection.key_guide, + "keyGuide": st.selection.key_guide.and_then(|id| crate::cmd::guide_indexes(d, &[id]).first().copied()), "history": st.history.undo.iter().map(|h| h.label.clone()).collect::>(), "redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::>(), "objects": d.node_count(), @@ -432,7 +438,8 @@ pub fn find_nodes(doc: &Document, filter: &FindFilter, limit: u64) -> (Vec, } fn walk(nodes: &[Arc], w: &mut Walk) { - for n in nodes.iter().rev() { + // Ruler guides are not objects (`guide.list` lists them). + for n in nodes.iter().rev().filter(|n| !n.is_ruler_guide()) { if w.filter.hits(n, &w.symbols) { w.ids.push(n.id); if (w.out.len() as u64) < w.limit { diff --git a/crates/engine/src/sanity.rs b/crates/engine/src/sanity.rs index b28cc23ca..22e0ce956 100644 --- a/crates/engine/src/sanity.rs +++ b/crates/engine/src/sanity.rs @@ -45,7 +45,7 @@ pub(crate) fn doc_sane(d: &Document, sel: &Selection) -> bool { && d.ruler_origin.x.abs() <= MAX_COORD && d.ruler_origin.y.abs() <= MAX_COORD && d.artboards.iter().all(|a| rect_in_range(a.rect)) - && d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) + && d.ruler_guides().iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) && objects_in_range(d, &sel.objects) } diff --git a/crates/engine/src/schemas.rs b/crates/engine/src/schemas.rs index 61ec8f0fd..fb08bb649 100644 --- a/crates/engine/src/schemas.rs +++ b/crates/engine/src/schemas.rs @@ -26,10 +26,10 @@ use crate::cmd::{CommandSpec, bad, command_specs}; use crate::{EngineError, Result}; /// The API level of the commands' parameters. -pub const API_VERSION: u32 = 2; +pub const API_VERSION: u32 = 3; /// [`schema_hash`] at each API level, oldest first; the last row is [`API_VERSION`]'s. -pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c), (2, 0x0e31_5d96_7bc9_2653)]; +pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c), (2, 0x0e31_5d96_7bc9_2653), (3, 0xffe0_58a3_2f0c_aa2a)]; /// The hash of every built-in command's schema ([`vectorcraft_apischema::hash`]). pub fn schema_hash() -> u64 { diff --git a/crates/engine/src/script/files.rs b/crates/engine/src/script/files.rs new file mode 100644 index 000000000..3f5381499 --- /dev/null +++ b/crates/engine/src/script/files.rs @@ -0,0 +1,1001 @@ +//! `File` and `Folder`: a script's objects for files and folders, as the help pages describe them +//! (, +//! and +//! ). The classes are +//! built-ins of the realm ([`Realm::define_file_classes`]); the host functions here make every +//! path check and every read. +//! +//! An object holds an absolute path: a relative path is taken from `Folder.current`, `~` is the home +//! folder, `%xx` escapes are read as UTF-8, and on Windows both `/` and `\` separate names and +//! `/c/…` names drive C. Naming a path does not look anything up. +//! +//! VectorCraft's own rules: +//! +//! - A script reads only inside its [`ScriptScope`]: a script run from a file reads in that file's +//! folder and the folders below it, and a script given as text reads nothing. A path whose names +//! lie outside, a Windows network or device path (`\\host\share`, `\\?\`, `\\.\`), and a path that +//! leads out through a link are refused before or instead of opening anything: `exists` is false, +//! the dates are `null`, `getFiles` gives `null`, and `error` is [`ACCESS_DENIED`]. The automation +//! read roots apply as well, so a run with roots in force reads only inside both. +//! - `File(path)` without `new` gives a `Folder` for an existing folder, and `Folder(path)` gives a +//! `File` for an existing file, only where the path may be read. +//! - Opening reads the whole file, at most [`MAX_FILE_BYTES`], checked from its size before any of +//! it is read; a run reads at most [`MAX_RUN_BYTES`] in all and ends past that, and has at most +//! [`MAX_OPEN`] files open at once. +//! - The encoding of an opened file is the one its byte order mark gives (UTF-8, UTF-16LE or +//! UTF-16BE), otherwise the one the script set, otherwise UTF-8 when the bytes are valid UTF-8, +//! otherwise CP1252. `read`, `readln` and `readch` count characters and `seek` and `tell` bytes; +//! `readln` and `readch` take CR, LF, CRLF and LFCR as one line end, and `readch` gives a line end +//! as `"\n"`. A character the encoding cannot decode fails the read with [`UNDECODABLE`] and +//! leaves the position where it was. +//! - `getFiles` lists at most [`MAX_ENTRIES`] entries, sorted by name, and looks at most +//! [`crate::file_access::MAX_LISTED`] names; a mask matches `*` and `?` without regard to case, +//! in at most [`MAX_MASK_STEPS`] steps for one listing. +//! - A path longer than [`MAX_PATH_CHARS`] characters is refused before it is split into names. +//! - Writing (`open` with `w`, `e` or `a`, `write`, `copy`, `remove`, `rename`, `create` and the +//! property setters), the file and folder dialogs and `execute` are not available: they return +//! `false` or `null` and set `error`. `execute` is counted in the report's `fileExecutes`. +//! - In a wasm build every lookup fails with [`NO_WEB`]. + +use std::collections::HashMap; +use std::path::Path; +use std::rc::Rc; + +use vectorcraft_js::{Data, HostError, HostItem, Realm, RunError}; + +use super::{Host, bump}; +use crate::file_access::{ScriptRead, ScriptScope}; + +/// The most bytes of one file a script opens. +pub const MAX_FILE_BYTES: usize = 8 << 20; +/// The most bytes of files one run opens, all of them counted together. +pub const MAX_RUN_BYTES: usize = 32 << 20; +/// The most entries `getFiles` gives. +pub const MAX_ENTRIES: usize = 2048; +/// The most files a run has open at once. +pub const MAX_OPEN: usize = 256; +/// The most characters of a path a script gives. +pub const MAX_PATH_CHARS: usize = 32_767; +/// The most steps one `getFiles` listing spends matching its mask against names. +pub const MAX_MASK_STEPS: usize = 1 << 24; + +pub(crate) const ACCESS_DENIED: &str = "Script file access denied"; +pub(crate) const TOO_LARGE: &str = "Script file exceeds 8 MiB"; +pub(crate) const NOT_OPEN: &str = "Script file is not open"; +pub(crate) const UNDECODABLE: &str = "Script file cannot be decoded"; +pub(crate) const NOT_REGULAR: &str = "Script file is not a regular file"; +pub(crate) const NOT_FOUND: &str = "Script file does not exist"; +pub(crate) const READ_FAILED: &str = "Script file could not be read"; +pub(crate) const LISTING_TOO_LARGE: &str = "Script folder has more than 2,048 entries"; +pub(crate) const BAD_MODE: &str = "Script file open mode must be r, w, e or a"; +pub(crate) const NO_WRITES: &str = "Script file writing is not available"; +pub(crate) const NO_PICKER: &str = "Script file dialogs are not available"; +pub(crate) const NO_EXECUTE: &str = "Script files cannot be opened in another application"; +pub(crate) const NO_WEB: &str = "Script file access is unavailable on the web"; +pub(crate) const TOO_MANY_OPEN: &str = "Script has 256 files open"; +pub(crate) const PATH_TOO_LONG: &str = "Script file path exceeds 32,767 characters"; +pub(crate) const MASK_TOO_COSTLY: &str = "Script folder mask takes too many steps to match"; +const OVER_RUN_BYTES: &str = "the script opened more file data than one run may open (32 MiB)"; + +/// The script's file state: its scope, where `Folder.current` starts, the files it has open and how +/// much it has read. +pub(crate) struct Disk { + scope: ScriptScope, + home: String, + start: String, + open: HashMap, + next: u32, + temps: u32, + bytes: usize, +} + +impl Disk { + /// The state of a run whose own files are read in `scope`. + pub(crate) fn new(scope: &ScriptScope) -> Self { + let home = home_folder(); + let start = scope.folder().map_or_else(|| home.clone(), |p| p.to_string_lossy().into_owned()); + Self { scope: scope.clone(), home, start, open: HashMap::new(), next: 1, temps: 0, bytes: 0 } + } + + /// The path `text` names from the folder `base`, a platform path; `text`'s URI escapes are + /// decoded when `uri` is set. + fn pathname(&self, text: &str, base: &str, uri: bool) -> Pathname { + let root = Pathname::root(WINDOWS); + let home = Pathname::parse(&self.home, &root, &root, WINDOWS, false); + let base = Pathname::parse(base, &home, &home, WINDOWS, false); + Pathname::parse(text, &base, &home, WINDOWS, uri) + } + + /// The path `path` as the scope takes it, or why it is refused by its form alone. + fn checked<'a>(&self, path: &'a str) -> Result<&'a Path, &'static str> { + if cfg!(target_arch = "wasm32") { + return Err(NO_WEB); + } + if too_long(path) { + return Err(PATH_TOO_LONG); + } + let p = Path::new(path); + if !p.is_absolute() || (WINDOWS && (path.starts_with(r"\\") || path.starts_with("//"))) { + return Err(ACCESS_DENIED); + } + Ok(p) + } + + fn stat(&self, path: &str) -> Result, &'static str> { + match self.scope.stat(self.checked(path)?) { + Ok(e) => Ok(Some(e)), + Err(ScriptRead::Missing) => Ok(None), + Err(ScriptRead::Refused | ScriptRead::TooLarge) => Err(ACCESS_DENIED), + Err(ScriptRead::Failed(_)) => Err(READ_FAILED), + } + } + + fn open_file(&mut self, path: &str, encoding: &str) -> Result { + let fail = |m: &str| Err(HostError::Error(m.to_string())); + if self.open.len() >= MAX_OPEN { + return fail(TOO_MANY_OPEN); + } + let entry = match self.stat(path) { + Ok(Some(e)) => e, + Ok(None) => return fail(NOT_FOUND), + Err(m) => return fail(m), + }; + if !entry.file { + return fail(NOT_REGULAR); + } + if entry.len > MAX_FILE_BYTES as u64 { + return fail(TOO_LARGE); + } + if self.bytes.saturating_add(entry.len as usize) > MAX_RUN_BYTES { + return Err(HostError::Abort(OVER_RUN_BYTES.to_string())); + } + let bytes = match self.scope.read(Path::new(path), MAX_FILE_BYTES) { + Ok((_, bytes)) => bytes, + Err(ScriptRead::TooLarge) => return fail(TOO_LARGE), + Err(ScriptRead::Missing) => return fail(NOT_FOUND), + Err(ScriptRead::Refused) => return fail(ACCESS_DENIED), + Err(ScriptRead::Failed(_)) => return fail(READ_FAILED), + }; + if self.bytes.saturating_add(bytes.len()) > MAX_RUN_BYTES { + return Err(HostError::Abort(OVER_RUN_BYTES.to_string())); + } + self.bytes += bytes.len(); + let file = Open::new(bytes, encoding).ok_or_else(|| HostError::Error(UNDECODABLE.to_string()))?; + let id = self.next; + self.next = self.next.wrapping_add(1).max(1); + self.open.insert(id, file); + Ok(id) + } +} + +/// Whether paths are read as Windows reads them. +const WINDOWS: bool = cfg!(windows); + +/// Does `path` have more than [`MAX_PATH_CHARS`] characters? +fn too_long(path: &str) -> bool { + path.len() > MAX_PATH_CHARS && path.chars().nth(MAX_PATH_CHARS).is_some() +} + +/// The home folder, as the environment gives it. +fn home_folder() -> String { + let var = if WINDOWS { "USERPROFILE" } else { "HOME" }; + std::env::var(var).unwrap_or_else(|_| if WINDOWS { r"C:\".to_string() } else { "/".to_string() }) +} + +/// The folders `Folder.temp`, `Folder.desktop` and the others name, `null` where there is none. +fn locations(home: &str) -> Data { + let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); + let join = |a: &str, b: &str| Path::new(a).join(b).to_string_lossy().into_owned(); + let exe = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_string_lossy().into_owned())); + let temp = std::env::temp_dir().to_string_lossy().into_owned(); + let (user_data, app_data, common, system, trash) = if WINDOWS { + (env("APPDATA"), env("PROGRAMDATA"), env("CommonProgramFiles"), env("SystemRoot"), None) + } else if cfg!(target_os = "macos") { + ( + Some(join(home, "Library/Application Support")), + Some("/Library/Application Support".to_string()), + Some("/Library/Application Support".to_string()), + Some("/System".to_string()), + Some(join(home, ".Trash")), + ) + } else { + let data = env("XDG_DATA_HOME").unwrap_or_else(|| join(home, ".local/share")); + (Some(data.clone()), Some("/usr/share".to_string()), Some("/usr/share".to_string()), Some("/".to_string()), Some(join(&data, "Trash"))) + }; + let s = |v: Option| v.map_or(Data::Null, Data::String); + let web = cfg!(target_arch = "wasm32"); + let places = vec![ + ("appData", app_data), + ("appPackage", exe.clone()), + ("commonFiles", common), + ("desktop", Some(join(home, "Desktop"))), + ("myDocuments", Some(join(home, "Documents"))), + ("startup", exe), + ("system", system), + ("temp", Some(temp)), + ("trash", trash), + ("userData", user_data), + ]; + Data::Object(places.into_iter().map(|(k, v)| (k.to_string(), if web { Data::Null } else { s(v) })).collect()) +} + +/// The start of an absolute path. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Root { + /// `/`. + Unix, + /// A Windows drive letter, in upper case. + Drive(char), + /// A Windows share: `\\host\share`. + Share(String, String), + /// A Windows `\\?\` or `\\.\` path, kept as given. + Device(String), +} + +/// An absolute path: its root and its names, with `.` and `..` resolved. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Pathname { + root: Root, + names: Vec, +} + +impl Pathname { + fn root(windows: bool) -> Self { + Self { root: if windows { Root::Drive('C') } else { Root::Unix }, names: Vec::new() } + } + + /// The path `text` names, relative ones taken from `base` and `~` from `home`, with its URI + /// escapes decoded when `uri` is set. + fn parse(text: &str, base: &Pathname, home: &Pathname, windows: bool, uri: bool) -> Self { + let decoded = if uri { decode(text) } else { text.to_string() }; + let t = if windows { decoded.replace('\\', "/") } else { decoded }; + let (start, rest): (Pathname, &str) = if windows && (t.starts_with("//?/") || t.starts_with("//./")) { + return Self { root: Root::Device(t.replace('/', "\\")), names: Vec::new() }; + } else if let Some(r) = t.strip_prefix("//").filter(|_| windows) { + let mut parts = r.splitn(3, '/'); + let host = parts.next().unwrap_or_default().to_string(); + let share = parts.next().unwrap_or_default().to_string(); + (Self { root: Root::Share(host, share), names: Vec::new() }, parts.next().unwrap_or_default()) + } else if windows && t.len() >= 2 && t.as_bytes()[0].is_ascii_alphabetic() && t.as_bytes()[1] == b':' { + (Self { root: Root::Drive(t.as_bytes()[0].to_ascii_uppercase() as char), names: Vec::new() }, t.get(2..).unwrap_or_default()) + } else if let Some(r) = t.strip_prefix('/') { + let (first, after) = r.split_once('/').unwrap_or((r, "")); + if windows && first.len() == 1 && first.as_bytes()[0].is_ascii_alphabetic() { + (Self { root: Root::Drive(first.as_bytes()[0].to_ascii_uppercase() as char), names: Vec::new() }, after) + } else if windows { + let root = match &base.root { + Root::Drive(_) | Root::Share(..) => base.root.clone(), + _ => Root::Drive('C'), + }; + (Self { root, names: Vec::new() }, r) + } else { + (Self { root: Root::Unix, names: Vec::new() }, r) + } + } else if t == "~" || t.starts_with("~/") { + (home.clone(), t.get(1..).unwrap_or_default()) + } else { + (base.clone(), t.as_str()) + }; + let mut out = start; + for name in rest.split('/') { + match name { + "" | "." => {} + ".." => { + out.names.pop(); + } + n => out.names.push(n.to_string()), + } + } + out + } + + /// The path as the platform writes it. + fn fs(&self) -> String { + match &self.root { + Root::Unix => format!("/{}", self.names.join("/")), + Root::Drive(c) => format!("{c}:\\{}", self.names.join("\\")), + Root::Share(host, share) => self.names.iter().fold(format!(r"\\{host}\{share}"), |s, n| format!("{s}\\{n}")), + Root::Device(raw) => raw.clone(), + } + } + + /// The path in URI notation, each name escaped. + fn uri(&self) -> String { + let start = match &self.root { + Root::Unix => String::new(), + Root::Drive(c) => format!("/{}", c.to_ascii_lowercase()), + Root::Share(host, share) => format!("//{}/{}", encode(host), encode(share)), + Root::Device(raw) => return raw.replace('\\', "/"), + }; + let names: String = self.names.iter().map(|n| format!("/{}", encode(n))).collect(); + if start.is_empty() && names.is_empty() { "/".to_string() } else { start + &names } + } + + fn parent(&self) -> Option { + let mut p = self.clone(); + p.names.pop().map(|_| p) + } + + /// `self` relative to the folder `base`, in URI notation; the whole URI when they have different + /// roots. + fn relative_to(&self, base: &Pathname, windows: bool) -> String { + if self.root != base.root { + return self.uri(); + } + let same = |a: &String, b: &String| if windows { a.to_lowercase() == b.to_lowercase() } else { a == b }; + let common = self.names.iter().zip(&base.names).take_while(|(a, b)| same(a, b)).count(); + let mut parts: Vec = vec!["..".to_string(); base.names.len() - common]; + parts.extend(self.names.iter().skip(common).map(|n| encode(n))); + if parts.is_empty() { ".".to_string() } else { parts.join("/") } + } + + /// The parts of `File` and `Folder` objects that come from the path alone. + fn data(&self) -> Data { + let s = |v: String| Data::String(v); + Data::Object(vec![ + ("fs".into(), s(self.fs())), + ("uri".into(), s(self.uri())), + ("name".into(), s(self.names.last().map(|n| encode(n)).unwrap_or_default())), + ("display".into(), s(self.names.last().cloned().unwrap_or_default())), + ("path".into(), s(self.parent().map(|p| p.uri()).unwrap_or_default())), + ("parent".into(), self.parent().map_or(Data::Null, |p| Data::String(p.fs()))), + ]) + } +} + +/// `s` with every character but letters, digits and `-_.!~*'()` written as `%xx` escapes of its +/// UTF-8 bytes (RFC 2396). +fn encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&b) { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +/// `s` with its `%xx` escapes read as UTF-8 bytes; `s` as it is when they do not make UTF-8. +fn decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + let hex = |c: u8| (c as char).to_digit(16); + match (b[i], b.get(i + 1).copied().and_then(hex), b.get(i + 2).copied().and_then(hex)) { + (b'%', Some(h), Some(l)) => { + out.push((h * 16 + l) as u8); + i += 3; + } + (c, ..) => { + out.push(c); + i += 1; + } + } + } + String::from_utf8(out).unwrap_or_else(|_| s.to_string()) +} + +/// A `getFiles` mask, where `*` stands for any run of characters and `?` for one, without regard +/// to case, read once for a whole listing. +struct Mask { + /// The mask in lower case, each run of `*` written as one. + chars: Vec, + /// How many of its characters are not `*`: a name with fewer characters does not match. + fixed: usize, +} + +impl Mask { + fn new(mask: &str) -> Self { + let mut chars: Vec = Vec::new(); + for c in mask.to_lowercase().chars() { + if !(c == '*' && chars.last() == Some(&'*')) { + chars.push(c); + } + } + let fixed = chars.iter().filter(|c| **c != '*').count(); + Self { chars, fixed } + } + + /// Does `name` match, taking a step from `steps` for each character compared? `None` when the + /// steps run out first. + fn matches(&self, name: &str, steps: &mut usize) -> Option { + let n: Vec = name.to_lowercase().chars().collect(); + if n.len() < self.fixed { + return Some(false); + } + matched(&self.chars, &n, steps) + } +} + +/// Does `n` match the lower-case mask `m`? `None` when `steps` run out first. +fn matched(m: &[char], n: &[char], steps: &mut usize) -> Option { + let (mut i, mut j, mut star, mut mark) = (0usize, 0usize, None, 0usize); + while j < n.len() { + *steps = steps.checked_sub(1)?; + match m.get(i) { + Some('*') => { + star = Some(i); + mark = j; + i += 1; + } + Some(c) if *c == '?' || *c == n[j] => { + i += 1; + j += 1; + } + _ => match star { + Some(s) => { + i = s + 1; + mark += 1; + j = mark; + } + None => return Some(false), + }, + } + } + Some(m[i..].iter().all(|c| *c == '*')) +} + +/// A character encoding an opened file is read in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Encoding { + Utf8, + Ascii, + Binary, + Latin1, + Cp1252, + Utf16Le, + Utf16Be, +} + +/// The characters CP1252 gives bytes 0x80 to 0x9F; the five it leaves undefined stand for +/// themselves. +const CP1252_HIGH: [u16; 32] = [ + 0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, 0x0090, 0x2018, + 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178, +]; + +impl Encoding { + fn parse(name: &str) -> Option { + Some(match name.trim().to_ascii_uppercase().as_str() { + "UTF-8" | "UTF8" => Self::Utf8, + "ASCII" | "US-ASCII" => Self::Ascii, + "BINARY" => Self::Binary, + "ISO-8859-1" | "ISO8859-1" | "LATIN1" | "LATIN-1" => Self::Latin1, + "CP1252" | "WINDOWS-1252" => Self::Cp1252, + "UTF-16LE" | "UTF16LE" => Self::Utf16Le, + "UTF-16" | "UTF16" | "UTF-16BE" | "UTF16BE" | "UCS-2" => Self::Utf16Be, + _ => return None, + }) + } + + fn name(self) -> &'static str { + match self { + Self::Utf8 => "UTF-8", + Self::Ascii => "ASCII", + Self::Binary => "BINARY", + Self::Latin1 => "ISO-8859-1", + Self::Cp1252 => "CP1252", + Self::Utf16Le => "UTF-16LE", + Self::Utf16Be => "UTF-16BE", + } + } +} + +/// An opened file: its bytes, the byte position and the encoding it is read in. +struct Open { + bytes: Vec, + pos: usize, + encoding: Encoding, + name: String, +} + +impl Open { + /// `bytes` opened with the encoding `asked` (empty: none asked); `None` when `asked` is not an + /// encoding and the file has no byte order mark. + fn new(bytes: Vec, asked: &str) -> Option { + let (encoding, pos) = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + (Encoding::Utf8, 3) + } else if bytes.starts_with(&[0xFF, 0xFE]) { + (Encoding::Utf16Le, 2) + } else if bytes.starts_with(&[0xFE, 0xFF]) { + (Encoding::Utf16Be, 2) + } else if !asked.is_empty() { + (Encoding::parse(asked)?, 0) + } else if std::str::from_utf8(&bytes).is_ok() { + (Encoding::Utf8, 0) + } else { + (Encoding::Cp1252, 0) + }; + Some(Self { bytes, pos, encoding, name: encoding.name().to_string() }) + } + + /// The character at byte `at` and its length in bytes; `Ok(None)` at the end, `Err` where the + /// encoding cannot decode it. + fn char_at(&self, at: usize) -> Result, ()> { + let Some(&b) = self.bytes.get(at) else { return Ok(None) }; + let encoding = self.encoding; + let unit = |i: usize| -> Option { + let pair = self.bytes.get(i..i + 2)?; + Some(if encoding == Encoding::Utf16Le { u16::from_le_bytes([pair[0], pair[1]]) } else { u16::from_be_bytes([pair[0], pair[1]]) }) + }; + let c = match encoding { + Encoding::Ascii if b < 0x80 => (b as char, 1), + Encoding::Ascii => return Err(()), + Encoding::Binary | Encoding::Latin1 => (b as char, 1), + Encoding::Cp1252 => { + let code = if (0x80..0xA0).contains(&b) { u32::from(CP1252_HIGH[usize::from(b - 0x80)]) } else { u32::from(b) }; + (char::from_u32(code).ok_or(())?, 1) + } + Encoding::Utf8 => { + let len = match b { + 0x00..=0x7F => 1, + 0xC2..=0xDF => 2, + 0xE0..=0xEF => 3, + 0xF0..=0xF4 => 4, + _ => return Err(()), + }; + let s = self.bytes.get(at..at + len).and_then(|s| std::str::from_utf8(s).ok()).ok_or(())?; + (s.chars().next().ok_or(())?, len) + } + Encoding::Utf16Le | Encoding::Utf16Be => { + let first = unit(at).ok_or(())?; + if (0xD800..0xDC00).contains(&first) { + let second = unit(at + 2).filter(|u| (0xDC00..0xE000).contains(u)).ok_or(())?; + let code = 0x10000 + ((u32::from(first) - 0xD800) << 10) + (u32::from(second) - 0xDC00); + (char::from_u32(code).ok_or(())?, 4) + } else { + (char::from_u32(u32::from(first)).ok_or(())?, 2) + } + } + }; + Ok(Some(c)) + } + + /// Up to `max` characters from the position (all of them for `None`). + fn read(&mut self, max: Option) -> Result { + let (mut at, mut out) = (self.pos, String::new()); + let mut n = 0; + while max.is_none_or(|m| n < m) { + let Some((c, len)) = self.char_at(at)? else { break }; + out.push(c); + at += len; + n += 1; + } + self.pos = at; + Ok(out) + } + + /// The position after a line end that began with `c` and continues at `at` (CR, LF, CRLF or + /// LFCR). A character there that cannot be decoded is left for the next read. + fn past_line_end(&self, c: char, at: usize) -> usize { + let other = if c == '\r' { '\n' } else { '\r' }; + match self.char_at(at) { + Ok(Some((n, len))) if n == other => at + len, + _ => at, + } + } + + /// The characters up to the next line end, which is passed over. + fn read_line(&mut self) -> Result { + let (mut at, mut out) = (self.pos, String::new()); + while let Some((c, len)) = self.char_at(at)? { + at += len; + if c == '\r' || c == '\n' { + at = self.past_line_end(c, at); + break; + } + out.push(c); + } + self.pos = at; + Ok(out) + } + + /// The next character; a line end is one `"\n"`. + fn read_char(&mut self) -> Result { + let Some((c, len)) = self.char_at(self.pos)? else { return Ok(String::new()) }; + let mut at = self.pos + len; + let c = if c == '\r' || c == '\n' { + at = self.past_line_end(c, at); + '\n' + } else { + c + }; + self.pos = at; + Ok(c.to_string()) + } +} + +/// `{text}` for a read that worked, `{error}` for one that did not. +fn text_result(r: Result) -> Data { + match r { + Ok(t) => Data::Object(vec![("text".into(), Data::String(t))]), + Err(()) => error(UNDECODABLE), + } +} + +fn error(message: &str) -> Data { + Data::Object(vec![("error".into(), Data::String(message.to_string()))]) +} + +fn arg_text(a: &[Data], i: usize) -> String { + match a.get(i) { + Some(Data::String(s)) => s.clone(), + _ => String::new(), + } +} + +fn arg_number(a: &[Data], i: usize) -> Option { + match a.get(i) { + Some(Data::Number(n)) => Some(*n), + _ => None, + } +} + +fn millis(t: Option) -> Data { + t.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(Data::Null, |d| Data::Number(d.as_millis() as f64)) +} + +/// One host function of `__vcFiles`, counted and charged as a host call. +fn call(host: &Rc, f: fn(&Host, &[Data]) -> Result) -> HostItem { + let h = host.clone(); + HostItem::Function(Rc::new(move |a| { + bump(&h.counts.file_calls); + let r = f(&h, &a)?; + h.charge_call(super::data_size(&r).saturating_add(a.iter().map(super::data_size).sum()))?; + Ok(r) + })) +} + +fn handle(a: &[Data]) -> u32 { + arg_number(a, 0).filter(|n| n.is_finite() && *n >= 0.0 && *n <= f64::from(u32::MAX)).map_or(0, |n| n as u32) +} + +fn with_open(host: &Host, a: &[Data], f: impl FnOnce(&mut Open) -> Data) -> Data { + match host.disk.borrow_mut().open.get_mut(&handle(a)) { + Some(file) => f(file), + None => error(NOT_OPEN), + } +} + +/// The first two arguments as path text, refused with a `RangeError` when either is too long. +fn two_paths(a: &[Data]) -> Result<(String, String), HostError> { + let (path, base) = (arg_text(a, 0), arg_text(a, 1)); + if too_long(&path) || too_long(&base) { + return Err(HostError::Range(PATH_TOO_LONG.to_string())); + } + Ok((path, base)) +} + +fn parse(host: &Host, a: &[Data]) -> Result { + let (path, base) = two_paths(a)?; + let platform = matches!(a.get(2), Some(Data::Bool(true))); + let disk = host.disk.borrow(); + Ok(disk.pathname(&path, &base, !platform).data()) +} + +/// The platform path `a[0]` relative to the platform path `a[1]`. +fn relative(host: &Host, a: &[Data]) -> Result { + let (path, base) = two_paths(a)?; + let disk = host.disk.borrow(); + let base = disk.pathname(&base, &disk.start, false); + Ok(Data::String(disk.pathname(&path, &disk.start, false).relative_to(&base, WINDOWS))) +} + +fn stat(host: &Host, a: &[Data]) -> Result { + Ok(match host.disk.borrow().stat(&arg_text(a, 0)) { + Err(m) => error(m), + Ok(None) => Data::Object(vec![("exists".into(), Data::Bool(false))]), + Ok(Some(e)) => Data::Object(vec![ + ("exists".into(), Data::Bool(true)), + ("dir".into(), Data::Bool(e.dir)), + ("file".into(), Data::Bool(e.file)), + ("length".into(), Data::Number(e.len as f64)), + ("created".into(), millis(e.created)), + ("modified".into(), millis(e.modified)), + ("hidden".into(), Data::Bool(e.hidden)), + ("readonly".into(), Data::Bool(e.readonly)), + ("alias".into(), Data::Bool(e.link)), + ]), + }) +} + +fn open(host: &Host, a: &[Data]) -> Result { + match host.disk.borrow_mut().open_file(&arg_text(a, 0), &arg_text(a, 1)) { + Ok(id) => Ok(Data::Object(vec![("id".into(), Data::Number(f64::from(id)))])), + Err(HostError::Error(m)) => Ok(error(&m)), + Err(e) => Err(e), + } +} + +fn close(host: &Host, a: &[Data]) -> Result { + Ok(Data::Bool(host.disk.borrow_mut().open.remove(&handle(a)).is_some())) +} + +fn read(host: &Host, a: &[Data]) -> Result { + let max = arg_number(a, 1).filter(|n| *n >= 0.0).map(|n| n as usize); + Ok(with_open(host, a, |f| text_result(f.read(max)))) +} + +fn readln(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| text_result(f.read_line()))) +} + +fn readch(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| text_result(f.read_char()))) +} + +fn seek(host: &Host, a: &[Data]) -> Result { + let (Some(pos), Some(mode)) = (arg_number(a, 1), arg_number(a, 2)) else { return Err(HostError::Type(BAD_SEEK.into())) }; + Ok(with_open(host, a, |f| { + let base = match mode as u8 { + 1 => f.pos as f64, + 2 => f.bytes.len() as f64, + _ => 0.0, + }; + let to = base + pos.trunc(); + if !(0.0..=f.bytes.len() as f64).contains(&to) { + return Data::Bool(false); + } + f.pos = to as usize; + Data::Bool(true) + })) +} + +const BAD_SEEK: &str = "File.seek: takes a position and a mode"; + +fn tell(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| Data::Number(f.pos as f64))) +} + +fn eof(host: &Host, a: &[Data]) -> Result { + Ok(Data::Bool(host.disk.borrow().open.get(&handle(a)).is_none_or(|f| f.pos >= f.bytes.len()))) +} + +fn encoding(host: &Host, a: &[Data]) -> Result { + Ok(with_open(host, a, |f| Data::String(f.name.clone()))) +} + +fn set_encoding(host: &Host, a: &[Data]) -> Result { + let name = arg_text(a, 1); + Ok(with_open(host, a, |f| match Encoding::parse(&name) { + Some(e) => { + f.encoding = e; + f.name = e.name().to_string(); + Data::Object(vec![]) + } + None => error(UNDECODABLE), + })) +} + +fn list(host: &Host, a: &[Data]) -> Result { + let disk = host.disk.borrow(); + let path = arg_text(a, 0); + let mask = Mask::new(&arg_text(a, 1)); + let checked = match disk.checked(&path) { + Ok(p) => p, + Err(m) => return Ok(error(m)), + }; + let steps = std::cell::Cell::new(MAX_MASK_STEPS); + let spent = std::cell::Cell::new(false); + let keep = |name: &str| { + let mut left = steps.get(); + let kept = mask.matches(name, &mut left); + steps.set(left); + spent.set(spent.get() || kept.is_none()); + kept.unwrap_or(false) + }; + let listed = disk.scope.list(checked, MAX_ENTRIES, keep); + if spent.get() { + return Ok(error(MASK_TOO_COSTLY)); + } + let found = match listed { + Ok(found) => found, + Err(ScriptRead::TooLarge) => return Ok(error(LISTING_TOO_LARGE)), + Err(ScriptRead::Missing) => return Ok(error(NOT_FOUND)), + Err(ScriptRead::Refused) => return Ok(error(ACCESS_DENIED)), + Err(ScriptRead::Failed(_)) => return Ok(error(READ_FAILED)), + }; + let entries = found + .into_iter() + .map(|(name, dir)| { + let fs = Path::new(&path).join(&name).to_string_lossy().into_owned(); + Data::Object(vec![("fs".into(), Data::String(fs)), ("dir".into(), Data::Bool(dir))]) + }) + .collect(); + Ok(Data::Object(vec![("entries".into(), Data::Array(entries))])) +} + +fn resolve(host: &Host, a: &[Data]) -> Result { + let disk = host.disk.borrow(); + let path = arg_text(a, 0); + let target = match disk.stat(&path) { + Err(m) => return Ok(error(m)), + Ok(Some(e)) if e.link => disk.checked(&path).ok().and_then(|p| disk.scope.locate(p).ok()), + Ok(_) => None, + }; + Ok(match target { + Some(real) => { + let dir = real.is_dir(); + let fs = crate::cmd::findfiles::plain(real).to_string_lossy().into_owned(); + Data::Object(vec![("fs".into(), Data::String(fs)), ("dir".into(), Data::Bool(dir))]) + } + None => Data::Object(vec![("fs".into(), Data::Null)]), + }) +} + +fn execute(host: &Host, _: &[Data]) -> Result { + bump(&host.counts.file_executes); + Ok(error(NO_EXECUTE)) +} + +fn temp_name(host: &Host, _: &[Data]) -> Result { + let mut disk = host.disk.borrow_mut(); + disk.temps += 1; + let name = format!("vc{:05}.tmp", disk.temps); + Ok(Data::String(std::env::temp_dir().join(name).to_string_lossy().into_owned())) +} + +/// Adds `File` and `Folder` to the realm. +pub(super) fn install(realm: &mut Realm, host: &Rc) -> Result<(), RunError> { + let (start, home) = { + let disk = host.disk.borrow(); + (disk.pathname(&disk.start, &disk.start, false).fs(), disk.home.clone()) + }; + let value = |s: &str| HostItem::Value(Data::String(s.to_string())); + let text = HostItem::Object(vec![ + ("notOpen".into(), value(NOT_OPEN)), + ("noWrites".into(), value(NO_WRITES)), + ("noPicker".into(), value(NO_PICKER)), + ("badMode".into(), value(BAD_MODE)), + ]); + let pure = |f: fn(&str) -> Data| HostItem::Function(Rc::new(move |a| Ok(f(&arg_text(&a, 0))))); + let system = if WINDOWS { + "Windows" + } else if cfg!(target_os = "macos") { + "Macintosh" + } else { + "Unix" + }; + let items = vec![ + ("text".into(), text), + ("system".into(), value(system)), + ("lineFeed".into(), value(if WINDOWS { "Windows" } else { "Unix" })), + ("current".into(), HostItem::Function(Rc::new(move |_| Ok(Data::String(start.clone()))))), + ("locations".into(), HostItem::Function(Rc::new(move |_| Ok(locations(&home))))), + ("encode".into(), pure(|s| Data::String(encode(s)))), + ("decode".into(), pure(|s| Data::String(decode(s)))), + ("encodingAvailable".into(), pure(|s| Data::Bool(Encoding::parse(s).is_some()))), + ("parse".into(), call(host, parse)), + ("relative".into(), call(host, relative)), + ("tempName".into(), call(host, temp_name)), + ("stat".into(), call(host, stat)), + ("open".into(), call(host, open)), + ("close".into(), call(host, close)), + ("read".into(), call(host, read)), + ("readln".into(), call(host, readln)), + ("readch".into(), call(host, readch)), + ("seek".into(), call(host, seek)), + ("tell".into(), call(host, tell)), + ("eof".into(), call(host, eof)), + ("encoding".into(), call(host, encoding)), + ("setEncoding".into(), call(host, set_encoding)), + ("list".into(), call(host, list)), + ("resolve".into(), call(host, resolve)), + ("execute".into(), call(host, execute)), + ]; + realm.define_file_classes(items) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(text: &str, base: &str, windows: bool) -> Pathname { + let root = Pathname::root(windows); + let home = Pathname::parse(if windows { r"C:\Users\me" } else { "/home/me" }, &root, &root, windows, false); + let base = Pathname::parse(base, &home, &home, windows, false); + Pathname::parse(text, &base, &home, windows, true) + } + + #[test] + fn windows_paths_take_both_separators_drives_and_uri_names() { + let f = p(r"C:\art\one two.txt", r"C:\work", true); + assert_eq!((f.fs(), f.uri()), (r"C:\art\one two.txt".to_string(), "/c/art/one%20two.txt".to_string())); + assert_eq!(p("/d/x/y.txt", r"C:\work", true).fs(), r"D:\x\y.txt"); + assert_eq!(p("c:/a/./b/../c", r"C:\work", true).fs(), r"C:\a\c"); + assert_eq!(p("sub/f.txt", r"C:\work", true).fs(), r"C:\work\sub\f.txt"); + assert_eq!(p("~/notes.txt", r"C:\work", true).fs(), r"C:\Users\me\notes.txt"); + assert_eq!(p(r"\\host\share\x.txt", r"C:\work", true).fs(), r"\\host\share\x.txt"); + assert_eq!(p(r"\\?\C:\x.txt", r"C:\work", true).root, Root::Device(r"\\?\C:\x.txt".into())); + assert_eq!(p("caf%C3%A9.txt", r"C:\w", true).fs(), "C:\\w\\caf\u{e9}.txt"); + assert_eq!(p("..\\..\\..\\..", r"C:\w", true).fs(), r"C:\"); + assert_eq!(p("C:", r"D:\w", true).uri(), "/c"); + } + + #[test] + fn unix_paths_keep_backslashes_in_names() { + assert_eq!(p(r"a\b.txt", "/work", false).fs(), r"/work/a\b.txt"); + assert_eq!(p("/x/../y", "/work", false).fs(), "/y"); + assert_eq!(p("~", "/work", false).fs(), "/home/me"); + assert_eq!(p("/", "/work", false).uri(), "/"); + } + + #[test] + fn relative_uris_climb_to_the_common_folder() { + let base = p("/a/b", "/", false); + assert_eq!(p("/a/b/c d.txt", "/", false).relative_to(&base, false), "c%20d.txt"); + assert_eq!(p("/a/x.txt", "/", false).relative_to(&base, false), "../x.txt"); + assert_eq!(p("/a/b", "/", false).relative_to(&base, false), "."); + assert_eq!(p("D:/x", "C:/", true).relative_to(&p("C:/a", "C:/", true), true), "/d/x"); + } + + #[test] + fn uri_escapes_are_utf8_both_ways() { + assert_eq!(encode("caf\u{e9} 1"), "caf%C3%A9%201"); + assert_eq!(encode("a-_.!~*'()/b"), "a-_.!~*'()%2Fb"); + assert_eq!(decode("caf%C3%A9%20x"), "caf\u{e9} x"); + assert_eq!(decode("100%"), "100%"); + assert_eq!(decode("%E9"), "%E9", "a lone Latin-1 escape is not UTF-8"); + } + + #[test] + fn masks_match_stars_and_question_marks_without_case() { + let matches = |mask: &str, name: &str| Mask::new(mask).matches(name, &mut { usize::MAX }) == Some(true); + assert!(matches("*.JSX", "a.jsx")); + assert!(matches("a?c*", "abcdef")); + assert!(!matches("*.txt", "a.jsx")); + assert!(matches("*", "")); + assert!(matches("*a*b", "xaxxb")); + assert!(matches("a***b", "ab")); + assert!(!matches("?", "")); + } + + #[test] + fn masks_stop_when_their_steps_run_out() { + let mask = Mask::new(&format!("*{}b", "a".repeat(100))); + let name = "a".repeat(200); + let mut steps = 1000; + assert_eq!(mask.matches(&name, &mut steps), None); + assert_eq!(steps, 0); + let mut steps = 1000; + assert_eq!(Mask::new("*.jsx").matches("a.jsx", &mut steps), Some(true)); + assert!(steps > 990); + } + + #[test] + fn decoding_counts_characters_and_takes_four_line_ends() { + let mut f = Open::new(b"\xEF\xBB\xBFa\xC3\xA9\r\nb\n\rc\rd\ne".to_vec(), "").unwrap(); + assert_eq!((f.name.as_str(), f.pos), ("UTF-8", 3), "the byte order mark is passed over"); + assert_eq!(f.read(Some(2)).unwrap(), "a\u{e9}"); + assert_eq!(f.pos, 6, "two characters, three bytes"); + assert_eq!(f.read_line().unwrap(), "", "CRLF is one line end"); + assert_eq!(f.read_line().unwrap(), "b", "LFCR is one line end"); + assert_eq!(f.read_line().unwrap(), "c"); + assert_eq!(f.read_char().unwrap(), "d"); + assert_eq!(f.read_char().unwrap(), "\n"); + assert_eq!(f.read(None).unwrap(), "e"); + assert_eq!(f.read_char().unwrap(), ""); + let mut b = Open::new(vec![0xE8, 0x41], "BINARY").unwrap(); + assert_eq!(b.read(None).unwrap(), "\u{e8}A"); + let mut a = Open::new(vec![0x41, 0xE8], "ascii").unwrap(); + assert!(a.read(None).is_err()); + assert_eq!(a.pos, 0, "a failed read leaves the position"); + let mut c = Open::new(vec![0x80, 0xE9], "").unwrap(); + assert_eq!(c.name, "CP1252"); + assert_eq!(c.read(None).unwrap(), "\u{20ac}\u{e9}"); + let mut u = Open::new(vec![0xFF, 0xFE, 0x3D, 0xD8, 0x00, 0xDE, 0x41, 0x00], "").unwrap(); + assert_eq!(u.name, "UTF-16LE"); + assert_eq!(u.read(None).unwrap(), "\u{1f600}A"); + assert!(Open::new(b"x".to_vec(), "KLINGON").is_none()); + let mut l = Open::new(vec![b'a', b'\n', 0xE8, b'\r', 0xE8], "ASCII").unwrap(); + assert_eq!(l.read_line().unwrap(), "a", "a line end before a byte ASCII cannot read still ends the line"); + assert!(l.read_line().is_err()); + assert_eq!(l.pos, 2); + l.pos = 3; + assert_eq!(l.read_char().unwrap(), "\n"); + assert_eq!(l.pos, 4); + } +} diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs index 70bfeac56..6cf0c60be 100644 --- a/crates/engine/src/script/include.rs +++ b/crates/engine/src/script/include.rs @@ -251,6 +251,11 @@ impl Files { self.include_path.join(";") } + /// The folders the run's own files are read from. + pub(crate) fn scope(&self) -> &ScriptScope { + &self.scope + } + /// The script's own text, `text`, with its preprocessor lines read. pub(crate) fn expand_script(&mut self, text: &str) -> Result { self.bytes = self.bytes.saturating_add(text.len()); diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 213cff53f..8d223f53c 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -36,6 +36,8 @@ //! //! `alert`, `confirm` and `prompt` open dialogs ([`dialogs`]): in the app the run waits for the //! person's answer, and a run with nobody to ask takes its answers from `script.run`'s `answers`. +//! +//! `File` and `Folder` read files inside the script's own folder ([`files`]). pub mod console; pub mod dialogs; @@ -58,6 +60,7 @@ use crate::{ Clipboard, DocState, DrawMode, EngineError, Journal, NodeId, PaintDefaults, Result, Session, cmd, command_specs, guard, waits_for_tool_edit, }; +pub mod files; pub mod folder; /// The most bytes of source a run takes, as a file or as `source`. @@ -502,6 +505,8 @@ struct Host { dialogs: dialogs::Dialogs, /// The files the run reads: its includes and `$.evalFile`'s. files: RefCell, + /// What `File` and `Folder` have open and have read. + disk: RefCell, /// Where the lines of the script's expanded source came from. script_map: RefCell, /// The same for each source `$.evalFile` gave, in order. @@ -527,6 +532,10 @@ struct Counts { command_list_reads: Cell, vc_log: Cell, console_writes: Cell, + /// Calls of `File` and `Folder` that reach the host ([`files`]). + file_calls: Cell, + /// `execute` calls, which open nothing. + file_executes: Cell, } impl Counts { @@ -537,6 +546,8 @@ impl Counts { "commandListReads": self.command_list_reads.get(), "vcLog": self.vc_log.get(), "consoleWrites": self.console_writes.get(), + "fileCalls": self.file_calls.get(), + "fileExecutes": self.file_executes.get(), }) } } @@ -1146,7 +1157,7 @@ fn kind_id(kind: &RunErrorKind) -> &'static str { /// `message`, with what to do about a failure that comes from a script written for another program. fn explained(kind: &RunErrorKind, message: &str) -> String { - const HOST_OBJECTS: &[&str] = &["app", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"]; + const HOST_OBJECTS: &[&str] = &["app", "Window", "BridgeTalk", "activeDocument", "documents"]; let names_host_object = message.strip_prefix("ReferenceError: ").is_some_and(|m| HOST_OBJECTS.iter().any(|o| m.split_whitespace().next() == Some(o))); if *kind == RunErrorKind::Thrown && names_host_object { @@ -1394,6 +1405,7 @@ impl Session { commands: RefCell::default(), run_nanos: Cell::new(0), dialogs: dialogs::Dialogs::new(self.script_frames, &realm, request.answers), + disk: RefCell::new(files::Disk::new(files.scope())), files: RefCell::new(files), script_map: RefCell::default(), eval_maps: RefCell::default(), @@ -1419,6 +1431,7 @@ impl Session { .and_then(|()| realm.define_host_object("vc", vc_object(&host, args))) .and_then(|()| realm.define_host_object("$", dollar_object(&host, info, &localization))) .and_then(|()| dialogs::install(&mut realm, &host)) + .and_then(|()| files::install(&mut realm, &host)) .and_then(|()| realm.start(&expanded.source)) .map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e)) } diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index e34eb711b..63fbcd5a7 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -300,8 +300,8 @@ impl Session { cmd::compositefonts::filter_loaded(&mut doc); // Inline graphics in text take their size from their symbols' art (not saved). doc.resolve_inline_art(); - // Guides from files saved before guides had layers go on a layer. - doc.adopt_guides(); + // Ruler guides a reader left in the saved list become nodes on their layers. + doc.guides_into_nodes(); cmd::dimension::conform(&mut doc); self.reset_tool_for_doc_switch(); let mut st = DocState::new(doc, path); @@ -328,7 +328,7 @@ impl Session { doc.limit_depth(); cmd::compositefonts::filter_loaded(&mut doc); doc.resolve_inline_art(); - doc.adopt_guides(); + doc.guides_into_nodes(); cmd::dimension::conform(&mut doc); // In the back, type set in a font named like a composite font is measured when the document // comes to the front and its own composite fonts are registered, not in the front diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 34c4eed72..abe9121ef 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -99,6 +99,13 @@ impl Session { // the artboards the journal recorded need only a document too, whatever is selected and // whichever tool is chosen. let given = given_ids(spec.params, params); + // The guide commands take ruler guides by index; an object command given one's id is refused. + if let (Some(ids), Some(st)) = (&given, self.active()) + && let Some(guide) = ids.iter().find(|i| st.doc.node(**i).is_some_and(|n| n.is_ruler_guide())) + { + let value = guide.0; + return Err(cmd::bad(id, format!("id {value} is a ruler guide, not an object"))); + } let enabled = if (id == "edit.clear" && params.get("ids").is_some()) || (matches!(id, "edit.copy" | "edit.cut") && params.get("selectedArtboards").is_some()) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9c6de6a89..77b0390a1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -155,6 +155,7 @@ mod script_dialogs; mod script_dollar; mod script_folder; mod script_include; +mod script_io; mod script_run; mod scripts; mod select_ids_first; @@ -192,6 +193,7 @@ mod toolsettings; mod transformfx; mod transparencygrid; mod trap; +mod turn_direction; mod typearea; mod typeglyphbounds; mod typescale; @@ -215,6 +217,18 @@ fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { NodeId(r["id"].as_u64().unwrap()) } +/// The active document's selected ruler guides, by their `guide.list` indexes. +pub(crate) fn selected_guides(s: &Session) -> Vec { + let st = s.doc().unwrap(); + crate::cmd::guide_indexes(&st.doc, &st.selection.guides) +} + +/// The active document's key guide, by its `guide.list` index. +pub(crate) fn key_guide(s: &Session) -> Option { + let st = s.doc().unwrap(); + st.selection.key_guide.and_then(|k| crate::cmd::guide_indexes(&st.doc, &[k]).first().copied()) +} + #[test] fn create_and_undo_redo() { let mut s = session(); diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index e98593bfc..491a47108 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let list = run(&mut s, "brush.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails"]); + assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); assert_eq!( @@ -118,6 +118,16 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { ["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"] ); assert_eq!(libs[4]["count"].as_u64(), Some(4)); + let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"})); + assert_eq!( + beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::>(), + ["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"] + ); + assert_eq!(libs[5]["count"].as_u64(), Some(4)); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap(); + let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]})); + assert_eq!(strs(&added["added"]), ["Laser Bolt"]); // One kind of brush per library, every brush drawing something. for (id, kind) in [ ("art-ribbons", "art"), @@ -125,6 +135,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { ("pattern-borders", "pattern"), ("scatter-shapes", "scatter"), ("game-trails", "art"), + ("energy-beams", "art"), ] { let (_, lib) = cmd::brushlib::library(&s, id).unwrap(); assert!(lib.len() >= 4, "{id}"); diff --git a/crates/engine/src/tests/constrain_tools.rs b/crates/engine/src/tests/constrain_tools.rs index 6a8317cfe..a93965eb0 100644 --- a/crates/engine/src/tests/constrain_tools.rs +++ b/crates/engine/src/tests/constrain_tools.rs @@ -78,7 +78,7 @@ fn constrain_angle_turns_the_arrow_keys() { let g = s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap()["index"].as_u64().unwrap(); s.execute("guide.select", &json!({"indexes": [g]})).unwrap(); s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); - let pos = s.doc().unwrap().doc.guides[g as usize].pos; + let pos = s.doc().unwrap().doc.ruler_guides()[g as usize].pos; assert!(close(pos, 60.0), "a vertical guide moves 10 right: {pos}"); } @@ -167,7 +167,7 @@ fn constrain_angle_keeps_guides_selected_with_art_on_the_page_axes() { let h = s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap()["index"].as_u64().unwrap() as usize; s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); s.execute("guide.select", &json!({"indexes": [v, h], "toggle": true})).unwrap(); - let pos = |s: &Session| s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let pos = |s: &Session| s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::>(); let at = centre(&s, id); s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); assert_eq!(centre(&s, id) - at, Vec2::new(0.0, -10.0), "the art moves along the turned axis"); diff --git a/crates/engine/src/tests/guide_clipboard.rs b/crates/engine/src/tests/guide_clipboard.rs index d509dfa20..421151102 100644 --- a/crates/engine/src/tests/guide_clipboard.rs +++ b/crates/engine/src/tests/guide_clipboard.rs @@ -12,7 +12,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value { /// The active document's guides: (vertical, pos, layer). fn guides(s: &Session) -> Vec<(bool, f64, Option)> { - s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos, g.layer)).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos, g.layer)).collect() } #[test] @@ -31,7 +31,7 @@ fn ruler_guides_copy_and_paste_into_another_document() { let r = run(&mut s, "edit.paste", json!({})); assert_eq!(r["guides"], 2); assert_eq!(guides(&s), [(true, 120.0, Some(layer)), (false, 80.0, Some(layer))], "where they were, not offset"); - assert_eq!(s.doc().unwrap().selection.guides, [0, 1]); + assert_eq!(super::selected_guides(&s), [0, 1]); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); run(&mut s, "edit.undo", json!({})); assert!(guides(&s).is_empty()); @@ -104,9 +104,9 @@ fn two_pastes_add_their_own_guides_on_the_current_layer_and_svg_has_none() { assert!(s.clipboard.flavour_types(true, true).is_empty(), "nor any other format (the app empties the system clipboard)"); let layer = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap()); run(&mut s, "edit.paste", json!({})); - assert_eq!(s.doc().unwrap().selection.guides, [1], "the first paste selects its own guide"); + assert_eq!(super::selected_guides(&s), [1], "the first paste selects its own guide"); run(&mut s, "edit.paste", json!({})); - assert_eq!(s.doc().unwrap().selection.guides, [2], "the second paste selects its own guide"); + assert_eq!(super::selected_guides(&s), [2], "the second paste selects its own guide"); let g = guides(&s); assert_eq!(g.len(), 3, "two pastes, two more guides: {g:?}"); assert_eq!((g[1].2, g[2].2), (Some(layer), Some(layer)), "on the layer new art goes to"); @@ -116,7 +116,7 @@ fn two_pastes_add_their_own_guides_on_the_current_layer_and_svg_has_none() { // Hiding that layer hides the guides pasted on it: they follow its visibility (and lock). run(&mut s, "layer.setProps", json!({"id": layer.0, "visible": false})); let st = s.doc().unwrap(); - let shown: Vec = st.doc.guides.iter().map(|g| st.doc.guide_shown(g)).collect(); + let shown: Vec = st.doc.ruler_guides().iter().map(|g| st.doc.guide_shown(g)).collect(); assert_eq!(shown, [true, false, false]); // With an object, the SVG holds the object and no guide. run(&mut s, "layer.setProps", json!({"id": layer.0, "visible": true})); diff --git a/crates/engine/src/tests/ids_without_selection.rs b/crates/engine/src/tests/ids_without_selection.rs index 8e26577f9..1e13c468c 100644 --- a/crates/engine/src/tests/ids_without_selection.rs +++ b/crates/engine/src/tests/ids_without_selection.rs @@ -436,9 +436,7 @@ fn an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing() { /// With the rectangle apart (`d`) selected, the same commands with `ids` (or `slices`) left out and /// with it null do the same: what the selection's list does for the commands that take the /// selection, and the command's own default for the others (`object.slice.select` deselects the -/// slices). `addon.color.filter` and `addon.color.applyNearestSwatch` read `ids` through -/// [`cmd::checked_ids_or_selection`], which refuses null as a value that is not a list and changes -/// nothing. +/// slices). #[test] fn ids_left_out_or_null_take_the_selection_or_the_default() { let (_, [.., d], _) = doc_with_type(); @@ -450,15 +448,7 @@ fn ids_left_out_or_null_take_the_selection_or_the_default() { let mut null = params.clone(); null[key] = serde_json::Value::Null; let with_null = outcome(cmd, &null, &[d], slice); - if matches!(cmd, "addon.color.filter" | "addon.color.applyNearestSwatch") { - let (mut s, ..) = doc_with_type(); - s.execute("select.set", &json!({ "ids": [d] })).unwrap(); - let st = s.doc().unwrap(); - let refused = with_null.0.as_ref().is_err_and(|e| e.ends_with("ids must be an array of non-negative integer object ids")); - if !refused || (&with_null.1, &with_null.2, with_null.3) != (&*st.doc, &st.selection, st.history.undo.len()) { - failures.push(format!("{cmd} {key}: null is not refused with nothing changed ({:.160?})", with_null.0)); - } - } else if with_null != left_out { + if with_null != left_out { failures.push(format!("{cmd} {key}: null does not do what leaving it out does ({:.160?})", left_out.0)); } let mut listed = params.clone(); @@ -477,6 +467,65 @@ fn ids_left_out_or_null_take_the_selection_or_the_default() { assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); } +/// The commands that read `ids` through [`cmd::checked_ids_or_selection`] take `ids: null` as left +/// out: with the rectangle apart (`d`) or the type object (`t`) selected, null gives the same +/// result or error, document, selection and undo history as leaving `ids` out. Print Ink Fixes +/// runs in a CMYK document. +#[test] +fn ids_null_takes_the_selection_in_every_command_that_falls_back_to_it() { + let (_, [.., d], t) = doc_with_type(); + let mut failures = vec![]; + let (rect, both, text) = (&[d][..], &[d, t][..], &[t][..]); + let cases = [ + ("addon.arrange.randomizeOpacity", json!({"seed": 1}), rect), + ("addon.arrange.sort", json!({"by": "area", "apply": "x"}), both), + ("addon.cleanup.simplify", json!({}), rect), + ("addon.cleanup.deepUngroup", json!({}), rect), + ("addon.cleanup.rejoin", json!({}), rect), + ("addon.color.nearestSwatch", json!({}), rect), + ("addon.pathtools.cornerSet", json!({"radius": 2}), rect), + ("addon.pathtools.cornerRemove", json!({}), rect), + ("addon.pathtools.shape.convert", json!({}), rect), + ("addon.pathtools.erase", json!({"points": [[0, 20], [200, 20]]}), rect), + ("addon.pathtools.crossings", json!({}), rect), + ("addon.pathtools.expandDashes", json!({}), rect), + ("object.path.smooth", json!({}), rect), + ("object.textWrap.make", json!({}), rect), + ("object.textWrap.release", json!({}), rect), + ("object.textWrap.options", json!({}), rect), + ("variable.bind", json!({"variable": "v"}), rect), + ("variable.unbind", json!({}), rect), + ("addon.cleanup.breakText", json!({}), text), + ("addon.cleanup.combineText", json!({}), text), + ("addon.cleanup.joinText", json!({}), text), + ("addon.cleanup.removeBreaks", json!({"kind": "line"}), text), + ("addon.cleanup.resetTextTransform", json!({}), text), + ("addon.cleanup.alignInPlace", json!({"align": "left"}), text), + ]; + for (cmd, params, selected) in cases { + let left_out = outcome(cmd, ¶ms, selected, false); + let mut null = params.clone(); + null["ids"] = serde_json::Value::Null; + let with_null = outcome(cmd, &null, selected, false); + if with_null != left_out { + failures.push(format!("{cmd}: null gives {:.160?}; left out gives {:.160?}", with_null.0, left_out.0)); + } + } + let print_fixes = |p: &serde_json::Value| { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk", "created": null})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let r = s.execute("addon.color.printFixes", p).map_err(|e| e.to_string()); + let st = s.doc().unwrap(); + (r, (*st.doc).clone(), st.selection.clone(), st.history.undo.len()) + }; + let (left_out, with_null) = (print_fixes(&json!({"inkLimit": 200})), print_fixes(&json!({"inkLimit": 200, "ids": null}))); + if with_null != left_out { + failures.push(format!("addon.color.printFixes: null gives {:.160?}; left out gives {:.160?}", with_null.0, left_out.0)); + } + assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); +} + /// With no document open, every command that takes `ids` refuses a list with an entry that is not /// a non-negative integer, and a value that is not a list, with the same error as with a document /// open (`effect.list` and `links.findFiles` run without a document). diff --git a/crates/engine/src/tests/keyguide.rs b/crates/engine/src/tests/keyguide.rs index 09acdd726..327c0b4b2 100644 --- a/crates/engine/src/tests/keyguide.rs +++ b/crates/engine/src/tests/keyguide.rs @@ -37,14 +37,14 @@ fn a_clicked_guide_is_the_key_and_align_lines_the_objects_up_on_it() { // Click an object, Shift-click the guide, then click the guide again. click(&mut s, 110.0, 110.0, false); click(&mut s, 500.0, 400.0, true); - assert_eq!((s.doc().unwrap().selection.objects.clone(), s.doc().unwrap().selection.guides.clone()), (vec![a], vec![0])); + assert_eq!((s.doc().unwrap().selection.objects.clone(), super::selected_guides(&s)), (vec![a], vec![0])); click(&mut s, 500.0, 400.0, false); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0), "the guide is the key"); + assert_eq!(super::key_guide(&s), Some(0), "the guide is the key"); assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["keyGuide"], 0); // Shift-click a third object: the guide stays the key. click(&mut s, 310.0, 260.0, true); assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0)); + assert_eq!(super::key_guide(&s), Some(0)); let (ya, yb) = (bounds(&s, a).y0, bounds(&s, b).y0); s.execute("object.align", &json!({"horizontal": "center", "vertical": "center"})).unwrap(); // Both centred on the guide's x; a vertical guide fixes no y, so they keep theirs. @@ -52,12 +52,12 @@ fn a_clicked_guide_is_the_key_and_align_lines_the_objects_up_on_it() { let r = bounds(&s, id); assert!((r.center().x - 500.0).abs() < 1e-6 && (r.y0 - y).abs() < 1e-6, "{id:?}: {r:?}"); } - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 500.0, "the guide stays put"); + assert_eq!(positions(&s)[0], 500.0, "the guide stays put"); s.execute("object.align", &json!({"horizontal": "left"})).unwrap(); assert!((bounds(&s, a).x0 - 500.0).abs() < 1e-6 && (bounds(&s, b).x0 - 500.0).abs() < 1e-6); // A click on the key guide again: no key. click(&mut s, 500.0, 400.0, false); - assert_eq!(s.doc().unwrap().selection.key_guide, None); + assert_eq!(super::key_guide(&s), None); } #[test] @@ -96,22 +96,22 @@ fn select_key_takes_only_a_selected_guide_with_objects() { s.execute("select.key", &json!({"guide": 1})).unwrap(); // An object key replaces the guide key, and the other way round. s.execute("select.key", &json!({"id": b.0})).unwrap(); - assert_eq!((s.doc().unwrap().selection.key, s.doc().unwrap().selection.key_guide), (Some(b), None)); + assert_eq!((s.doc().unwrap().selection.key, super::key_guide(&s)), (Some(b), None)); s.execute("select.key", &json!({"guide": 1})).unwrap(); - assert_eq!((s.doc().unwrap().selection.key, s.doc().unwrap().selection.key_guide), (None, Some(1))); + assert_eq!((s.doc().unwrap().selection.key, super::key_guide(&s)), (None, Some(1))); // Deleting a guide before it keeps the key on the same guide. s.execute("guide.remove", &json!({"index": 0})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0)); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 600.0); + assert_eq!(super::key_guide(&s), Some(0)); + assert_eq!(positions(&s)[0], 600.0); // Taking the guide out of the selection, or every object, lets the key go. s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, None); + assert_eq!(super::key_guide(&s), None); s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); s.execute("select.key", &json!({"guide": 0})).unwrap(); s.execute("select.toggle", &json!({"id": a.0})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0), "one object is enough"); + assert_eq!(super::key_guide(&s), Some(0), "one object is enough"); s.execute("select.toggle", &json!({"id": b.0})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, None, "no object to align"); + assert_eq!(super::key_guide(&s), None, "no object to align"); } #[test] @@ -141,12 +141,12 @@ fn selected_guides_align_to_a_clicked_key_object() { click(&mut s, 110.0, 110.0, false); click(&mut s, 500.0, 300.0, true); click(&mut s, 300.0, 400.0, true); - assert_eq!(s.doc().unwrap().selection.guides, vec![0, 1]); + assert_eq!(super::selected_guides(&s), vec![0, 1]); click(&mut s, 110.0, 110.0, false); assert_eq!(s.doc().unwrap().selection.key, Some(a), "the object is the key"); let before = bounds(&s, a); s.execute("object.align", &json!({"horizontal": "center", "vertical": "center"})).unwrap(); - let guides = |s: &Session| s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let guides = positions; assert_eq!(guides(&s), [120.0, 110.0], "the vertical guide on the centre's x, the horizontal one on its y"); assert_eq!(bounds(&s, a), before, "the key stays put"); s.execute("object.align", &json!({"horizontal": "right"})).unwrap(); @@ -174,15 +174,15 @@ fn guides_align_to_a_key_object_among_others_and_parallel_ones_to_a_key_guide() s.execute("select.key", &json!({"id": b.0})).unwrap(); s.execute("object.align", &json!({"horizontal": "left"})).unwrap(); assert_eq!(bounds(&s, a).x0, 300.0); - assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.pos == 300.0)); + assert!(positions(&s).iter().all(|p| *p == 300.0)); // Aligning to the selection leaves guides alone. s.execute("object.align", &json!({"horizontal": "right", "to": "selection"})).unwrap(); - assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.pos == 300.0)); + assert!(positions(&s).iter().all(|p| *p == 300.0)); // A key guide stays put, and the other vertical guide lines up on it. s.execute("guide.move", &json!({"index": 1, "pos": 700})).unwrap(); s.execute("select.key", &json!({"guide": 1})).unwrap(); s.execute("object.align", &json!({"horizontal": "center"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(), [700.0, 700.0]); + assert_eq!(positions(&s), [700.0, 700.0]); } /// Objects 40 pt wide at `xs` (y 100) and a vertical guide at `pos` that is their key. @@ -208,7 +208,7 @@ fn distribute_with_a_spacing_spaces_the_objects_from_a_key_guide() { for (h, want) in [("left", [330.0, 340.0, 360.0]), ("center", [310.0, 320.0, 340.0]), ("right", [290.0, 300.0, 320.0])] { s.execute("object.distribute", &json!({"horizontal": h, "spacing": 10})).unwrap(); assert_eq!(lefts(&s, &ids), want, "{h}"); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 350.0, "{h}: the guide stays put"); + assert_eq!(positions(&s)[0], 350.0, "{h}: the guide stays put"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(lefts(&s, &ids), [100.0, 300.0, 500.0], "{h}: one undo step"); } @@ -300,14 +300,14 @@ fn guides_alone(vertical: &[f64], horizontal: &[f64]) -> Session { /// Selects the objects `ids` and the ruler guides `guides`, and nothing else. fn select_with_guides(s: &mut Session, ids: &[NodeId], guides: &[usize]) { s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::>()})).unwrap(); - let now = s.doc().unwrap().selection.guides.clone(); - let flip: Vec = (0..s.doc().unwrap().doc.guides.len()).filter(|i| now.contains(i) != guides.contains(i)).collect(); + let now = super::selected_guides(s); + let flip: Vec = (0..positions(s).len()).filter(|i| now.contains(i) != guides.contains(i)).collect(); s.execute("guide.select", &json!({"indexes": flip, "toggle": true})).unwrap(); assert_eq!((s.doc().unwrap().selection.objects.len(), s.doc().unwrap().selection.guides.len()), (ids.len(), guides.len())); } fn positions(s: &Session) -> Vec { - s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect() } #[test] diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index e1def8a6f..e9a30c57e 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -3686,7 +3686,7 @@ fn make_release_clear_guides() { s.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap(); let n = undo_len(&s); assert_eq!(s.execute("view.guides.clear", &json!({})).unwrap()["count"], 2); - assert!(s.doc().unwrap().doc.guides.is_empty()); + assert!(s.doc().unwrap().doc.ruler_guides().is_empty()); assert!(s.doc().unwrap().doc.node(a).is_none()); assert_eq!(undo_len(&s), n + 1); } @@ -3751,13 +3751,13 @@ fn ruler_guides_and_lock() { let mut s = session(); assert_eq!(s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap()["index"], 0); s.execute("guide.move", &json!({"index": 0, "pos": 70})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 70.0); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].pos, 70.0); assert_eq!(s.execute("view.guides.lock", &json!({})).unwrap()["locked"], true); assert!(s.guides_locked()); assert!(matches!(s.execute("guide.remove", &json!({"index": 0})), Err(EngineError::Disabled(..)))); s.execute("view.guides.lock", &json!({})).unwrap(); s.execute("guide.remove", &json!({"index": 0})).unwrap(); - assert!(s.doc().unwrap().doc.guides.is_empty()); + assert!(s.doc().unwrap().doc.ruler_guides().is_empty()); assert!(s.execute("guide.remove", &json!({"index": 0})).is_err()); } @@ -3804,7 +3804,7 @@ fn invalid_guide_lock_parameters_keep_guides_selected() { for params in [json!({"locked": "false"}), json!({"locked": 1})] { assert!(s.execute("view.guides.lock", ¶ms).is_err(), "accepted {params}"); assert!(!s.guides_locked(), "locked guides for {params}"); - assert_eq!(s.doc().unwrap().selection.guides, vec![0], "cleared selection for {params}"); + assert_eq!(selected_guides(&s), vec![0], "cleared selection for {params}"); assert_eq!(s.doc().unwrap().history.undo.len(), before_undo, "recorded undo for {params}"); } // Left out, or null as for every command, it toggles. @@ -3987,11 +3987,11 @@ fn a_failed_preview_leaves_locked_guides_deselected() { } fn guides(s: &Session) -> Vec<(bool, f64)> { - s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect() } fn selected_guides(s: &Session) -> Vec { - s.doc().unwrap().selection.guides.clone() + super::selected_guides(s) } /// #414: ruler guides are selected, moved, copied, nudged and deleted like art, over commands. @@ -4219,10 +4219,12 @@ fn guides_and_art_later_cases(s: &mut Session, a: NodeId) { assert_eq!(guides(s).len(), 4); assert_eq!(guides(s)[2], (true, 210.0)); assert_eq!(guides(s)[3], (false, 320.0)); - assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2); + // The layer's art (its ruler guides are listed after it). + let art = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().filter(|c| !c.is_ruler_guide()).count(); + assert_eq!(art(s), 2); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(guides(s).len(), 2); - assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); + assert_eq!(art(s), 1); } /// #451: an artboard guide (`guide.add {artboard}`) runs across its artboard only, and is moved, @@ -4244,7 +4246,7 @@ fn artboard_guides_go_with_their_artboard() { ); let spans = |s: &Session| { let d = &s.doc().unwrap().doc; - d.guides.iter().map(|g| d.guide_span(g)).collect::>() + d.ruler_guides().iter().map(|g| d.guide_span(g)).collect::>() }; assert_eq!(spans(&s), [Some((0.0, 300.0)), None]); for bad in [json!(5), json!("x"), json!(-1)] { @@ -4270,7 +4272,7 @@ fn artboard_guides_go_with_their_artboard() { // Rearranged, they follow their artboards. s.execute("artboard.rearrange", &json!({"columns": 4})).unwrap(); let d = &s.doc().unwrap().doc; - for (g, ab) in d.guides.iter().filter_map(|g| Some((g, d.artboards.iter().find(|a| Some(a.id) == g.artboard)?))) { + for (g, ab) in d.ruler_guides().iter().filter_map(|g| Some((g, d.artboards.iter().find(|a| Some(a.id) == g.artboard)?))) { assert_eq!(g.pos - ab.rect.x0, 160.5, "{g:?} on {ab:?}"); } // Deleted, the artboard takes its guides; the selected ones left stay selected. @@ -4405,13 +4407,16 @@ fn cmd_ruler_drags_make_guides_of_the_active_artboard_when_the_preference_is_on( #[test] fn guides_from_older_files_are_put_on_a_layer() { let mut s = Session::new(); - let mut d = vectorcraft_doc::Document::new(400.0, 300.0); - d.guides.push(vectorcraft_doc::Guide::new(true, 50.0)); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + // A document read with its guides on no layer, as such a file holds them. + let mut v = serde_json::to_value(vectorcraft_doc::Document::new(400.0, 300.0)).unwrap(); + v["guides"] = json!([{"vertical": true, "pos": 50.0}, {"vertical": false, "pos": 80.0}]); + let d: vectorcraft_doc::Document = serde_json::from_value(v).unwrap(); s.add_document(d, None); let st = s.doc().unwrap(); let top = st.doc.default_layer(); - assert!(top.is_some() && st.doc.guides.iter().all(|g| g.layer == top), "{:?}", st.doc.guides); + let guides = st.doc.ruler_guides(); + assert_eq!(guides.len(), 2); + assert!(top.is_some() && guides.iter().all(|g| g.layer == top), "{guides:?}"); } /// Ruler guides are layer objects: made on the current layer, hidden, locked and deleted with it, @@ -4421,10 +4426,10 @@ fn ruler_guides_live_on_layers() { let mut s = session(); let first = s.doc().unwrap().current_layer().unwrap(); s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[0].layer, Some(first), "on the current layer"); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].layer, Some(first), "on the current layer"); let second = id_of(&s.execute("layer.new", &json!({"name": "Guides"})).unwrap()); s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(second), "the new layer is current"); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[1].layer, Some(second), "the new layer is current"); assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 1, "layer": 99999})).is_err()); // Hidden or locked with its layer. s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap(); @@ -4435,12 +4440,12 @@ fn ruler_guides_live_on_layers() { s.execute("layer.setProps", &json!({"ids": [second.0], "locked": false})).unwrap(); // Moved to the first layer, then back. s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(first)); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[1].layer, Some(first)); s.execute("guide.setLayer", &json!({"index": 1, "layer": second.0})).unwrap(); assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": 99999})).is_err()); // Several at once, as dragging their rows onto a layer's row does. s.execute("guide.setLayer", &json!({"indexes": [0, 1], "layer": first.0})).unwrap(); - assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.layer == Some(first))); + assert!(s.doc().unwrap().doc.ruler_guides().iter().all(|g| g.layer == Some(first))); s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap(); assert!(s.execute("guide.setLayer", &json!({"indexes": [7], "layer": first.0})).is_err()); // Deleting a layer deletes its guides; undo brings them back. @@ -4476,7 +4481,7 @@ fn guides_on_hidden_or_locked_layers_are_left_alone() { s.execute("layer.setProps", &json!({"ids": [first.0], "locked": false})).unwrap(); // The current layer locked: the new guide goes on the top shown, unlocked layer. s.execute("guide.add", &json!({"vertical": true, "pos": 20})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[2].layer, Some(first)); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[2].layer, Some(first)); assert_eq!(guides(&s).len(), 3); } @@ -4489,7 +4494,7 @@ fn guides_go_with_their_layers_when_duplicated_merged_and_flattened() { s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); let second = id_of(&s.execute("layer.new", &json!({})).unwrap()); s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); - let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).map(|(_, g)| (g.vertical, g.pos)).collect::>(); + let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).iter().map(|g| (g.vertical, g.pos)).collect::>(); // Duplicate Layer copies its guides onto the copy. let copy = s.execute("layer.duplicate", &json!({"ids": [second.0]})).unwrap()["ids"][0].as_u64().map(NodeId).unwrap(); assert_eq!((on(&s, second), on(&s, copy)), (vec![(false, 300.0)], vec![(false, 300.0)])); diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 4d549c31e..7e7204f6e 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -798,7 +798,7 @@ fn copied_artboards_take_a_free_id_and_keep_their_own_guides() { let mut kept = vec![]; for i in [1, 0] { s.execute("artboard.delete", &json!({"index": i})).unwrap(); - kept.push(s.doc().unwrap().doc.guides.iter().map(|g| g.artboard).collect::>()); + kept.push(s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.artboard).collect::>()); s.execute("edit.undo", &json!({})).unwrap(); } got.push((cmd, s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect::>(), kept)); @@ -1675,12 +1675,12 @@ fn delete_empty_artboards_deletes_those_with_no_art_in_one_step() { assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [0, 2]})); let st = s.doc().unwrap(); assert_eq!(artboard_names(&s), ["Artboard 2"]); - assert_eq!(st.doc.guides.len(), 1, "the deleted artboards' guides go with them"); + assert_eq!(st.doc.ruler_guides().len(), 1, "the deleted artboards' guides go with them"); assert_eq!(st.doc.node(art).map(|n| n.geometric_bounds()), before.node(art).map(|n| n.geometric_bounds()), "the art stays"); assert_eq!(st.history.undo.len(), steps + 1); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 2", "Artboard 3"]); - assert_eq!(s.doc().unwrap().doc.guides.len(), 3); + assert_eq!(s.doc().unwrap().doc.ruler_guides().len(), 3); } /// What counts as art: hidden and locked objects, an object straddling two artboards and one diff --git a/crates/engine/src/tests/pathops.rs b/crates/engine/src/tests/pathops.rs index 72aa559ae..139735706 100644 --- a/crates/engine/src/tests/pathops.rs +++ b/crates/engine/src/tests/pathops.rs @@ -656,13 +656,13 @@ fn simplify_refreshes_changed_path_selection_and_undo() { let kept = NodeId(s.execute("path.create", &json!({"anchors": [{"x": 350, "y": 180}]})).unwrap()["id"].as_u64().unwrap()); let unrelated = rect(&mut s, 400.0, 200.0, 40.0, 30.0); s.edit("selection fixture", |d, sel| { - d.guides.push(vectorcraft_doc::Guide::new(true, 30.0)); + let guide = d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 30.0)); sel.set([kept, id]); sel.anchors.insert(id, [(0, 19)].into_iter().collect()); sel.anchors.insert(kept, [(0, 0)].into_iter().collect()); sel.key = Some(kept); sel.target = Some(id); - sel.guides.push(0); + sel.guides.push(guide); Ok(()) }) .unwrap(); @@ -781,7 +781,7 @@ fn split_into_grid_options() { rect(&mut s, 10.0, 20.0, 110.0, 50.0); let r = s.execute("object.path.splitIntoGrid", &p).unwrap(); let cells: Vec = ids(&r).iter().map(|id| bounds(&s, *id)).collect(); - let guides: Vec<(bool, f64)> = s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect(); + let guides: Vec<(bool, f64)> = s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect(); assert_eq!(r["guides"], guides.len()); (cells, guides) }; @@ -812,13 +812,16 @@ fn split_into_grid_options() { assert!(close(bounds(&s, r[0]), Rect::new(0.0, 0.0, 40.0, 60.0), 1e-9) && close(bounds(&s, r[1]), Rect::new(60.0, 0.0, 100.0, 60.0), 1e-9)); let st = s.doc().unwrap(); assert!(r.iter().all(|id| node(&s, *id).appearance == node(&s, r[0]).appearance) && st.doc.node(back).is_none() && st.doc.node(front).is_none()); - assert_eq!(st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::>(), [kept, r[0], r[1]], "above the path left alone"); + let art: Vec = st.doc.layers[0].children().unwrap().iter().filter(|n| !n.is_ruler_guide()).map(|n| n.id).collect(); + assert_eq!(art, [kept, r[0], r[1]], "above the path left alone"); + let children = st.doc.layers[0].children().unwrap(); + assert!(children.len() == 9 && children[3..].iter().all(|n| n.is_ruler_guide()), "the six guides are above the art"); assert_eq!(st.selection.objects, r); - assert!(st.doc.guides.iter().all(|g| g.layer == Some(st.doc.layers[0].id))); + assert!(st.doc.ruler_guides().iter().all(|g| g.layer == Some(st.doc.layers[0].id))); assert_eq!(st.history.undo.len(), undo + 1); s.execute("edit.undo", &json!({})).unwrap(); let st = s.doc().unwrap(); - assert!(st.doc.node(back).is_some() && st.doc.node(front).is_some() && st.doc.guides.is_empty()); + assert!(st.doc.node(back).is_some() && st.doc.node(front).is_some() && st.doc.ruler_guides().is_empty()); // A grid with no room for its gutters is refused and changes nothing. assert!(s.execute("object.path.splitIntoGrid", &json!({"columns": 4, "gutter": 50})).is_err()); assert!(s.execute("object.path.splitIntoGrid", &json!({"width": -5})).is_err()); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 3c647deb0..a4be6e554 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -13,6 +13,11 @@ use crate::*; const DESATURATE: &[u8] = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm"); const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate"; +/// Held by the tests that set the Additional Plug-ins Folder preference or run `plugin.reload`: +/// the folder last loaded and its report are kept for the whole process, so another test loading +/// its own folder between two steps would change what the next step loads or lists. +static PREFS_FOLDER: std::sync::Mutex<()> = std::sync::Mutex::new(()); + fn run(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } @@ -1278,6 +1283,7 @@ fn filters_keep_their_data_with_the_document() { #[test] fn plugin_management_commands() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); install(&mut s, DESATURATE); let list = run(&mut s, "plugin.list", json!({})); @@ -1319,6 +1325,7 @@ fn plugin_management_commands() { /// installs what is in it, as setting any other folder does. #[test] fn plugins_folder_set_again_after_clearing_installs_its_plugins() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let dir = crate::tests_links::Folder::new("plugin-folder-cleared"); let id = "test.engine.foldercleared"; let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#); @@ -1998,6 +2005,7 @@ fn a_grants_file_that_cannot_be_deleted_keeps_the_stored_module() { /// the plug-in store stays, with the stored module it covers. #[test] fn a_plugin_folder_ends_the_process_grant_of_an_id_it_replaces() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); let id = "test.engine.permissions.folderstale"; let empty = json!({"objects": []}); @@ -2175,6 +2183,7 @@ fn a_failed_store_write_keeps_the_stored_module_with_its_grant() { /// grants held by the process. #[test] fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); let id = "test.engine.permissions.folderbytes"; let plain = "test.engine.permissions.folderplain"; @@ -2403,6 +2412,7 @@ fn a_run_past_the_memory_budget_fails_before_the_plugin_starts() { /// until it is granted on its own. #[test] fn granted_permissions_are_kept_and_folders_skip_the_rest() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let folder = crate::tests_links::Folder::new("plugin-store-grants"); let id = "test.engine.permissions.stored"; let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); @@ -2491,6 +2501,7 @@ fn granted_permissions_are_kept_and_folders_skip_the_rest() { /// covers no module. #[test] fn a_grant_covers_only_the_module_it_was_given_to() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let folder = crate::tests_links::Folder::new("plugin-store-grant-hash"); let id = "test.engine.granthash"; let mut s = session(); diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 8e3c043e4..ed37d0969 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -1990,9 +1990,12 @@ fn real_time_drawing_off_still_shows_a_dragged_guide() { set_pref(&mut s, "realTimeDrawing", json!(false)); s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 200.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, 150.0, 200.0), v).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 150.0, "the guide moves while dragged"); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].pos, 150.0, "the guide moves while dragged"); s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 200.0), v).unwrap(); - assert_eq!((s.doc().unwrap().doc.guides[0].pos, s.doc().unwrap().history.undo.last().map(|e| e.label.as_str())), (150.0, Some("Move Guide"))); + assert_eq!( + (s.doc().unwrap().doc.ruler_guides()[0].pos, s.doc().unwrap().history.undo.last().map(|e| e.label.as_str())), + (150.0, Some("Move Guide")) + ); } /// With Real-time Drawing off, a 9-slice guide of the edited symbol moved with the Selection tool diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index 68b8b57ab..d92365a35 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -579,7 +579,7 @@ fn units_and_ruler_guides_survive_save_and_reopen() { s.execute("file.close", &json!({})).unwrap(); s.execute("document.open", &json!({"path": p})).unwrap(); let doc = &s.doc().unwrap().doc; - let guides: Vec<_> = doc.guides.iter().map(|g| (g.vertical, g.pos)).collect(); + let guides: Vec<_> = doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect(); assert_eq!((doc.units, guides), (vectorcraft_doc::Unit::Millimeters, vec![(true, 50.0), (false, 30.0)]), "{ext}"); } let _ = std::fs::remove_dir_all(d); diff --git a/crates/engine/src/tests/script_console.rs b/crates/engine/src/tests/script_console.rs index 985e5b810..27f53cef0 100644 --- a/crates/engine/src/tests/script_console.rs +++ b/crates/engine/src/tests/script_console.rs @@ -137,7 +137,10 @@ fn the_result_and_the_report_agree_and_count_each_kind_of_host_call() { try { vc.run('no.such', {}); } catch (e) {} var d = vc.document; var c = vc.commands; vc.log({a: 1}); vc.log('two\\nlines'); $.writeln('w');"; let r = run(&mut s, source).unwrap(); assert_eq!(&r, s.script_report().unwrap()); - assert_eq!(r["hostCallCounts"], json!({"vcRun": 3, "documentReads": 1, "commandListReads": 1, "vcLog": 2, "consoleWrites": 1})); + assert_eq!( + r["hostCallCounts"], + json!({"vcRun": 3, "documentReads": 1, "commandListReads": 1, "vcLog": 2, "consoleWrites": 1, "fileCalls": 0, "fileExecutes": 0}) + ); assert_eq!(r["timingAvailable"], true); assert!(r["runTimeMs"].is_u64()); let slow = r["slowestCommands"].as_array().unwrap(); diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs new file mode 100644 index 000000000..aae4810da --- /dev/null +++ b/crates/engine/src/tests/script_io.rs @@ -0,0 +1,407 @@ +//! `File` and `Folder` in a script: their classes and names, reading inside the script's own folder, +//! the paths refused before anything is looked up, the read limits, and the parts that write, which +//! are not available. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::file_access::{AutomationRoots, confine, script_probes}; +use crate::*; + +const DENIED: &str = "Script file access denied"; + +/// A fresh folder for test `name`, with a `main` folder inside it for the script. +fn folder(name: &str) -> PathBuf { + let base = std::env::temp_dir().join(format!("vc-script-io-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("main")).unwrap(); + base +} + +fn write(path: &Path, bytes: &[u8]) { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::write(path, bytes).unwrap(); +} + +/// Runs `main/main.jsx` in `base`, with the text `text`. +fn run(base: &Path, text: &str) -> Value { + let main = base.join("main/main.jsx"); + write(&main, text.as_bytes()); + Session::new().execute("script.run", &json!({"path": main.to_string_lossy()})).unwrap() +} + +/// The values the run of `text` logged. +fn logged(base: &Path, text: &str) -> Value { + run(base, text)["log"].clone() +} + +#[test] +fn file_constructor_identity_and_folder_path() { + let base = folder("identity"); + write(&base.join("main/data.txt"), b"x"); + std::fs::create_dir_all(base.join("main/sub")).unwrap(); + let log = logged( + &base, + "var a = new File('data.txt'), b = new File('data.txt');\n\ + vc.log([a === b, a instanceof File, a instanceof Folder, File('sub') instanceof Folder, new File('sub') instanceof File,\n\ + Folder('data.txt') instanceof File, new Folder('data.txt') instanceof Folder, File('nope') instanceof File,\n\ + a.parent instanceof Folder, a.name, a.parent.fsName === Folder.current.fsName, String(a) === a.absoluteURI,\n\ + a.exists, new File('nope').exists, a.length, a.created instanceof Date, Folder('sub').exists, new File().exists]);", + ); + assert_eq!(log, json!([[false, true, false, true, true, true, true, true, true, "data.txt", true, true, true, false, 1, true, true, false]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn file_names_are_uri_encoded_and_current_relative() { + let base = folder("names"); + std::fs::create_dir_all(base.join("main/sub")).unwrap(); + let log = logged( + &base, + "var f = new File('caf%C3%A9 x.txt');\n\ + var sep = File.fs === 'Windows' ? '\\\\' : '/';\n\ + vc.log([f.name, f.displayName, f.relativeURI, f.fsName === Folder.current.fsName + sep + 'caf\\u00e9 x.txt',\n\ + File.encode('\\u00e9 /'), File.decode('%C3%A9'), new File('sub/../sub/./y.txt').relativeURI,\n\ + new File('~/z').fsName === new Folder('~').fsName + sep + 'z', new File('a/b').path === new Folder('a').absoluteURI]);\n\ + Folder.current = new Folder('sub');\n\ + vc.log([new File('w').parent.name, new File('w').relativeURI, new File('../w').getRelativeURI(Folder.current.parent)]);\n\ + var l = new File('literal%2520name.txt'); Folder.current = new Folder('p%2541');\n\ + vc.log([l.displayName, l.name, l.absoluteURI.slice(-20), l.fsName === new File(l).fsName, Folder.current.displayName, new File('x').parent.displayName]);", + ); + assert_eq!( + log, + json!([ + ["caf%C3%A9%20x.txt", "caf\u{e9} x.txt", "caf%C3%A9%20x.txt", true, "%C3%A9%20%2F", "\u{e9}", "sub/y.txt", true, true], + ["sub", "w", "w"], + ["literal%20name.txt", "literal%2520name.txt", "literal%2520name.txt", true, "p%41", "p%41"] + ]) + ); + if cfg!(windows) { + let log = logged(&base, "vc.log([new File('/c/a b/x.txt').fsName, new File('C:/a/x.txt').absoluteURI, new File('d:\\\\q').fsName]);"); + assert_eq!(log, json!([["C:\\a b\\x.txt", "/c/a/x.txt", "D:\\q"]])); + } + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_without_scope_cannot_probe_home() { + let base = folder("home"); + let source = "var f = File('~/.ssh/id_test'); var g = new Folder('~');\n\ + vc.log([f instanceof File, f.exists, f.open('r'), f.error, g.getFiles(), g.error, f.modified, f.length]);"; + let denied = json!([[true, false, false, "Script file access denied", null, "Script file access denied", null, 0]]); + let before = script_probes(); + let r = Session::new().execute("script.run", &json!({"source": source})).unwrap(); + assert_eq!(r["log"], denied, "a script given as text reads nothing"); + assert_eq!(script_probes(), before, "nothing was looked up"); + // A script run from a file is held to its folder. + let before = script_probes(); + assert_eq!(logged(&base, source), denied); + assert_eq!(script_probes(), before, "the home folder lies outside the script's folder by its names alone"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_unc_rejected_before_lookup() { + let base = folder("unc"); + let before = script_probes(); + let log = logged( + &base, + "var out = [];\n\ + var paths = ['\\\\\\\\host\\\\s\\\\x.txt', '//host/s/x.txt', '\\\\\\\\?\\\\C:\\\\x.txt', '\\\\\\\\.\\\\pipe\\\\x'];\n\ + for (var i = 0; i < paths.length; i++) { var f = new File(paths[i]); out.push(f.exists, f.open('r'), new Folder(paths[i]).getFiles()); }\n\ + vc.log(out);", + ); + assert_eq!(log, json!([[false, false, null, false, false, null, false, false, null, false, false, null]])); + if cfg!(windows) { + assert_eq!(script_probes(), before, "no network or device path was looked up"); + } else { + // Outside Windows the backslash forms are relative file names in the script's folder, and + // `//host/s/x.txt` is an absolute path outside it. + let before = script_probes(); + let log = logged(&base, "var f = new File('//host/s/x.txt'); vc.log([f.exists, f.open('r'), new Folder('//host/s/x.txt').getFiles()]);"); + assert_eq!(log, json!([[false, false, null]])); + assert_eq!(script_probes(), before, "a path outside the script's folder was not looked up"); + } + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_link_escape_refused() { + let base = folder("links"); + write(&base.join("outside/secret.txt"), b"secret"); + write(&base.join("main/own.txt"), b"own"); + std::fs::create_dir_all(base.join("main/inner")).unwrap(); + // A folder link: a symbolic link, or on Windows a junction, which needs no privilege. + let link_folder = |target: &Path, link: &Path| { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + { + let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(link).arg(target).output().unwrap(); + assert!(made.status.success(), "mklink /J: {}", String::from_utf8_lossy(&made.stderr)); + } + }; + link_folder(&base.join("outside"), &base.join("main").join("away")); + link_folder(&base.join("main").join("inner"), &base.join("main").join("near")); + let log = logged( + &base, + "var away = new Folder('away'); var s = new File('away/secret.txt');\n\ + vc.log([away.exists, away.getFiles(), away.error, away.resolve(), s.exists, s.open('r'), s.error,\n\ + Folder.current.getFiles().map(function (f) { return f.name; }), new Folder('near').alias, new Folder('near').resolve().name]);", + ); + assert_eq!(log, json!([[false, null, DENIED, null, false, false, DENIED, ["inner", "main.jsx", "near", "own.txt"], true, "inner"]])); + #[cfg(unix)] + let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + let made = |target: &Path, link: &Path| std::os::windows::fs::symlink_file(target, link).is_ok(); + #[cfg(not(any(unix, windows)))] + let made = |_: &Path, _: &Path| false; + // Windows needs Developer Mode or elevation to make a link to a file. + if !made(&base.join("outside/secret.txt"), &base.join("main/leak.txt")) { + eprintln!("skipped: links to files can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(made(&base.join("main/own.txt"), &base.join("main/alias.txt"))); + let log = logged( + &base, + "var leak = new File('leak.txt');\n\ + vc.log([leak.exists, leak.open('r'), leak.error, leak.resolve(), new File('away/secret.txt').exists, new Folder('away').getFiles(),\n\ + Folder.current.getFiles().map(function (f) { return f.name; }), new File('alias.txt').alias, new File('alias.txt').resolve().name]);", + ); + assert_eq!(log, json!([[false, false, DENIED, null, false, null, ["alias.txt", "inner", "main.jsx", "near", "own.txt"], true, "own.txt"]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_oversize_refused_before_read() { + let base = folder("oversize"); + let big = std::fs::File::create(base.join("main/big.bin")).unwrap(); + big.set_len(9 << 20).unwrap(); + drop(big); + write(&base.join("main/small.txt"), b"ok"); + let r = + run(&base, "var f = new File('big.bin'); var s = new File('small.txt'); vc.log([f.length, f.open('r'), f.error, s.open('r'), s.read()]);"); + assert_eq!(r["log"], json!([[9437184, false, "Script file exceeds 8 MiB", true, "ok"]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_files_read_at_most_32_mib_in_a_run() { + let base = folder("total"); + write(&base.join("main/seven.bin"), &vec![b'a'; 7 << 20]); + let mut s = Session::new(); + let main = base.join("main/main.jsx"); + write(&main, b"var f = new File('seven.bin'); for (var i = 0; i < 5; i++) { f.open('r'); vc.log(i); }"); + let e = s.execute("script.run", &json!({"path": main.to_string_lossy()})); + assert!(e.is_err(), "the fifth open passes 32 MiB"); + let report = s.script_report().unwrap(); + assert_eq!(report["log"], json!([0, 1, 2, 3])); + assert!(report["message"].as_str().unwrap().contains("32 MiB"), "{report}"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_files_have_at_most_256_open() { + let base = folder("open"); + write(&base.join("main/empty.txt"), b""); + let log = logged( + &base, + "var all = [], fails = 0, error = '';\n\ + for (var i = 0; i < 300; i++) { var f = new File('empty.txt'); if (f.open('r')) all.push(f); else { fails++; error = f.error; } }\n\ + var closed = all[0].close(); var again = new File('empty.txt');\n\ + vc.log([all.length, fails, error, closed, again.open('r'), all[1].open('r'), new File('empty.txt').open('r')]);", + ); + assert_eq!(log, json!([[256, 44, "Script has 256 files open", true, true, true, false]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_folder_getfiles_stays_inside_scope() { + let base = folder("list"); + for name in ["a.jsx", "b.txt", "C.JSX"] { + write(&base.join("main").join(name), b""); + } + std::fs::create_dir_all(base.join("main/sub")).unwrap(); + write(&base.join("outside.jsx"), b""); + let log = logged( + &base, + "var d = Folder.current; var names = function (l) { var o = []; for (var i = 0; i < l.length; i++) o.push(l[i].name); return o; };\n\ + vc.log(names(d.getFiles('*.jsx')));\n\ + vc.log(names(d.getFiles(function (f) { return f instanceof Folder; })));\n\ + var up = d.parent; vc.log([names(d.getFiles('?.txt')), d.getFiles().length, up.getFiles(), up.error]);\n\ + try { d.getFiles(function () { throw new Error('stop'); }); } catch (e) { vc.log(e.message); }\n\ + vc.log([new Folder('nope').getFiles(), d.getFiles('*.jsx')[0] instanceof File]);", + ); + assert_eq!(log, json!([["C.JSX", "a.jsx", "main.jsx"], ["sub"], [["b.txt"], 5, null, DENIED], "stop", [null, true]])); + let many = base.join("main/many"); + for i in 0..=crate::script::files::MAX_ENTRIES { + write(&many.join(format!("{i:04}.txt")), b""); + } + let log = logged(&base, "var m = new Folder('many'); vc.log([m.getFiles(), m.error, m.getFiles('0001.*').length]);"); + assert_eq!(log, json!([[null, "Script folder has more than 2,048 entries", 1]])); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_bom_and_line_read() { + let base = folder("lines"); + write(&base.join("main/lines.txt"), b"\xEF\xBB\xBFone\r\ntwo\n\rthree\rfour\nfin"); + write(&base.join("main/e.txt"), "\u{e9}t\u{e9}".as_bytes()); + let log = logged( + &base, + "var f = new File('lines.txt'); var out = [f.eof, f.open('r'), f.encoding, f.tell()];\n\ + var lines = []; while (!f.eof) lines.push(f.readln()); out.push(lines, f.tell());\n\ + out.push(f.seek(3), f.read(3), f.seek(-3, 2), f.read(), f.seek(1, 1), f.seek(100), f.seek(-1), f.tell());\n\ + f.seek(6); out.push(f.readch(), f.readch(), f.tell(), f.close(), f.read(), f.error, f.eof);\n\ + var e = new File('e.txt'); e.open(); out.push(e.read(1), e.tell(), e.read(5), e.tell());\n\ + vc.log(out);", + ); + assert_eq!( + log, + json!([[ + true, + true, + "UTF-8", + 3, + ["one", "two", "three", "four", "fin"], + 27, + true, + "one", + true, + "fin", + false, + false, + false, + 27, + "\n", + "t", + 9, + true, + "", + "Script file is not open", + true, + "\u{e9}", + 2, + "t\u{e9}", + 5 + ]]) + ); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_encoding_binary_ascii() { + let base = folder("encodings"); + write(&base.join("main/bytes.bin"), &[0xE8, 0x41]); + write(&base.join("main/euro.txt"), &[0x80]); + let log = logged( + &base, + "var b = new File('bytes.bin'); b.encoding = 'BINARY'; b.open('r'); var t = b.read();\n\ + var a = new File('bytes.bin'); a.encoding = 'ASCII'; a.open('r');\n\ + var out = [t.charCodeAt(0), t.charAt(1), b.encoding, a.read(), a.error, a.tell()];\n\ + a.encoding = 'binary'; out.push(a.encoding, a.read().length);\n\ + var c = new File('euro.txt'); c.open('r'); out.push(c.encoding, c.read().charCodeAt(0));\n\ + var k = new File('bytes.bin'); k.encoding = 'KLINGON'; out.push(k.open('r'), k.error);\n\ + out.push(File.isEncodingAvailable('utf-8'), Folder.isEncodingAvailable('BINARY'), File.isEncodingAvailable('KLINGON'));\n\ + vc.log(out);\n\ + var m = new File('bytes.bin'); m.encoding = 'BINARY'; m.open('r'); m.encoding = 'KLINGON'; vc.log([m.error, m.encoding, m.read().length]);\n\ + var r = new File('bytes.bin'); r.open('r'); r.encoding = 'ASCII'; r.close(); vc.log([r.encoding, r.open('r'), r.read(), r.error]);", + ); + let undecodable = "Script file cannot be decoded"; + assert_eq!(log[0], json!([232, "A", "BINARY", "", undecodable, 0, "BINARY", 2, "CP1252", 0x20AC, false, undecodable, true, true, false])); + assert_eq!(log[1], json!([undecodable, "BINARY", 2]), "a name that is not an encoding leaves the open file's encoding"); + assert_eq!(log[2], json!(["ASCII", true, "", undecodable]), "an encoding set while open is kept after close"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn script_file_writes_dialogs_and_execute_are_not_available() { + let base = folder("writes"); + write(&base.join("main/keep.txt"), b"keep"); + let r = run( + &base, + "var f = new File('keep.txt'); var n = new File('new.txt');\n\ + var out = [n.open('w'), n.error, f.open('a'), f.open('e'), f.open('x'), f.error, f.write('x'), f.remove(), f.copy('c.txt'), f.rename('r.txt')];\n\ + out.push(new Folder('made').create(), f.openDlg(), f.error, File.openDialog(), Folder.selectDialog(), f.execute(), f.error);\n\ + f.hidden = true; out.push(f.error, f.hidden); f.error = ''; out.push(f.error);\n\ + vc.log(out);", + ); + assert_eq!( + r["log"], + json!([[ + false, + "Script file writing is not available", + false, + false, + false, + "Script file open mode must be r, w, e or a", + false, + false, + false, + false, + false, + null, + "Script file dialogs are not available", + null, + null, + false, + "Script files cannot be opened in another application", + "Script file writing is not available", + false, + "" + ]]) + ); + assert_eq!(r["hostCallCounts"]["fileExecutes"], 1); + assert!(r["hostCallCounts"]["fileCalls"].as_u64().unwrap() > 0); + assert_eq!(std::fs::read(base.join("main/keep.txt")).unwrap(), b"keep"); + assert!(!base.join("main/new.txt").exists() && !base.join("main/made").exists() && !base.join("main/c.txt").exists()); + let _ = std::fs::remove_dir_all(base); +} + +/// A run with automation roots in force reads inside its folder and the read roots, and a run given +/// as text reads nothing even where the roots allow it. +#[test] +fn script_files_read_inside_the_automation_read_roots() { + let base = folder("roots"); + write(&base.join("main/data.txt"), b"data"); + let source = "var f = new File('data.txt'); vc.log([f.exists, f.open('r'), f.error, f.read()]);"; + let roots = AutomationRoots::new(&[&base.join("main")], &[]).unwrap(); + assert_eq!(confine(roots.as_ref(), || logged(&base, source)), json!([[true, true, "", "data"]])); + let text = format!("var f = new File({:?}); vc.log([f.exists, f.error]);", base.join("main/data.txt").to_string_lossy()); + let r = confine(roots.as_ref(), || Session::new().execute("script.run", &json!({"source": text})).unwrap()); + assert_eq!(r["log"], json!([[false, "Script file access denied"]])); + let _ = std::fs::remove_dir_all(base); +} + +/// A path longer than 32,767 characters is refused before it is split into names. +#[test] +fn script_file_path_length_is_bounded() { + let base = folder("longpath"); + let log = logged( + &base, + "var s = 'a/'; while (s.length < 40000) s += s;\n\ + var out = []; try { new File(s); } catch (e) { out.push(e.name, e.message); }\n\ + try { new Folder('x').getRelativeURI(s); } catch (e) { out.push(e.name); }\n\ + out.push(new File(s.slice(0, 30000)).exists); vc.log(out);", + ); + assert_eq!(log, json!([["RangeError", "Script file path exceeds 32,767 characters", "RangeError", false]])); + let _ = std::fs::remove_dir_all(base); +} + +/// An error `File` or `Folder` throws is reported at the script's line that called it. +#[test] +fn script_file_errors_are_at_the_calling_line() { + let base = folder("errors"); + let mut s = Session::new(); + let main = base.join("main/main.jsx"); + write(&main, b"\n\n\nnew File('x').read(-1);"); + assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err()); + let report = s.script_report().unwrap(); + assert!(report["message"].as_str().unwrap().contains("RangeError: File.read: the count must be a finite number"), "{report}"); + assert_eq!(report["line"].as_u64(), Some(4), "{report}"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/crates/engine/src/tests/slices.rs b/crates/engine/src/tests/slices.rs index 87d0088d9..79b2cbe66 100644 --- a/crates/engine/src/tests/slices.rs +++ b/crates/engine/src/tests/slices.rs @@ -93,7 +93,7 @@ fn too_many_guide_pieces_are_refused() { let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); for i in 1..3000 { for vertical in [true, false] { - doc.guides.push(vectorcraft_doc::Guide::new(vertical, f64::from(i) * 0.1)); + doc.add_ruler_guide(&vectorcraft_doc::Guide::new(vertical, f64::from(i) * 0.1)); } } assert!(s.execute("object.slice.fromGuides", &json!({})).unwrap_err().to_string().contains("more than")); @@ -440,9 +440,9 @@ fn a_canceled_undo_scope_leaves_locked_or_hidden_slices_deselected() { /// Select slice `id` and the first ruler guide together, through [`Session::select`]. fn pick_slice_and_guide(s: &mut Session, id: u64) { - s.select(|_, sel| { + s.select(|d, sel| { sel.slices = vec![NodeId(id)]; - sel.guides = vec![0]; + sel.guides = d.ruler_guides().first().and_then(|g| g.id).into_iter().collect(); }) .unwrap(); } diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 6aa42f7d9..9eea9cf45 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1016,7 +1016,7 @@ fn dropped_type_press_restores_the_original_text_and_selection() { let a = at(&s, first); click(&mut s, a.x + 0.1, a.y); assert!(s.tool_wants_text()); - s.select(|_, selection| selection.guides.push(0)).unwrap(); + s.select(|d, selection| selection.guides.extend(d.ruler_guides().first().and_then(|g| g.id))).unwrap(); let before = s.doc().unwrap().selection.clone(); let b = at(&s, second); s.pointer(&PointerEvent::new(PointerKind::Down, b.x + 0.1, b.y), v).unwrap(); diff --git a/crates/engine/src/tests/turn_direction.rs b/crates/engine/src/tests/turn_direction.rs new file mode 100644 index 000000000..f12f0d196 --- /dev/null +++ b/crates/engine/src/tests/turn_direction.rs @@ -0,0 +1,116 @@ +//! Every angle or rotation a command takes says which way it turns (#6340): creation commands turn +//! clockwise and most edits counter-clockwise, so a client can't guess. + +use crate::cmd::params::{is_key, object_body, split_top}; +use crate::command_specs; + +/// Params that are angles but not a turn, with why. +const NOT_A_TURN: &[(&str, &str, &str)] = &[ + ("addon.pathtools.cornerSet", "maxAngle", "the angle a corner's sides meet at"), + ("addon.pathtools.cornerSet", "minAngle", "the angle a corner's sides meet at"), + ("addon.shapes.box3d", "angleX", "a tilt out of the page: the description says which side turns away"), + ("addon.shapes.box3d", "angleY", "a tilt out of the page: the description says which side turns away"), + ("addon.shapes.configure", "angleX", "a tilt out of the page: the description says which side turns away"), + ("addon.shapes.configure", "angleY", "a tilt out of the page: the description says which side turns away"), + ("object.path.simplify", "cornerAngle", "the angle a corner's sides meet at"), + ("perspective.grid.define", "angle", "the viewing angle, 0 to 90°"), +]; + +/// A param name that is an angle or a turn. +fn is_turn(key: &str) -> bool { + let k = key.to_ascii_lowercase(); + k.ends_with("angle") || k.ends_with("angles") || k.starts_with("angle") || ["rotation", "rotate", "piestart", "pieend"].contains(&k.as_str()) +} + +/// The bodies of the `{…}` objects in `text` that are not inside another one. +fn objects_in(text: &str) -> Vec<&str> { + let mut out = vec![]; + let mut rest = text; + while let Some(i) = rest.find('{') { + let Ok((body, after)) = object_body(&rest[i..]) else { break }; + out.push(body); + rest = after; + } + out +} + +/// `(key, what the description says about it)` for each key the object `body` names, and for the +/// keys of the objects inside it. A key given without a note (`a?, b?: degrees`) shares the note of +/// the next key that has one. +fn keys_and_notes(body: &str, out: &mut Vec<(String, String)>) { + let mut items: Vec<(&str, String)> = vec![]; + for entry in split_top(body, ',').into_iter().map(str::trim).filter(|e| !e.is_empty() && !e.starts_with('…')) { + for (i, alternative) in split_top(entry, '|').into_iter().enumerate() { + let head = alternative.split([':', '=', '(']).next().unwrap_or_default(); + let note = alternative.get(head.len()..).unwrap_or_default(); + let name = head.trim().trim_end_matches('?').trim_end(); + let a_key = is_key(name) && (i == 0 || head.trim_end().ends_with('?') || note.starts_with(':')); + match items.last_mut() { + Some((_, last)) if !a_key => last.push_str(alternative), + _ if a_key => items.push((name, note.to_string())), + _ => {} + } + } + } + for (i, (key, note)) in items.iter().enumerate() { + let shared = items.get(i..).unwrap_or_default().iter().map(|(_, n)| n.trim()).find(|n| !n.is_empty()).unwrap_or_default(); + out.push((key.to_string(), shared.to_string())); + for inner in objects_in(note) { + keys_and_notes(inner, out); + } + } +} + +/// The turn params of description `doc` whose note doesn't say which way they turn. +fn undirected(doc: &str) -> Vec { + let mut found = vec![]; + let mut next = Some(doc); + // `{…} or {…}`: each object the command takes. + while let Some(text) = next { + let Ok((body, rest)) = object_body(text) else { break }; + keys_and_notes(body, &mut found); + next = rest.trim_start().strip_prefix("or ").or_else(|| rest.trim_start().strip_prefix("| ")).filter(|r| r.starts_with('{')); + } + found.into_iter().filter(|(k, note)| is_turn(k) && !note.to_ascii_lowercase().contains("clockwise")).map(|(k, _)| k).collect() +} + +#[test] +fn a_note_is_found_for_each_key() { + assert_eq!(undirected("{angle: deg (counter-clockwise), origin?}"), Vec::::new()); + assert_eq!(undirected("{x, rotation?: deg} → {rotation}"), ["rotation"]); + // A shared note, an alternative, a nested object, and the object after `or`. + assert_eq!(undirected("{inAngle?, outAngle?: degrees counter-clockwise, size?: pt}"), Vec::::new()); + assert_eq!(undirected("{startAngle?, endAngle?, center?: [x,y] | hSpacing? | angle?}"), ["startAngle", "endAngle", "angle"]); + assert_eq!(undirected("{gradient?: {kind?: linear|radial, angle?: deg}, id?}"), ["angle"]); + assert_eq!(undirected("{id} or {ids, rotate?: deg}"), ["rotate"]); + // Not a turn by its name, or a value rather than a key. + assert_eq!(undirected("{autoRotate: true, orientation?: portrait|landscape, mode?: rotation|scale}"), Vec::::new()); +} + +/// Each command param that is an angle or a rotation says in its description which way it turns +/// (clockwise or counter-clockwise), unless [`NOT_A_TURN`] says why it isn't a turn. +#[test] +fn every_turn_param_says_which_way_it_turns() { + let mut missing = vec![]; + for c in command_specs() { + for key in undirected(c.params) { + if !NOT_A_TURN.iter().any(|(id, k, _)| *id == c.id && *k == key) { + missing.push(format!("{}: {key}", c.id)); + } + } + } + assert!(missing.is_empty(), "{} params don't say whether they turn clockwise or counter-clockwise:\n{}", missing.len(), missing.join("\n")); + let stale: Vec<_> = NOT_A_TURN + .iter() + .filter(|(id, key, _)| !command_specs().iter().any(|c| c.id == *id && keys_named(c.params).iter().any(|k| k == key))) + .collect(); + assert!(stale.is_empty(), "NOT_A_TURN names params no command takes: {stale:?}"); +} + +fn keys_named(doc: &str) -> Vec { + let mut found = vec![]; + if let Ok((body, _)) = object_body(doc) { + keys_and_notes(body, &mut found); + } + found.into_iter().map(|(k, _)| k).collect() +} diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index b096922e9..e9aa3c532 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -766,7 +766,7 @@ fn stroke_width(s: &Session, id: NodeId) -> f64 { } fn guide_positions(s: &Session) -> Vec { - s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect() } /// Scale Artwork with Artboard (#602): `artboard.setProps {scaleArt}` takes the art fully inside the diff --git a/crates/engine/tests/it/beyond_canvas.rs b/crates/engine/tests/it/beyond_canvas.rs index 9dee868d5..6ac31398f 100644 --- a/crates/engine/tests/it/beyond_canvas.rs +++ b/crates/engine/tests/it/beyond_canvas.rs @@ -51,7 +51,7 @@ fn an_artboard_a_guide_or_a_ruler_origin_beyond_the_canvas_no_longer_blocks_edit v["document"]["ruler_origin"] = json!([1e17, 0.0]); let (s, warnings) = open_and_move(&v, "setup"); let d = &s.doc().unwrap().doc; - assert_eq!((d.artboards.len(), d.guides.len(), d.ruler_origin.x), (1, 1, 0.0)); + assert_eq!((d.artboards.len(), d.ruler_guides().len(), d.ruler_origin.x), (1, 1, 0.0)); assert!(warnings.to_string().contains("1 artboard, 1 guide beyond the canvas"), "{warnings}"); assert!(warnings.to_string().contains("ruler origin"), "{warnings}"); } diff --git a/crates/engine/tests/it/clear_commands.rs b/crates/engine/tests/it/clear_commands.rs index 655c71996..a50c70c18 100644 --- a/crates/engine/tests/it/clear_commands.rs +++ b/crates/engine/tests/it/clear_commands.rs @@ -177,12 +177,12 @@ fn clear_guides_and_empty_explicit_targets_keep_their_separate_behavior() { s.execute("edit.clear", &json!({"ids": []})).unwrap(); assert_eq!(s.active().unwrap().history.undo.len(), history); assert_eq!(s.active().unwrap().selection, selection); - assert_eq!(s.active().unwrap().doc.guides.len(), 1); + assert_eq!(s.active().unwrap().doc.ruler_guides().len(), 1); s.execute("edit.clear", &json!({})).unwrap(); - assert!(s.active().unwrap().doc.guides.is_empty()); + assert!(s.active().unwrap().doc.ruler_guides().is_empty()); assert!(s.active().unwrap().doc.node(a).is_some()); s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc.guides.len(), 1); + assert_eq!(s.active().unwrap().doc.ruler_guides().len(), 1); } #[test] diff --git a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs index 6c84c66b3..c9b5ac466 100644 --- a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs +++ b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs @@ -1,8 +1,8 @@ //! The JavaScript parser's lexer (`vectorcraft-js`) and the script checker's (`vectorcraft-scriptcheck`) //! read the same scripts the same way: both crates stand alone, so each has its own lexer, and these //! tests run both over one set of scripts and fail on any disagreement about the lines a script has, -//! its preprocessor lines, and where a string, comment or regular expression that is never closed -//! starts. The scripts stay inside ECMAScript 3, the language both read (no XML literals, which +//! its preprocessor lines, where a string, comment or regular expression that is never closed +//! starts, and the characters a name may hold. The scripts stay inside ECMAScript 3, the language both read (no XML literals, which //! only the checker reads). use vectorcraft_js::{Options, TokenKind, parse, tokenize}; @@ -88,6 +88,42 @@ fn both_lexers_find_the_same_unclosed_token_on_the_same_line() { } } +/// Characters on both sides of the identifier rule: `Other_ID_Start`, `Other_ID_Continue`, connector +/// punctuation, marks, digits and letters of other scripts, and a superscript digit, a +/// `Pattern_Syntax` letter and punctuation that no name holds. +const NAME_CHARS: &[char] = &[ + '\u{AA}', '\u{B2}', '\u{B7}', '\u{D7}', '\u{300}', '\u{387}', '\u{660}', '\u{1369}', '\u{1885}', '\u{19DA}', '\u{200C}', '\u{200D}', '\u{203F}', + '\u{2040}', '\u{2118}', '\u{212E}', '\u{2160}', '\u{2E2F}', '\u{3005}', '\u{309B}', '\u{30FB}', '\u{FE33}', '\u{FF10}', '\u{FF65}', +]; + +/// Whether the parser reads `src` as one identifier spelling `name`. +fn one_identifier(src: &str, name: &str) -> bool { + let want = TokenKind::Ident(name.to_string()); + tokenize(src, &js_options()).is_ok_and(|tokens| tokens.iter().filter(|t| t.kind != TokenKind::Eof).map(|t| &t.kind).eq([&want])) +} + +/// Whether the checker lists the member `app.` as `app.`. +fn checker_member(src: &str, name: &str) -> bool { + let report = check("script.jsx", format!("app.{src};\n").as_bytes(), None, &Limits::default()); + report.members.iter().any(|m| m.name == format!("app.{name}")) +} + +#[test] +fn both_lexers_take_the_same_characters_into_names() { + assert!(checker_member("a\u{2118}b", "a\u{2118}b") && one_identifier("a\u{2118}b", "a\u{2118}b")); + for &c in NAME_CHARS { + let escape = format!("\\u{:04X}", u32::from(c)); + for (src, name) in [ + (format!("a{c}b"), format!("a{c}b")), + (format!("{c}b"), format!("{c}b")), + (format!("a{escape}b"), format!("a{c}b")), + (format!("{escape}b"), format!("{c}b")), + ] { + assert_eq!(checker_member(&src, &name), one_identifier(&src, &name), "{src:?} (U+{:04X})", u32::from(c)); + } + } +} + #[test] fn the_parser_reads_a_regexp_wherever_the_checker_does() { // Each `/` that divides has no second `/` after it on its line, so a lexer that read one as a diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 7f85e5acd..9227ee199 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -27,6 +27,8 @@ mod param_keys; mod paste_many; mod pen_click_after_smooth; mod pen_endpoint_picking; +mod ruler_guide_nodes; +mod ruler_guide_perf; mod ruler_origin; mod selection_commands; mod stroke_visibility; diff --git a/crates/engine/tests/it/ruler_guide_nodes.rs b/crates/engine/tests/it/ruler_guide_nodes.rs new file mode 100644 index 000000000..95625d65d --- /dev/null +++ b/crates/engine/tests/it/ruler_guide_nodes.rs @@ -0,0 +1,439 @@ +//! Ruler guides held as nodes of a layer: commands that work on a layer's art leave them out and +//! keep them above the art. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_doc::{Node, NodeId, NodeKind}; +use vectorcraft_engine::Session; + +fn id(v: &serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + id(&s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()) +} + +/// A ruler guide added on top of `layer`'s contents. +fn guide(s: &mut Session, layer: NodeId, pos: f64) -> NodeId { + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let g = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos, artboard: None }); + d.insert(Some(layer), usize::MAX, g).unwrap() +} + +fn children(s: &Session, layer: NodeId) -> Vec { + s.doc().unwrap().doc.children(Some(layer)).unwrap().iter().map(|c| c.id).collect() +} + +/// A document with a layer holding two rectangles and two ruler guides: (layer, [art], [guides]). +fn session() -> (Session, NodeId, Vec, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + let art = vec![rect(&mut s, 10.0), rect(&mut s, 50.0)]; + let guides = vec![guide(&mut s, layer, 5.0), guide(&mut s, layer, 7.0)]; + (s, layer, art, guides) +} + +#[test] +fn layer_commands_keep_ruler_guides_above_the_art() { + // Send to Current Layer puts the object below the current layer's guides. + let (mut s, a, art, guides) = session(); + let b = id(&s.execute("layer.new", &json!({})).unwrap()); + let moved = rect(&mut s, 90.0); + s.execute("layer.setCurrent", &json!({"id": a.0})).unwrap(); + s.execute("select.set", &json!({"ids": [moved.0]})).unwrap(); + s.execute("object.arrange.sendToCurrentLayer", &json!({})).unwrap(); + assert_eq!(children(&s, a), [art[0], art[1], moved, guides[0], guides[1]]); + // Already on top of the art: nothing changes and no undo step is added. + let steps = s.doc().unwrap().history.undo.len(); + s.execute("object.arrange.sendToCurrentLayer", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + + // Moving a row on top of a layer's contents puts it below the guides. + let other = rect(&mut s, 130.0); + s.execute("layer.move", &json!({"id": other.0, "target": a.0})).unwrap(); + assert_eq!(children(&s, a), [art[0], art[1], moved, other, guides[0], guides[1]]); + + // Merging layers keeps every guide above the merged art. + let gb = guide(&mut s, b, 9.0); + let late = { + s.execute("layer.setCurrent", &json!({"id": b.0})).unwrap(); + rect(&mut s, 170.0) + }; + s.execute("layer.merge", &json!({"ids": [a.0, b.0]})).unwrap(); + let merged = children(&s, b); + let first_guide = merged.iter().position(|c| [guides[0], guides[1], gb].contains(c)).unwrap(); + assert_eq!(merged.len() - first_guide, 3, "{merged:?}"); + assert!(merged.contains(&late)); +} + +#[test] +fn release_and_clipping_leave_ruler_guides_alone() { + let (mut s, layer, art, guides) = session(); + // Make Clipping Mask clips by the top object, not by a guide above it. + assert_eq!(s.execute("layer.clippingMask.toggle", &json!({"id": layer.0})).unwrap()["clip"], json!(true)); + assert_eq!(children(&s, layer), [art[1], art[0], guides[0], guides[1]]); + s.execute("edit.undo", &json!({})).unwrap(); + + // Release to Layers makes layers of the art only, below the guides. + s.execute("layer.releaseToLayers", &json!({"id": layer.0})).unwrap(); + let ch = children(&s, layer); + assert_eq!(ch.len(), 4); + assert_eq!(ch[2..], [guides[0], guides[1]]); + let d = &s.doc().unwrap().doc; + assert!(ch[..2].iter().all(|c| d.node(*c).is_some_and(Node::is_layer))); +} + +#[test] +fn ruler_guides_are_not_art_to_select_paint_or_inspect() { + let (mut s, layer, art, guides) = session(); + // Target Contents selects the layer's objects only. + let out = s.execute("appearance.targetContents", &json!({"ids": [layer.0]})).unwrap(); + assert_eq!(out["ids"], json!([art[0].0, art[1].0])); + // Painting the layer paints its objects and leaves the guides with no appearance. + s.execute("paint.setFill", &json!({"ids": [layer.0], "color": "#ff0000"})).unwrap(); + let d = &s.doc().unwrap().doc; + for g in &guides { + assert_eq!(d.node(*g).unwrap().appearance, vectorcraft_doc::Appearance::default()); + } + // Document inspection lists the objects, not the guides. + assert_eq!(s.execute("document.find", &json!({"kind": "Guide"})).unwrap()["total"], json!(0)); + let layer_row = s.execute("document.inspect", &json!({"ids": [layer.0]})).unwrap(); + let listed = layer_row.to_string(); + assert!(guides.iter().all(|g| !listed.contains(&format!("\"id\":{}", g.0))), "{listed}"); + // Unlock All does not reach a locked guide. + Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(guides[0]).unwrap().locked = true; + s.execute("object.unlockAll", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(guides[0]).unwrap().locked); +} + +#[test] +fn flattening_a_sublayers_art_keeps_its_ruler_guides() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + let sub = id(&s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()); + s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap(); + let r = rect(&mut s, 10.0); + s.execute("transparency.set", &json!({"ids": [r.0], "opacity": 50})).unwrap(); + let g = guide(&mut s, sub, 5.0); + s.execute("object.flattenTransparency", &json!({"ids": [sub.0]})).unwrap(); + // The sublayer stands for its art: the art is flattened and the guide stays on top of it. + let d = &s.doc().unwrap().doc; + assert!(d.node(r).is_none(), "the art was flattened"); + assert!(d.node(g).is_some_and(Node::is_ruler_guide), "the guide is kept"); + assert_eq!(d.parent_of(g), Some(sub)); + assert_eq!(children(&s, sub).len(), 2); + assert_eq!(children(&s, sub).last(), Some(&g)); + assert_eq!(d.parent_of(sub), Some(layer)); +} + +#[test] +fn object_commands_refuse_a_ruler_guide_id() { + let (mut s, _, art, guides) = session(); + s.execute("select.set", &json!({"ids": [art[0].0]})).unwrap(); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone(), s.doc().unwrap().history.undo.len()); + for (command, params) in [ + ("select.set", json!({"ids": [guides[0].0]})), + ("select.set", json!({"ids": [art[1].0, guides[1].0]})), + ("edit.clear", json!({"ids": [guides[0].0]})), + ("object.lock", json!({"ids": [guides[0].0]})), + ("object.move", json!({"ids": [guides[0].0], "dx": 5, "dy": 0})), + ("paint.setFill", json!({"ids": [guides[1].0], "color": "#ff0000"})), + ] { + let e = s.execute(command, ¶ms).unwrap_err().to_string(); + assert!(e.contains("is a ruler guide, not an object"), "{command}: {e}"); + let d = s.doc().unwrap(); + assert_eq!((&d.doc, &d.selection, d.history.undo.len()), (&before.0, &before.1, before.2), "{command} changed something"); + } + // A selection holding a guide id as an object loses it when pruned; the guide stays. + let d = &s.doc().unwrap().doc; + let mut sel = vectorcraft_doc::Selection { objects: vec![art[0], guides[0]], key: Some(guides[1]), ..Default::default() }; + sel.prune(d); + assert_eq!((sel.objects, sel.key), (vec![art[0]], None)); + assert!(d.node(guides[0]).is_some_and(Node::is_ruler_guide)); +} + +#[test] +fn a_ruler_guide_stays_directly_in_a_layer() { + let (mut s, layer, art, guides) = session(); + s.execute("select.set", &json!({"ids": [art[0].0, art[1].0]})).unwrap(); + let group = id(&s.execute("object.group", &json!({})).unwrap()); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + // Reorder refuses a group as a guide's parent, and the top level. + for parent in [json!(group.0), json!(null)] { + assert!(s.execute("node.move", &json!({"id": guides[0].0, "parent": parent, "index": 0})).is_err(), "{parent}"); + assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&before.0, before.1)); + } + // So do the document's own insert and move. + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + assert!(d.move_node(guides[1], Some(group), 0).is_err()); + let loose = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: false, pos: 3.0, artboard: None }); + assert!(d.insert(Some(group), 0, loose.clone()).is_err()); + assert!(d.insert(None, 0, loose.clone()).is_err()); + assert_eq!(d.ruler_guides().iter().filter_map(|g| g.id).collect::>(), guides); + assert_eq!(children(&s, layer), [group, guides[0], guides[1]]); + // Within its layer it still moves among the guides. + s.execute("node.move", &json!({"id": guides[1].0, "parent": layer.0, "index": 0})).unwrap(); + assert_eq!(children(&s, layer), [group, guides[1], guides[0]]); +} + +/// Arrange counts only a layer's art: the guides above it are no place to move to. +#[test] +fn arranging_counts_only_a_layers_art() { + let (mut s, layer, art, guides) = session(); + let top = rect(&mut s, 90.0); + assert_eq!(children(&s, layer), [art[0], art[1], top, guides[0], guides[1]]); + s.execute("select.set", &json!({"ids": [art[0].0, top.0]})).unwrap(); + s.execute("object.arrange.bringToFront", &json!({})).unwrap(); + assert_eq!(children(&s, layer), [art[1], art[0], top, guides[0], guides[1]], "the selection keeps its order at the top of the art"); + // The topmost art brought to the front or forward: nothing changes and no undo step is added. + s.execute("select.set", &json!({"ids": [top.0]})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + for command in ["object.arrange.bringToFront", "object.arrange.bringForward"] { + s.execute(command, &json!({})).unwrap(); + assert_eq!(children(&s, layer), [art[1], art[0], top, guides[0], guides[1]], "{command}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{command}"); + } + s.execute("select.set", &json!({"ids": [art[1].0]})).unwrap(); + s.execute("object.arrange.bringForward", &json!({})).unwrap(); + assert_eq!(children(&s, layer), [art[0], art[1], top, guides[0], guides[1]]); +} + +/// A guide made while an opacity mask, a pattern or a symbol is edited goes on the temporary +/// editing layer, and stays in the document when editing ends; a save made during mask editing +/// keeps it. +#[test] +fn guides_made_while_editing_a_mask_a_pattern_or_a_symbol_stay() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + rect(&mut s, 10.0); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let edit_layer = s.doc().unwrap().doc.mask_edit.expect("editing the mask").layer; + s.execute("guide.add", &json!({"vertical": true, "pos": 40})).unwrap(); + let made = s.doc().unwrap().doc.ruler_guides(); + assert_eq!(made.iter().map(|g| (g.layer, g.pos)).collect::>(), [(Some(edit_layer), 40.0)]); + let saved = s.doc().unwrap().doc.without_edit_modes().ruler_guides(); + assert_eq!(saved.iter().map(|g| (g.id, g.pos)).collect::>(), [(made[0].id, 40.0)], "a save keeps the guide"); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let kept = d.ruler_guides(); + assert_eq!(kept.iter().map(|g| (g.id, g.pos)).collect::>(), [(made[0].id, 40.0)]); + assert!(kept[0].layer.is_some_and(|l| l != edit_layer && d.node(l).is_some_and(Node::is_layer))); + + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + rect(&mut s, 10.0); + s.execute("object.pattern.make", &json!({})).unwrap(); + let tile_layer = s.doc().unwrap().doc.pattern_edit.as_ref().expect("editing the pattern").layer; + s.execute("guide.add", &json!({"vertical": false, "pos": 25})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].layer, Some(tile_layer)); + s.execute("object.pattern.done", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let kept = d.ruler_guides(); + assert_eq!(kept.iter().map(|g| g.pos).collect::>(), [25.0]); + assert!(kept[0].layer.is_some_and(|l| l != tile_layer && d.node(l).is_some_and(Node::is_layer))); + let tile = &d.patterns.last().expect("the new pattern").art; + assert!(tile.iter().all(|n| !n.is_ruler_guide()), "the guide is not part of the tile"); + + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + rect(&mut s, 10.0); + let instance = id(&s.execute("symbol.new", &json!({"name": "Mark"})).unwrap()); + s.execute("symbol.edit", &json!({"ids": [instance.0]})).unwrap(); + let edit_layer = s.doc().unwrap().doc.symbol_edit.as_ref().expect("editing the symbol").layer; + s.execute("guide.add", &json!({"vertical": true, "pos": 15})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].layer, Some(edit_layer)); + s.execute("symbol.exitEditing", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let kept = d.ruler_guides(); + assert_eq!(kept.iter().map(|g| g.pos).collect::>(), [15.0]); + assert!(kept[0].layer.is_some_and(|l| l != edit_layer && d.node(l).is_some_and(Node::is_layer))); + let art = &d.symbols.iter().find(|x| x.name == "Mark").expect("the symbol").art; + let mut in_symbol = false; + art.walk(&mut |n| in_symbol |= n.is_ruler_guide()); + assert!(!in_symbol, "the guide is not part of the symbol"); +} + +/// The bytes of each export format (PDF without the embedded native document, which keeps the +/// guides), and the box Fit All in Window fits (the art and every artboard). +fn exports(s: &mut Session) -> (Vec<(String, String)>, Option) { + let files = [json!({"format": "svg"}), json!({"format": "pdf", "preserveEditing": false}), json!({"format": "png"}), json!({"format": "psd"})] + .iter() + .map(|p| { + let out = s.execute("document.export", p).unwrap(); + (p["format"].as_str().unwrap().to_string(), out["dataBase64"].as_str().unwrap().to_string()) + }) + .collect(); + let d = &s.doc().unwrap().doc; + let fit = d.art_bounds().map(|a| d.artboards.iter().fold(a, |r, ab| r.union(ab.rect))); + (files, fit) +} + +/// Ruler guides on a layer, on a sublayer and on an artboard change no export and not the box +/// Fit All in Window fits. +#[test] +fn exports_and_fit_all_are_the_same_with_ruler_guides() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 150, "artboards": 2})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + rect(&mut s, 10.0); + let sub = id(&s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()); + s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 60, "y": 30, "width": 40, "height": 30})).unwrap(); + let without = exports(&mut s); + assert_eq!(exports(&mut s), without, "the exports are repeatable"); + // Guides far outside the art too: they must not widen any box. + s.execute("guide.add", &json!({"vertical": true, "pos": -5000, "layer": layer.0})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "pos": 9000, "layer": sub.0})).unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 120, "artboard": 1})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides().len(), 3); + let with = exports(&mut s); + for ((format, a), (_, b)) in without.0.iter().zip(&with.0) { + assert!(a == b, "{format} export changed with ruler guides present"); + } + assert_eq!(with.1, without.1, "Fit All"); + // A PDF that preserves editing carries them in its native document, and opens with them. + let guides = s.doc().unwrap().doc.ruler_guides(); + let pdf = s.execute("document.export", &json!({"format": "pdf"})).unwrap()["dataBase64"].clone(); + s.execute("document.open", &json!({"name": "guides.pdf", "dataBase64": pdf})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides(), guides); +} + +/// One step of [`guide_commands_follow_a_list_model`]. +#[derive(Clone, Debug)] +enum GuideOp { + Add { vertical: bool, pos: u16, layer: usize }, + Remove(usize), + Copy(usize), + Paste, + SetCurrent(usize), + Undo, + Redo, +} + +fn guide_op() -> impl proptest::strategy::Strategy { + use proptest::prelude::*; + prop_oneof![ + 3 => (any::(), 0u16..400, 0usize..3).prop_map(|(vertical, pos, layer)| GuideOp::Add { vertical, pos, layer }), + 2 => (0usize..16).prop_map(GuideOp::Remove), + 1 => (0usize..16).prop_map(GuideOp::Copy), + 1 => Just(GuideOp::Paste), + 1 => (0usize..3).prop_map(GuideOp::SetCurrent), + 2 => Just(GuideOp::Undo), + 1 => Just(GuideOp::Redo), + ] +} + +/// A guide as `guide.list` reports it: (vertical, pos, layer). +type Line = (bool, f64, u64); + +fn listed(s: &mut Session) -> Vec { + let rows = s.execute("guide.list", &json!({})).unwrap(); + rows.as_array() + .unwrap() + .iter() + .enumerate() + .map(|(i, r)| { + assert_eq!(r["index"], json!(i)); + (r["vertical"].as_bool().unwrap(), r["pos"].as_f64().unwrap(), r["layer"].as_u64().unwrap()) + }) + .collect() +} + +/// Runs `ops` on a document with two layers and a sublayer, checking `guide.list` against a plain +/// list after every step: Add appends (a new guide takes the highest id), Remove takes the entry +/// out, Paste appends the copied guide on the current layer at the same place, and Undo and +/// Redo bring back the list as it was, the same indexes included. +fn run_guide_ops(ops: &[GuideOp]) -> Result<(), proptest::test_runner::TestCaseError> { + use proptest::prop_assert_eq; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let bottom = s.doc().unwrap().doc.layers[0].id; + let top = id(&s.execute("layer.new", &json!({})).unwrap()); + let sub = id(&s.execute("layer.newSublayer", &json!({"parent": bottom.0})).unwrap()); + let layers = [bottom, top, sub]; + s.execute("layer.setCurrent", &json!({"id": bottom.0})).unwrap(); + let mut current = bottom; + let mut model: Vec = vec![]; + let mut clip: Option<(bool, f64)> = None; + let (mut undo, mut redo): (Vec>, Vec>) = (vec![], vec![]); + for (step, op) in ops.iter().enumerate() { + let steps = s.doc().unwrap().history.undo.len(); + let before = model.clone(); + // Whether the step is an edit (one undo step). + let edit = match *op { + GuideOp::Add { vertical, pos, layer } => { + let l = layers[layer]; + let r = s.execute("guide.add", &json!({"vertical": vertical, "pos": pos, "layer": l.0})).unwrap(); + model.push((vertical, f64::from(pos), l.0)); + prop_assert_eq!(&r["index"], &json!(model.len() - 1), "step {}", step); + true + } + GuideOp::Remove(i) if !model.is_empty() => { + let i = i % model.len(); + s.execute("guide.remove", &json!({"index": i})).unwrap(); + model.remove(i); + true + } + GuideOp::Copy(i) if !model.is_empty() => { + let i = i % model.len(); + s.execute("guide.select", &json!({"indexes": [i]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + clip = Some((model[i].0, model[i].1)); + false + } + GuideOp::Paste => match clip { + Some((vertical, pos)) => { + s.execute("edit.paste", &json!({})).unwrap(); + model.push((vertical, pos, current.0)); + true + } + None => false, + }, + GuideOp::SetCurrent(l) => { + current = layers[l]; + s.execute("layer.setCurrent", &json!({"id": current.0})).unwrap(); + false + } + GuideOp::Undo => { + if let Some(m) = undo.pop() { + s.execute("edit.undo", &json!({})).unwrap(); + redo.push(std::mem::replace(&mut model, m)); + } + false + } + GuideOp::Redo => { + if let Some(m) = redo.pop() { + s.execute("edit.redo", &json!({})).unwrap(); + undo.push(std::mem::replace(&mut model, m)); + } + false + } + GuideOp::Remove(_) | GuideOp::Copy(_) => false, + }; + let after = s.doc().unwrap().history.undo.len(); + if edit { + prop_assert_eq!(after, steps + 1, "step {} {:?}: one undo step", step, op); + undo.push(before); + redo.clear(); + } else if !matches!(op, GuideOp::Undo | GuideOp::Redo) { + prop_assert_eq!(after, steps, "step {} {:?}: no undo step", step, op); + } + prop_assert_eq!(listed(&mut s), model.clone(), "step {} {:?}", step, op); + } + Ok(()) +} + +proptest::proptest! { + #![proptest_config(proptest::test_runner::Config { cases: 64, failure_persistence: None, ..proptest::test_runner::Config::default() })] + + /// Add, remove, copy, paste, undo and redo keep `guide.list` in step with a plain list, on + /// three layers. + #[test] + fn guide_commands_follow_a_list_model(ops in proptest::collection::vec(guide_op(), 1..40)) { + run_guide_ops(&ops)?; + } +} diff --git a/crates/engine/tests/it/ruler_guide_perf.rs b/crates/engine/tests/it/ruler_guide_perf.rs new file mode 100644 index 000000000..1b5de5c5e --- /dev/null +++ b/crates/engine/tests/it/ruler_guide_perf.rs @@ -0,0 +1,130 @@ +//! Timings of a document with 10,000 ruler guides and 100,000 objects: save, load, open, the guide +//! list, hovering and dragging with the Selection tool, an edit that does not touch guides and one +//! that does. Ignored by default; it prints its timings and sets no bound on them: +//! `cargo test -p vectorcraft-engine --test it ruler_guide_perf -- --ignored --nocapture`. + +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use vectorcraft_color::Color; +use vectorcraft_doc::Document; +use vectorcraft_engine::{Session, ViewInfo}; +use vectorcraft_geom::Rect; +use vectorcraft_testkit::fixtures::DocBuilder; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +const GUIDES: usize = 10_000; +const LAYERS: usize = 4; +const GROUPS_PER_LAYER: usize = 250; +const RECTS_PER_GROUP: usize = 100; + +/// The `.vectorcraft` file of the document: 100,000 rectangles in groups of 100 on four layers, +/// and the ruler guides in its saved list, spread over the layers. +fn file() -> Vec { + let mut b = DocBuilder::new(2000.0, 2000.0); + let mut layers = vec![b.layer]; + for l in 0..LAYERS { + if l > 0 { + layers.push(b.layer(&format!("Layer {}", l + 1))); + } + for g in 0..GROUPS_PER_LAYER { + let kids = (0..RECTS_PER_GROUP) + .map(|r| { + let (x, y) = (((g * RECTS_PER_GROUP + r) % 400) as f64 * 5.0, (l * GROUPS_PER_LAYER + g) as f64 * 2.0); + b.rect_node(Rect::new(x, y, x + 3.0, y + 1.5), Color::rgb(0.2, 0.4, 0.6)) + }) + .collect(); + b.group(kids, false); + } + } + let d: Document = b.build(); + let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(&d, false)).unwrap(); + v["document"]["guides"] = + (0..GUIDES).map(|i| json!({"vertical": i % 2 == 0, "pos": (i / 2) as f64 * 0.37 + 0.1, "layer": layers[i % LAYERS]})).collect(); + serde_json::to_vec(&v).unwrap() +} + +/// The median of `n` runs of `f`. +fn median(n: usize, mut f: impl FnMut()) -> Duration { + let mut t: Vec = (0..n) + .map(|_| { + let start = Instant::now(); + f(); + start.elapsed() + }) + .collect(); + t.sort(); + t[n / 2] +} + +fn report(what: &str, d: Duration) { + eprintln!("ruler_guide_perf: {what:<44} {:>10.3} ms", d.as_secs_f64() * 1000.0); +} + +fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap(); +} + +#[test] +#[ignore = "a benchmark: run with --ignored --nocapture"] +fn ruler_guide_perf() { + let bytes = file(); + eprintln!("ruler_guide_perf: file of {} bytes", bytes.len()); + report("load (format::load), median of 5", median(5, || drop(vectorcraft_format::load(&bytes).unwrap()))); + let doc = vectorcraft_format::load(&bytes).unwrap(); + report("save (format::save), median of 5", median(5, || drop(vectorcraft_format::save(&doc, false)))); + + let mut s = Session::new(); + let data = vectorcraft_format::base64_encode(&bytes); + let start = Instant::now(); + s.execute("document.open", &json!({"name": "perf.vectorcraft", "dataBase64": data})).unwrap(); + report("document.open", start.elapsed()); + let listed = s.execute("guide.list", &json!({})).unwrap().as_array().map_or(0, Vec::len); + assert_eq!(listed, GUIDES); + report("guide.list, median of 11", median(11, || drop(s.execute("guide.list", &json!({})).unwrap()))); + + s.select_tool("selection", ViewInfo::default()).unwrap(); + let mut i = 0.0; + report( + "hover (Selection tool move), median of 201", + median(201, || { + i += 1.0; + pointer(&mut s, PointerKind::Move, 600.0 + i * 0.13, 300.0 + i * 0.07); + }), + ); + // A drag of the first rectangle's group (the rectangle is at 0,0 to 3,1.5), with its + // snapping: one undo step. + let steps = s.doc().unwrap().history.undo.len(); + pointer(&mut s, PointerKind::Down, 1.5, 0.75); + let mut j = 0.0; + report( + "drag (Selection tool drag step), median of 51", + median(51, || { + j += 1.0; + pointer(&mut s, PointerKind::Drag, 1.5 + j * 0.9, 0.75 + j * 0.4); + }), + ); + let start = Instant::now(); + pointer(&mut s, PointerKind::Up, 1.5 + j * 0.9, 0.75 + j * 0.4); + report("drag release (commit)", start.elapsed()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "the drag moved the group"); + + let mut k = 0.0; + report( + "edit without guides (shape.rectangle), median of 21", + median(21, || { + k += 1.0; + s.execute("shape.rectangle", &json!({"x": k, "y": 1900, "width": 5, "height": 5})).unwrap(); + }), + ); + report("undo, median of 11", median(11, || drop(s.execute("edit.undo", &json!({})).unwrap()))); + let mut m = 0.0; + report( + "guide edit (guide.move), median of 21", + median(21, || { + m += 1.0; + s.execute("guide.move", &json!({"index": GUIDES / 2, "pos": 100.0 + m})).unwrap(); + }), + ); + report("file.saveCopy, median of 3", median(3, || drop(s.execute("file.saveCopy", &json!({})).unwrap()))); +} diff --git a/crates/engine/tests/it/ruler_origin.rs b/crates/engine/tests/it/ruler_origin.rs index 4b5c06286..f6a910515 100644 --- a/crates/engine/tests/it/ruler_origin.rs +++ b/crates/engine/tests/it/ruler_origin.rs @@ -78,7 +78,7 @@ fn native_versions_and_compression_keep_the_origin_and_old_files_default_to_zero assert_eq!(back.ruler_origin_for(0), doc.ruler_origin_for(0), "v{version}, compression {compress}"); assert_eq!((back.rulers, &back.artboard_ruler_origins), (doc.rulers, &doc.artboard_ruler_origins), "v{version}, compression {compress}"); assert_eq!(back.layers, doc.layers); - assert_eq!(back.guides, doc.guides); + assert_eq!(back.ruler_guides(), doc.ruler_guides()); } } diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index c598e2cd9..d77d8264e 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -460,6 +460,9 @@ impl<'a> Writer<'a> { let flag = |b: bool| u8::from(b); let dim = dim_images.unwrap_or(50); let name = n.name.as_deref().unwrap_or("Layer"); + // Ruler guides are kept in the document's native copy only. + let children: Vec> = children.iter().filter(|c| !c.is_ruler_guide()).cloned().collect(); + let children = &children; // The last flag makes the layer a clipping layer: its top object clips the rest. Only a // clipping path or compound path, outside any art cut to a clipping object, is written as // one ([`Self::clip_group`] writes the others). @@ -583,6 +586,10 @@ impl<'a> Writer<'a> { if let NodeKind::Layer { .. } = n.kind { return self.write_layer(n, &[]); } + // Ruler guides are kept in the document's native copy only: no object, state or style. + if n.is_ruler_guide() { + return; + } if self.in_symbol { self.wrote_fixed |= symbols::redrawn(n); } @@ -697,6 +704,8 @@ impl<'a> Writer<'a> { } self.end_group("U", xy); } + // Ruler guides are kept in the document's native copy only. + NodeKind::RulerGuide { .. } => {} } if self.body.len() > start { // A group's dictionary goes between its end and its state block. diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index 65fe91859..c4ae9fda3 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -28,6 +28,10 @@ pub(in crate::ai_editing) const STYLE_LINKS: &str = "links to graphic styles are /// How many objects of `doc`'s layers, opacity masks and symbols are linked to a graphic style. pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { fn count(n: &Node) -> usize { + // Ruler guides are not written as objects. + if n.is_ruler_guide() { + return 0; + } let own = usize::from(n.extras().graphic_style.is_some()); let mask = n.extras().mask.as_ref().map_or(0, |m| count(&m.art)); own + mask + n.children().map_or(0, |c| c.iter().map(|m| count(m)).sum()) diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index eb3d73c69..ad7f26603 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1244,9 +1244,9 @@ impl<'a> Reader<'a> { None => None, }; let pos = if vertical { p.x } else { p.y }; - pos.is_finite().then_some(Guide { vertical, pos, artboard, layer: None }) + pos.is_finite().then_some(Guide { artboard, ..Guide::new(vertical, pos) }) }; - take_ruler_guides(&mut doc.layers, &pick, &mut doc.guides, 0); + take_ruler_guides(&mut doc.layers, &pick, 0); } let mut brushes = self.brush_library(); if let Some((lib, _)) = &brushes { @@ -1429,9 +1429,9 @@ fn ruler_guide(a: Point, b: Point, centre: Option<(f64, f64)>, boards: &[Rect]) } } -/// Moves the children of the layers and sublayers of `nodes` that `pick` makes a guide of into -/// `guides`, each on its layer. -fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option, guides: &mut Vec, depth: usize) { +/// Makes the children of the layers and sublayers of `nodes` that `pick` makes a guide of into +/// ruler guide nodes with the same ids, moved to the top of their layer in their order. +fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option, depth: usize) { if depth > MAX_DEPTH { return; } @@ -1439,16 +1439,17 @@ fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option { - guides.push(Guide { layer: Some(id), ..g }); + guides.push(Arc::new(Node::new(k.id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }))); false } None => true, }); - take_ruler_guides(kids, pick, guides, depth + 1); + take_ruler_guides(kids, pick, depth + 1); + kids.extend(guides); } } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index c04d06c6a..46386d220 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -62,9 +62,14 @@ fn arrays_nested_past_the_depth_limit_are_refused() { assert!(nested(100_000)); } -/// The children of the first layer. +/// The children of the first layer, its ruler guides left out. fn art(doc: &Document) -> Vec> { - doc.layers.first().and_then(|l| l.children()).cloned().unwrap_or_default() + doc.layers.first().and_then(|l| l.children()).into_iter().flatten().filter(|c| !c.is_ruler_guide()).cloned().collect() +} + +/// The ruler guides of `doc` by their line, artboard and layer. +fn guide_lines(doc: &Document) -> Vec { + doc.ruler_guides().into_iter().map(|g| Guide { artboard: g.artboard, layer: g.layer, ..Guide::new(g.vertical, g.pos) }).collect() } fn fill(n: &Node) -> Paint { @@ -8459,17 +8464,16 @@ fn canvas_wide_guide_paths_are_ruler_guides_of_their_layer() { ); let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc; let top = &doc.layers[0]; - let kids = top.children().unwrap(); + let kids = art(&doc); assert_eq!(kids.len(), 2, "the square and the sublayer"); assert!(matches!(kids[0].kind, NodeKind::Path { guide: false, .. })); let sub = &kids[1]; - assert!(sub.children().unwrap().is_empty()); - let want = [ - Guide { vertical: false, pos: 60.0, artboard: None, layer: Some(top.id) }, - Guide { vertical: true, pos: 25.0, artboard: None, layer: Some(top.id) }, - Guide { vertical: false, pos: 10.0, artboard: None, layer: Some(sub.id) }, - ]; - assert_eq!(doc.guides, want); + assert!(sub.children().unwrap().iter().all(|c| c.is_ruler_guide())); + let at = |vertical, pos, layer| Guide { layer: Some(layer), ..Guide::new(vertical, pos) }; + let want = [at(false, 60.0, top.id), at(true, 25.0, top.id), at(false, 10.0, sub.id)]; + assert_eq!(guide_lines(&doc), want); + // The guides sit above the layers' art. + assert!(top.children().unwrap()[2..].iter().all(|c| c.is_ruler_guide())); } /// #779: a straight `(N) *` guide path from edge to edge of one artboard is a ruler guide of that @@ -8478,7 +8482,7 @@ fn canvas_wide_guide_paths_are_ruler_guides_of_their_layer() { fn artboard_wide_guide_paths_are_ruler_guides_of_their_artboard() { // The crop marks' artboard, 0 0 200 100. let doc = read(canvas_stream(&layer(&guide_line(0.0, 30.0, 200.0, 30.0, "N"))).as_bytes()).unwrap().doc; - assert_eq!(doc.guides, [Guide { vertical: false, pos: 70.0, artboard: Some(1), layer: Some(doc.layers[0].id) }]); + assert_eq!(guide_lines(&doc), [Guide { artboard: Some(1), layer: Some(doc.layers[0].id), ..Guide::new(false, 70.0) }]); assert!(art(&doc).is_empty()); // The second of two artboards: art space x 400 to 600, y -150 to 0. let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ @@ -8487,7 +8491,7 @@ fn artboard_wide_guide_paths_are_ruler_guides_of_their_artboard() { %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; let doc = read_ok(&format!("{boards}{}", layer(&guide_line(500.0, 0.0, 500.0, -150.0, "N")))).doc; assert_eq!(doc.artboards[1].id, 2); - assert_eq!(doc.guides, [Guide { vertical: true, pos: 500.0, artboard: Some(2), layer: Some(doc.layers[0].id) }]); + assert_eq!(guide_lines(&doc), [Guide { artboard: Some(2), layer: Some(doc.layers[0].id), ..Guide::new(true, 500.0) }]); } /// Guide paths that aren't ruler guides stay guide paths: `(S) *` ones (guides made from objects), @@ -8506,7 +8510,7 @@ fn other_guide_paths_stay_guide_paths() { ]; for body in cases { let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc; - assert!(doc.guides.is_empty(), "{body}"); + assert!(doc.ruler_guides().is_empty(), "{body}"); assert_eq!(art(&doc).len(), 1, "{body}"); } } diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 31cd90d79..c86cdb951 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -1669,7 +1669,7 @@ fn page_boards(doc: &Document, visible: &Document, first: usize, pages: &[Rect]) /// Drops the guides of artboards `doc` no longer has: an artboard guide goes with its artboard. fn drop_guides_off_boards(doc: &mut Document) { let kept: Vec = doc.artboards.iter().map(|a| a.id).collect(); - doc.guides.retain(|g| g.artboard.is_none_or(|id| kept.contains(&id))); + doc.retain_guides(&mut vectorcraft_doc::Selection::default(), |g| g.artboard.is_none_or(|id| kept.contains(&id))); } /// Draw `doc`'s layers, artboards and guides `k` times as large, from the origin, with their @@ -1682,13 +1682,12 @@ pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) { let layer = Arc::make_mut(layer); layer.transform(a, scaling); scale_wrap_offsets(layer, k); + // A transform leaves ruler guides where they are: their positions scale here. + layer.map_ruler_guide_positions(&mut |_, pos| *pos *= k); } for board in &mut doc.artboards { board.rect = a.transform_rect_bbox(board.rect); } - for guide in &mut doc.guides { - guide.pos *= k; - } if styles { for g in doc.graphic_styles.iter_mut() { g.appearance.scale_strokes(k); diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs index 5b87a03c7..34f458f32 100644 --- a/crates/eps/src/import/native/compare.rs +++ b/crates/eps/src/import/native/compare.rs @@ -122,7 +122,7 @@ pub(super) fn comparison_samples(doc: &Document) -> u32 { let (mut images, mut vector) = (false, false); doc.walk(|n| match n.kind { NodeKind::Image(_) => images = true, - NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } => {} + NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } | NodeKind::RulerGuide { .. } => {} _ => vector = true, }); if images && !vector { 4 } else { 1 } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index f9983c6e8..45a7a92c8 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -77,6 +77,14 @@ fn names(d: &Document) -> Vec { d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect() } +/// The ruler guides of `d` by their line, artboard and layer. +fn guide_lines(d: &Document) -> Vec { + d.ruler_guides() + .into_iter() + .map(|g| vectorcraft_doc::Guide { artboard: g.artboard, layer: g.layer, ..vectorcraft_doc::Guide::new(g.vertical, g.pos) }) + .collect() +} + #[test] fn ruler_units_in_the_program_header_alone_are_kept() { let art = layer("Art", true, &square(10, 10)); @@ -2219,11 +2227,11 @@ fn an_ai_files_ruler_guides_come_in_on_their_layers() { assert!(read, "{w:?}"); let jig = d.layers[0].id; let want = [ - vectorcraft_doc::Guide { vertical: false, pos: 60.0 * k, artboard: None, layer: Some(jig) }, - vectorcraft_doc::Guide { vertical: true, pos: 20.0 * k, artboard: Some(d.artboards[0].id), layer: Some(jig) }, + vectorcraft_doc::Guide { layer: Some(jig), ..vectorcraft_doc::Guide::new(false, 60.0 * k) }, + vectorcraft_doc::Guide { artboard: Some(d.artboards[0].id), layer: Some(jig), ..vectorcraft_doc::Guide::new(true, 20.0 * k) }, ]; - assert_eq!(d.guides, want); - assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 1, "the square alone"); + assert_eq!(guide_lines(&d), want); + assert_eq!(d.layers[0].children().map_or(0, |c| c.iter().filter(|c| !c.is_ruler_guide()).count()), 1, "the square alone"); } } @@ -2241,7 +2249,10 @@ fn an_eps_of_its_art_box_keeps_the_guides_of_its_first_artboard() { assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); assert_eq!(r.document.artboards.len(), 1); let (board, layer) = (r.document.artboards[0].id, r.document.layers[0].id); - assert_eq!(r.document.guides, [vectorcraft_doc::Guide { vertical: true, pos: 25.0, artboard: Some(board), layer: Some(layer) }]); + assert_eq!( + guide_lines(&r.document), + [vectorcraft_doc::Guide { artboard: Some(board), layer: Some(layer), ..vectorcraft_doc::Guide::new(true, 25.0) }] + ); } /// A PDF part read from a `UserUnit` page cropped to the art: the art's box, not the artboard, is diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index c95940b95..26a46401f 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -152,7 +152,9 @@ impl<'a> Writer<'a> { self.locked = false; self.line("0 A"); self.in_hidden = !n.visible; - self.contents(children, *clip, false); + // Ruler guides are kept in the document's native copy only. + let art: Vec> = children.iter().filter(|c| !c.is_ruler_guide()).cloned().collect(); + self.contents(&art, *clip, false); self.in_hidden = false; self.line("LB\n%AI5_EndLayer--"); true @@ -209,6 +211,10 @@ impl<'a> Writer<'a> { /// One object (inside a container locked when `locked`). fn node(&mut self, n: &Node, locked: bool) { + // Ruler guides are kept in the document's native copy only. + if n.is_ruler_guide() { + return; + } if !n.visible && !self.in_hidden { match self.hidden { HiddenObjects::Omit => { @@ -292,6 +298,8 @@ impl<'a> Writer<'a> { self.nest -= 1; } } + // Ruler guides are kept in the document's native copy only. + NodeKind::RulerGuide { .. } => {} } } diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 974831b70..8c9b04ff8 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -389,6 +389,8 @@ impl<'a> Scene<'a> { self.node(c, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } if self.body.len() > MAX_BODY { self.body.truncate(was); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 667b39aad..38545b829 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -239,6 +239,9 @@ struct Body { fn body(doc: &Document, o: &SaveOptions) -> Result { let version = o.version; let mut d = doc.without_edit_modes().into_owned(); + // Ruler guides are saved in the document's `guides` list, as before they were nodes, so every + // version reads them. + d.guides_to_saved(); // Older apps don't know placed documents: they get the art the objects show, with its // resources. Otherwise resources only exporting adds are never saved. if version < VERSION && d.has_placed() { @@ -602,6 +605,12 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result ))); } depth_notes.extend(beyond.note()); + // Untrusted: ruler guide nodes only directly in layers, above their art (after the depth limit: + // the walk recurses). + doc.tidy_ruler_guides(); + // The saved ruler guides become nodes on their layers (after the guides beyond the canvas are + // dropped, and after `fix_next_id`: new ids start above the file's). + doc.guides_into_nodes(); // Untrusted: the type's sizes, leading, baseline shifts and scales, its indents and paragraph // spacing, and its tab stops, each within its range (after the depth limit: the walk recurses). doc.keep_type_values_in_range(); @@ -1203,6 +1212,66 @@ mod tests { assert_eq!(load(&bytes).unwrap().node_count(), n, "the real budget opens ordinary documents"); } + /// The saved ruler guides become objects when read, so each counts against the same budget. + #[test] + fn saved_ruler_guides_count_against_the_object_budget() { + let mut d = Document::new(100.0, 100.0); + let l = d.add_layer(None); + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + for i in 0..50 { + d.add_ruler_guide(&vectorcraft_doc::Guide::new(i % 2 == 0, f64::from(i))); + } + let n = d.node_count() + d.ruler_guides().len(); + let bytes = save_with(&d, &SaveOptions { compress: true, ..SaveOptions::default() }).unwrap(); + let opened = load_file_within(&bytes, n, vectorcraft_doc::node::MAX_BUFFERED_VALUES).unwrap().doc; + assert_eq!(opened.ruler_guides().len(), 50, "a document of exactly the budget opens with its guides"); + let Err(FormatError::NotVectorcraft(why)) = load_file_within(&bytes, n - 1, vectorcraft_doc::node::MAX_BUFFERED_VALUES) else { + panic!("one guide past the budget must be refused") + }; + assert!(why.contains(&format!("more than {} objects", n - 1)), "{why}"); + } + + /// Ruler guide nodes a file holds out of place are put right as it is read: one below a layer's + /// art moves up to the layer's guides, and one in a group, beside the layers or in a symbol is + /// removed. Saving again writes no ruler guide node, which builds from before ruler guides + /// were nodes could not read. + #[test] + fn ruler_guide_nodes_out_of_place_in_a_file_are_put_right() { + let mut d = Document::new(100.0, 100.0); + let l = d.add_layer(None); + let (a, b, group) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let path = |id| Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + d.insert(Some(l), 0, path(a)).unwrap(); + d.insert(Some(l), usize::MAX, Node::group(group, vec![Arc::new(path(b))])).unwrap(); + let li = d.layers.iter().position(|x| x.id == l).unwrap(); + let mut v: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + let guide = |id: u64| serde_json::json!({"id": id, "kind": {"type": "rulerguide", "vertical": true, "pos": 5.0}}); + let kids = &mut v["document"]["layers"][li]["kind"]["children"]; + kids.as_array_mut().unwrap().insert(0, guide(50)); + kids[2]["kind"]["children"].as_array_mut().unwrap().push(guide(51)); + v["document"]["layers"].as_array_mut().unwrap().push(guide(52)); + let opened = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + let listed: Vec = opened.ruler_guides().iter().filter_map(|g| g.id).map(|id| id.0).collect(); + assert_eq!(listed, [50]); + let layer = opened.layers.iter().find(|x| x.id == l).unwrap(); + assert_eq!(layer.children().unwrap().iter().map(|c| c.id.0).collect::>(), [a.0, group.0, 50], "art first, then the guide"); + assert_eq!(opened.node(group).unwrap().children().unwrap().len(), 1, "the group keeps only its path"); + assert!(opened.layers.iter().all(|x| !x.is_ruler_guide())); + assert!(!String::from_utf8(save(&opened, false)).unwrap().contains("rulerguide")); + + // A ruler guide in a symbol's art is removed too. + let mut d = Document::new(100.0, 100.0); + let sym = d.alloc_id(); + let art = Node::group(sym, vec![Arc::new(path(d.alloc_id()))]); + d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art), dynamic: false }); + let mut v: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + v["document"]["symbols"][0]["art"]["kind"]["children"].as_array_mut().unwrap().push(guide(60)); + let opened = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + assert_eq!(opened.symbols[0].art.children().unwrap().len(), 1); + assert!(!opened.symbols[0].art.children().unwrap().iter().any(|c| c.is_ruler_guide())); + } + /// #3587: a reordered file (its objects' fields before their `type`, as a JSON tool that /// sorts keys writes them) still opens; one whose fields before a `type` hold more values than /// the budget, such as a long array of numbers, is refused as they are read. diff --git a/crates/format/tests/it/main.rs b/crates/format/tests/it/main.rs index a5f028c38..2272f42f7 100644 --- a/crates/format/tests/it/main.rs +++ b/crates/format/tests/it/main.rs @@ -3,6 +3,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod links; +mod native_guides; mod native_v3; mod native_v4; mod save_options; diff --git a/crates/format/tests/it/native_guides.rs b/crates/format/tests/it/native_guides.rs new file mode 100644 index 000000000..614ceded6 --- /dev/null +++ b/crates/format/tests/it/native_guides.rs @@ -0,0 +1,146 @@ +//! Ruler guides in `.vectorcraft` files of every version: the saved `guides` list becomes ruler +//! guide nodes on their layers when a file opens, and is written back as the same list. + +use serde::Deserialize; +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, LayerColor, Node, NodeId}; +use vectorcraft_format::{MIN_VERSION, SaveOptions, VERSION, load, save_with}; +use vectorcraft_testkit::fixtures; + +fn json_of(bytes: &[u8]) -> Value { + serde_json::from_slice(bytes).unwrap() +} + +fn save_v(d: &Document, version: u32) -> Vec { + save_with(d, &SaveOptions { version, ..SaveOptions::default() }).unwrap() +} + +/// The document of a saved file without its guide list and its next id (giving guides ids +/// raises the next id). +fn without_guides(v: &Value) -> Value { + let mut d = v["document"].clone(); + let o = d.as_object_mut().unwrap(); + o.remove("guides"); + o.remove("next_id"); + d +} + +/// A document with art on two layers, a sublayer in the bottom one, and two artboards: +/// (document, bottom layer, sublayer, top layer, second artboard's id). +fn doc() -> (Document, NodeId, NodeId, NodeId, u32) { + let mut d = (*fixtures::rich_session().doc().unwrap().doc).clone(); + let bottom = d.layers[0].id; + let sub = d.alloc_id(); + d.insert(Some(bottom), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let top = d.add_layer(Some("Top")); + let mut ab = d.artboards[0].clone(); + ab.id = d.next_artboard_id(); + d.artboards.push(ab); + let board = d.artboards[1].id; + (d, bottom, sub, top, board) +} + +/// Each version's file with a guide on no layer, one on a sublayer, one on a layer that is gone, +/// and an artboard guide, as files written before ruler guides were nodes hold them: it opens +/// with the guides on their layers, saves the same lines with their layers and ids, and saving +/// what that file opens to gives the same `guides` list again. The rest of the document is saved +/// as it was. +#[test] +fn every_version_reads_and_writes_back_the_same_guides() { + let (d, bottom, sub, top, board) = doc(); + for version in MIN_VERSION..=VERSION { + let mut v = json_of(&save_v(&d, version)); + assert_eq!(v["version"], version); + let plain = without_guides(&v); + v["document"]["guides"] = json!([ + {"vertical": true, "pos": 12.5}, + {"vertical": false, "pos": 40.0, "layer": sub}, + {"vertical": true, "pos": 70.0, "layer": 99_999}, + {"vertical": false, "pos": 90.0, "layer": bottom, "artboard": board}, + ]); + let opened = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + let guides = opened.ruler_guides(); + let lines: Vec<_> = guides.iter().map(|g| (g.vertical, g.pos, g.artboard, g.layer)).collect(); + // On no layer and on a deleted layer: the top visible, unlocked layer. + assert_eq!( + lines, + [(true, 12.5, None, Some(top)), (false, 40.0, None, Some(sub)), (true, 70.0, None, Some(top)), (false, 90.0, Some(board), Some(bottom))], + "version {version}" + ); + for g in &guides { + let n = opened.node(g.id.unwrap()).unwrap(); + assert!(n.is_ruler_guide() && n.visible && !n.locked && n.name.is_none(), "version {version}"); + // Each sits at the top of its layer, above the art. + let kids = opened.node(g.layer.unwrap()).unwrap().children().unwrap(); + let first_guide = kids.iter().position(|c| c.is_ruler_guide()).unwrap(); + assert!(kids[first_guide..].iter().all(|c| c.is_ruler_guide()), "version {version}"); + } + + let once = json_of(&save_v(&opened, version)); + assert_eq!(once["version"], version); + let saved: Vec<_> = once["document"]["guides"] + .as_array() + .unwrap() + .iter() + .map(|g| (g["vertical"].clone(), g["pos"].clone(), g.get("artboard").cloned(), g["layer"].clone(), g["id"].is_u64())) + .collect(); + assert_eq!( + saved, + [ + (json!(true), json!(12.5), None, json!(top), true), + (json!(false), json!(40.0), None, json!(sub), true), + (json!(true), json!(70.0), None, json!(top), true), + (json!(false), json!(90.0), Some(json!(board)), json!(bottom), true), + ], + "version {version}" + ); + assert_eq!(without_guides(&once), plain, "version {version}: the rest of the document"); + + let reopened = load(&serde_json::to_vec(&once).unwrap()).unwrap(); + assert_eq!(reopened.ruler_guides(), guides, "version {version}: same ids, layers and lines"); + let twice = json_of(&save_v(&reopened, version)); + assert_eq!(twice["document"]["guides"], once["document"]["guides"], "version {version}"); + } +} + +/// The guide entry of a build from before ruler guides were nodes, field for field. +#[derive(Debug, PartialEq, Deserialize)] +struct OldGuide { + vertical: bool, + pos: f64, + #[serde(default)] + artboard: Option, + #[serde(default)] + layer: Option, +} + +/// An older build reads the guides a file now saves, the new fields (id, name, lock, visibility) +/// included, as the same lines on the same layers. +#[test] +fn an_older_guide_entry_reads_the_saved_guides() { + let (mut d, bottom, sub, _, board) = doc(); + let mut g = vectorcraft_doc::Guide::new(true, 15.0); + g.layer = Some(sub); + g.name = Some("Fold".into()); + g.locked = true; + g.hidden = true; + d.add_ruler_guide(&g); + d.add_ruler_guide(&vectorcraft_doc::Guide { layer: Some(bottom), artboard: Some(board), ..vectorcraft_doc::Guide::new(false, 25.0) }); + for version in MIN_VERSION..=VERSION { + let v = json_of(&save_v(&d, version)); + let entries = v["document"]["guides"].as_array().unwrap(); + assert_eq!(entries[0]["name"], "Fold", "the new fields are written"); + let old: Vec = serde_json::from_value(v["document"]["guides"].clone()).unwrap(); + assert_eq!( + old, + [ + OldGuide { vertical: true, pos: 15.0, artboard: None, layer: Some(sub) }, + OldGuide { vertical: false, pos: 25.0, artboard: Some(board), layer: Some(bottom) }, + ], + "version {version}" + ); + // And this build reads its own lock, visibility and name back. + let back = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + assert_eq!(back.ruler_guides(), d.ruler_guides(), "version {version}"); + } +} diff --git a/crates/format/tests/it/native_v3.rs b/crates/format/tests/it/native_v3.rs index 6a96fae79..512025375 100644 --- a/crates/format/tests/it/native_v3.rs +++ b/crates/format/tests/it/native_v3.rs @@ -293,13 +293,21 @@ fn artboard_guides_round_trip_and_canvas_guides_save_as_before() { use vectorcraft_doc::Guide; let mut d = Document::new(100.0, 100.0); let id = d.artboards[0].id; - d.guides = vec![Guide::new(true, 10.0), Guide { artboard: Some(id), ..Guide::new(false, 20.0) }]; + d.add_ruler_guide(&Guide::new(true, 10.0)); + d.add_ruler_guide(&Guide { artboard: Some(id), ..Guide::new(false, 20.0) }); let mut v = json_of(&save(&d, false)); - assert_eq!(v["document"]["guides"], json!([{"vertical": true, "pos": 10.0}, {"vertical": false, "pos": 20.0, "artboard": id}])); - assert_eq!(load(&save(&d, false)).unwrap().guides, d.guides); + let lines: Vec<_> = v["document"]["guides"] + .as_array() + .unwrap() + .iter() + .map(|g| json!({"vertical": g["vertical"], "pos": g["pos"], "artboard": g.get("artboard")})) + .collect(); + assert_eq!(lines, [json!({"vertical": true, "pos": 10.0, "artboard": null}), json!({"vertical": false, "pos": 20.0, "artboard": id})]); + assert_eq!(load(&save(&d, false)).unwrap().ruler_guides(), d.ruler_guides()); v["document"]["guides"] = json!([{"vertical": false, "pos": 20.0}]); let old = load(&serde_json::to_vec(&v).unwrap()).unwrap(); - assert_eq!(old.guides, [Guide::new(false, 20.0)]); + let old_lines: Vec<_> = old.ruler_guides().into_iter().map(|g| (g.vertical, g.pos, g.artboard)).collect(); + assert_eq!(old_lines, [(false, 20.0, None)]); } #[test] diff --git a/crates/js/src/file_classes.rs b/crates/js/src/file_classes.rs new file mode 100644 index 000000000..cc22f1834 --- /dev/null +++ b/crates/js/src/file_classes.rs @@ -0,0 +1,864 @@ +//! `File` and `Folder`, the classes the help pages +//! and +//! describe, as built-ins +//! whose file system work a host does ([`crate::Realm::define_file_classes`]). +//! +//! The constructors build their objects with and without `new`: `new File(path)` is always a +//! `File` and `new Folder(path)` always a `Folder`, while `File(path)` gives a `Folder` when the +//! host's `stat` says the path is a folder, and `Folder(path)` a `File` when it is a file. Two +//! objects for one path are different objects. An object keeps its path in the host's platform +//! form, its `error` text, the id of the file it has open (or -1), the encoding set before `open` +//! and its line feed, in properties that are not enumerable; every name the getters give comes from +//! the host's `parse` of that path. `Folder.current` is kept in the realm and starts at the host's +//! `current`. +//! +//! A method whose host call fails sets `error` and returns `false`, `null` or an empty string. A +//! count or position that is not a finite number, a negative count and a seek mode other than 0, 1 +//! or 2 throw a `RangeError`; a method called on an object that is not a `File` or `Folder` throws +//! a `TypeError`. `getFiles` with a function calls it with each entry and keeps those it returns a +//! true value for; every entry takes fuel. + +use crate::HostItem; +use crate::builtins::{arg, constructor, data, method, native, put_at}; +use crate::heap::{CONFIGURABLE, Class, HIDDEN, NativeFn, NativeKind, OBJECT_BYTES, ObjId, Object, PROP_BYTES, Prop, key, string_bytes}; +use crate::interp::{Abrupt, ErrorType, Interp, JsResult}; +use crate::value::Value; + +/// The objects of `File` and `Folder`, kept by the realm, and `Folder.current`. +#[derive(Clone, Debug)] +pub(crate) struct Classes { + file_proto: ObjId, + folder_proto: ObjId, + host: ObjId, + current: Value, +} + +impl Classes { + /// The objects a collection keeps. + pub(crate) fn objects(&self) -> [ObjId; 3] { + [self.file_proto, self.folder_proto, self.host] + } +} + +const PATH: &str = "__vcPath"; +const ERROR: &str = "__vcError"; +const HANDLE: &str = "__vcHandle"; +const ENCODING: &str = "__vcEncoding"; +const LINE_FEED: &str = "__vcLineFeed"; + +/// The locations `Folder` has as properties, each read from the host's `locations` when it is read. +const PLACES: [&str; 10] = ["appData", "appPackage", "commonFiles", "desktop", "myDocuments", "startup", "system", "temp", "trash", "userData"]; + +fn classes(i: &Interp) -> JsResult { + i.files.clone().ok_or_else(|| Abrupt::Internal("File and Folder are not installed".into())) +} + +/// Calls the host function `name` with `args`. +fn host(i: &mut Interp, name: &str, args: &[Value]) -> JsResult { + let c = classes(i)?; + let f = i.get_value(&Value::Object(c.host), &key(name))?; + i.call(&f, &Value::Undefined, args) +} + +/// The host's message `name`. +fn message(i: &mut Interp, name: &str) -> JsResult { + let c = classes(i)?; + let text = i.get_value(&Value::Object(c.host), &key("text"))?; + i.get_value(&text, &key(name)) +} + +fn field(i: &mut Interp, v: &Value, name: &str) -> JsResult { + i.get_value(v, &key(name)) +} + +/// The value of the own data property `name` of `o`, `undefined` when it has none. +fn slot(i: &Interp, o: ObjId, name: &str) -> JsResult { + Ok(match i.get_own_property(o, &key(name))? { + Some(Prop::Data { value, .. }) => value, + _ => Value::Undefined, + }) +} + +fn set_slot(i: &mut Interp, o: ObjId, name: &str, value: Value) -> JsResult<()> { + if let Value::String(s) = &value { + i.rooted(std::slice::from_ref(&value), |i| i.reserve_heap(PROP_BYTES + string_bytes(s.len())))?; + } + i.define(o, key(name), Prop::Data { value, flags: HIDDEN }) +} + +/// The object `this` and its path, or a `TypeError` naming `what` when it is not a `File` or +/// `Folder`. +fn this_path(i: &mut Interp, this: &Value, what: &str) -> JsResult<(ObjId, Value)> { + if let Some(o) = this.as_object() + && let path @ Value::String(_) = slot(i, o, PATH)? + { + return Ok((o, path)); + } + Err(i.type_error(&format!("{what}: the object is not a File or a Folder"))) +} + +/// The path `x` names, and whether it is already in the platform's form: the path of a `File` or +/// `Folder`, or `String(x)`, which may hold URI escapes. +fn path_text(i: &mut Interp, x: &Value) -> JsResult<(Value, bool)> { + if let Some(o) = x.as_object() + && let path @ Value::String(_) = slot(i, o, PATH)? + { + return Ok((path, true)); + } + let s = i.to_str(x)?; + Ok((Value::String(s), false)) +} + +/// The host's `parse` of the path `x` names, from `Folder.current`. +fn parse(i: &mut Interp, x: &Value) -> JsResult { + let (text, platform) = path_text(i, x)?; + parse_text(i, text, platform) +} + +/// The host's `parse` of `text`, from `Folder.current`; its URI escapes are decoded unless it is +/// already a `platform` path. +fn parse_text(i: &mut Interp, text: Value, platform: bool) -> JsResult { + let current = classes(i)?.current; + host(i, "parse", &[text, current, Value::Bool(platform)]) +} + +fn parsed(i: &mut Interp, this: &Value, what: &str, name: &str) -> JsResult { + let (_, path) = this_path(i, this, what)?; + let p = parse_text(i, path, true)?; + i.rooted(std::slice::from_ref(&p), |i| field(i, &p, name)) +} + +/// A new object with prototype `proto` for the path `fs`, already in the platform's form. +fn instance(i: &mut Interp, proto: ObjId, fs: Value) -> JsResult { + let line_feed = host_value(i, "lineFeed")?; + i.rooted(&[fs.clone(), line_feed.clone()], |i| { + i.reserve_heap(OBJECT_BYTES + 5 * PROP_BYTES)?; + let o = i.heap.alloc_object(Object::new(Class::Ordinary, Some(proto))); + i.rooted(&[Value::Object(o)], |i| { + set_slot(i, o, PATH, fs.clone())?; + set_slot(i, o, ERROR, Value::str(""))?; + set_slot(i, o, HANDLE, Value::Number(-1.0))?; + set_slot(i, o, ENCODING, Value::str(""))?; + set_slot(i, o, LINE_FEED, line_feed.clone())?; + Ok(Value::Object(o)) + }) + }) +} + +fn host_value(i: &mut Interp, name: &str) -> JsResult { + let c = classes(i)?; + i.get_value(&Value::Object(c.host), &key(name)) +} + +/// The host's `stat` of the object's path, or `None` after setting `error` when it failed. +fn stat(i: &mut Interp, this: &Value, what: &str) -> JsResult> { + let (o, path) = this_path(i, this, what)?; + let r = host(i, "stat", &[path])?; + i.rooted(std::slice::from_ref(&r), |i| { + let error = field(i, &r, "error")?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(None); + } + Ok(Some(r.clone())) + }) +} + +/// The field `name` of the object's `stat`, when it exists, or `missing`. +fn stat_field(i: &mut Interp, this: &Value, what: &str, name: &str, missing: Value) -> JsResult { + let Some(r) = stat(i, this, what)? else { return Ok(missing) }; + i.rooted(std::slice::from_ref(&r), |i| { + if !field(i, &r, "exists")?.truthy() && name != "exists" { + return Ok(missing); + } + field(i, &r, name) + }) +} + +/// The result of a host read: its `text`, or `failed` after setting `error`. +fn text_result(i: &mut Interp, o: ObjId, r: Value, failed: Value) -> JsResult { + i.rooted(std::slice::from_ref(&r), |i| { + let error = field(i, &r, "error")?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(failed); + } + field(i, &r, "text") + }) +} + +/// The id of the object's open file, or `None` after setting `error` when it has none. +fn opened(i: &mut Interp, o: ObjId) -> JsResult> { + let h = slot(i, o, HANDLE)?; + if matches!(h, Value::Number(n) if n >= 0.0) { + return Ok(Some(h)); + } + let m = message(i, "notOpen")?; + set_slot(i, o, ERROR, m)?; + Ok(None) +} + +fn is_open(i: &Interp, o: ObjId) -> JsResult> { + let h = slot(i, o, HANDLE)?; + Ok(matches!(h, Value::Number(n) if n >= 0.0).then_some(h)) +} + +/// Closes the object's open file, if it has one. +fn close_open(i: &mut Interp, o: ObjId) -> JsResult<()> { + if let Some(h) = is_open(i, o)? { + host(i, "close", &[h])?; + } + set_slot(i, o, HANDLE, Value::Number(-1.0)) +} + +fn date(i: &mut Interp, ms: Value) -> JsResult { + let Value::Number(t) = ms else { return Ok(Value::Null) }; + i.reserve_heap(OBJECT_BYTES)?; + let proto = i.intrinsics.date_proto; + Ok(Value::Object(i.heap.alloc_object(Object::new(Class::Date(t), Some(proto))))) +} + +/// A count of characters: `None` for `undefined`, else a whole number 0 or more. +fn count(i: &mut Interp, v: &Value, what: &str) -> JsResult> { + if matches!(v, Value::Undefined) { + return Ok(None); + } + let n = i.to_number(v)?; + if !n.is_finite() || n < 0.0 { + return Err(i.throw(ErrorType::Range, &format!("{what}: the count must be a finite number, 0 or more"))); + } + Ok(Some(n.floor())) +} + +// ---- constructors ---- + +fn file_ctor(i: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + make(i, args, construct, true) +} + +fn folder_ctor(i: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + make(i, args, construct, false) +} + +/// `new File(path)`, `File(path)`, `new Folder(path)` or `Folder(path)`. +fn make(i: &mut Interp, args: &[Value], construct: bool, file: bool) -> JsResult { + let c = classes(i)?; + let (own, other) = if file { (c.file_proto, c.folder_proto) } else { (c.folder_proto, c.file_proto) }; + let (path, platform) = match arg(args, 0) { + Value::Undefined if file => (host(i, "tempName", &[])?, true), + Value::Undefined => (c.current.clone(), true), + v => (v, false), + }; + let fs = i.rooted(std::slice::from_ref(&path), |i| { + let p = if platform { parse_text(i, path.clone(), true)? } else { parse(i, &path)? }; + i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs")) + })?; + let proto = if construct { + own + } else { + let st = i.rooted(std::slice::from_ref(&fs), |i| host(i, "stat", std::slice::from_ref(&fs)))?; + let flip = i.rooted(&[fs.clone(), st.clone()], |i| { + let error = field(i, &st, "error")?; + let exists = field(i, &st, "exists")?.truthy(); + let dir = field(i, &st, "dir")?.truthy(); + Ok(matches!(error, Value::Undefined) && exists && dir == file) + })?; + if flip { other } else { own } + }; + instance(i, proto, fs) +} + +// ---- both classes ---- + +fn absolute_uri(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "absoluteURI", "uri") +} + +fn fs_name(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(this_path(i, &this, "fsName")?.1) +} + +fn relative_uri(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (_, path) = this_path(i, &this, "relativeURI")?; + let current = classes(i)?.current; + host(i, "relative", &[path, current]) +} + +fn name(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "name", "name") +} + +fn display_name(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "displayName", "display") +} + +fn path(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "path", "path") +} + +fn parent(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let up = parsed(i, &this, "parent", "parent")?; + if matches!(up, Value::Null | Value::Undefined) { + return Ok(Value::Null); + } + let proto = classes(i)?.folder_proto; + instance(i, proto, up) +} + +fn exists(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(stat_field(i, &this, "exists", "exists", Value::Bool(false))?.truthy())) +} + +fn created(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let ms = stat_field(i, &this, "created", "created", Value::Null)?; + date(i, ms) +} + +fn modified(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let ms = stat_field(i, &this, "modified", "modified", Value::Null)?; + date(i, ms) +} + +fn alias(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let Some(r) = stat(i, &this, "alias")? else { return Ok(Value::Bool(false)) }; + let v = i.rooted(std::slice::from_ref(&r), |i| field(i, &r, "alias"))?; + Ok(Value::Bool(v.truthy())) +} + +fn get_error(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "error")?; + slot(i, o, ERROR) +} + +fn set_error(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "error")?; + let v = match arg(args, 0) { + Value::Undefined | Value::Null => Value::str(""), + v => Value::String(i.to_str(&v)?), + }; + set_slot(i, o, ERROR, v)?; + Ok(Value::Undefined) +} + +fn get_relative_uri(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (_, path) = this_path(i, &this, "getRelativeURI")?; + let base = match arg(args, 0) { + Value::Undefined => classes(i)?.current, + b => { + let p = i.rooted(std::slice::from_ref(&path), |i| parse(i, &b))?; + i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs"))? + } + }; + host(i, "relative", &[path, base]) +} + +fn change_path(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "changePath")?; + close_open(i, o)?; + let p = parse(i, &arg(args, 0))?; + let fs = i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs"))?; + set_slot(i, o, PATH, fs)?; + Ok(Value::Bool(true)) +} + +fn resolve(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "resolve")?; + let r = host(i, "resolve", &[path])?; + let (error, fs, dir) = i.rooted(std::slice::from_ref(&r), |i| Ok((field(i, &r, "error")?, field(i, &r, "fs")?, field(i, &r, "dir")?)))?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(Value::Null); + } + if !matches!(fs, Value::String(_)) { + return Ok(Value::Null); + } + let c = classes(i)?; + instance(i, if dir.truthy() { c.folder_proto } else { c.file_proto }, fs) +} + +fn execute(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "execute")?; + let r = host(i, "execute", &[path])?; + let error = i.rooted(std::slice::from_ref(&r), |i| field(i, &r, "error"))?; + set_slot(i, o, ERROR, error)?; + Ok(Value::Bool(false)) +} + +fn to_string(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + parsed(i, &this, "toString", "uri") +} + +/// A method that writes: it sets `error` and returns `false`. +fn no_writes(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "write")?; + let m = message(i, "noWrites")?; + set_slot(i, o, ERROR, m)?; + Ok(Value::Bool(false)) +} + +/// A setter that writes: it sets `error` and changes nothing. +fn no_writes_setter(i: &mut Interp, this: Value, args: &[Value], construct: bool) -> JsResult { + no_writes(i, this, args, construct)?; + Ok(Value::Undefined) +} + +/// A dialog method: it sets `error` and returns `null`. +fn no_picker(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "dialog")?; + let m = message(i, "noPicker")?; + set_slot(i, o, ERROR, m)?; + Ok(Value::Null) +} + +fn null_static(_: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Null) +} + +fn text_static(i: &mut Interp, name: &str, args: &[Value]) -> JsResult { + let s = i.to_str(&arg(args, 0))?; + host(i, name, &[Value::String(s)]) +} + +fn encode(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + text_static(i, "encode", args) +} + +fn decode(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + text_static(i, "decode", args) +} + +fn encoding_available(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + text_static(i, "encodingAvailable", args) +} + +// ---- File ---- + +fn length(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let Some(r) = stat(i, &this, "length")? else { return Ok(Value::Number(0.0)) }; + i.rooted(std::slice::from_ref(&r), |i| { + let regular = field(i, &r, "exists")?.truthy() && field(i, &r, "file")?.truthy(); + if regular { field(i, &r, "length") } else { Ok(Value::Number(0.0)) } + }) +} + +fn hidden(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(stat_field(i, &this, "hidden", "hidden", Value::Bool(false))?.truthy())) +} + +fn readonly(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(stat_field(i, &this, "readonly", "readonly", Value::Bool(false))?.truthy())) +} + +fn four_question_marks(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + this_path(i, &this, "creator")?; + Ok(Value::str("????")) +} + +fn eof(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "eof")?; + match is_open(i, o)? { + Some(h) => host(i, "eof", &[h]), + None => Ok(Value::Bool(true)), + } +} + +fn get_line_feed(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "lineFeed")?; + slot(i, o, LINE_FEED) +} + +fn set_line_feed(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "lineFeed")?; + let v = i.to_str(&arg(args, 0))?; + set_slot(i, o, LINE_FEED, Value::String(v))?; + Ok(Value::Undefined) +} + +fn get_encoding(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "encoding")?; + if let Some(h) = is_open(i, o)? { + return host(i, "encoding", &[h]); + } + Ok(match slot(i, o, ENCODING)? { + Value::String(s) if s.len() != 0 => Value::String(s), + _ => Value::str("UTF-8"), + }) +} + +fn set_encoding(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "encoding")?; + let v = Value::String(i.to_str(&arg(args, 0))?); + match is_open(i, o)? { + Some(h) => { + let r = i.rooted(std::slice::from_ref(&v), |i| host(i, "setEncoding", &[h, v.clone()]))?; + let error = i.rooted(std::slice::from_ref(&r), |i| field(i, &r, "error"))?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + } else { + set_slot(i, o, ENCODING, v)?; + } + } + None => set_slot(i, o, ENCODING, v)?, + } + Ok(Value::Undefined) +} + +fn open(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "open")?; + let mode = match arg(args, 0) { + Value::Undefined => "r".to_string(), + m => i.to_str(&m)?.to_rust_string().to_lowercase(), + }; + let refusal = match mode.as_str() { + "r" => None, + "w" | "e" | "a" => Some("noWrites"), + _ => Some("badMode"), + }; + if let Some(name) = refusal { + let m = message(i, name)?; + set_slot(i, o, ERROR, m)?; + return Ok(Value::Bool(false)); + } + close_open(i, o)?; + let encoding = slot(i, o, ENCODING)?; + let r = host(i, "open", &[path, encoding])?; + let (error, id) = i.rooted(std::slice::from_ref(&r), |i| Ok((field(i, &r, "error")?, field(i, &r, "id")?)))?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(Value::Bool(false)); + } + set_slot(i, o, HANDLE, id)?; + Ok(Value::Bool(true)) +} + +fn close(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "close")?; + if opened(i, o)?.is_none() { + return Ok(Value::Bool(false)); + } + close_open(i, o)?; + Ok(Value::Bool(true)) +} + +fn read(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "read")?; + let n = count(i, &arg(args, 0), "File.read")?; + let Some(h) = opened(i, o)? else { return Ok(Value::str("")) }; + let r = host(i, "read", &[h, n.map_or(Value::Number(-1.0), Value::Number)])?; + text_result(i, o, r, Value::str("")) +} + +fn read_line(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "readln")?; + let Some(h) = opened(i, o)? else { return Ok(Value::str("")) }; + let r = host(i, "readln", &[h])?; + text_result(i, o, r, Value::str("")) +} + +fn read_char(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "readch")?; + let Some(h) = opened(i, o)? else { return Ok(Value::str("")) }; + let r = host(i, "readch", &[h])?; + text_result(i, o, r, Value::str("")) +} + +fn seek(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "seek")?; + let pos = i.to_number(&arg(args, 0))?; + if !pos.is_finite() { + return Err(i.throw(ErrorType::Range, "File.seek: the position must be a finite number")); + } + let mode = match arg(args, 1) { + Value::Undefined => 0.0, + m => i.to_number(&m)?, + }; + if mode != 0.0 && mode != 1.0 && mode != 2.0 { + return Err(i.throw(ErrorType::Range, "File.seek: the mode must be 0, 1 or 2")); + } + let Some(h) = opened(i, o)? else { return Ok(Value::Bool(false)) }; + host(i, "seek", &[h, Value::Number(pos.floor()), Value::Number(mode)]) +} + +fn tell(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let (o, _) = this_path(i, &this, "tell")?; + let Some(h) = opened(i, o)? else { return Ok(Value::Number(-1.0)) }; + host(i, "tell", &[h]) +} + +// ---- Folder ---- + +fn get_files(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let (o, path) = this_path(i, &this, "getFiles")?; + let mask = arg(args, 0); + let call = i.is_callable(&mask); + let pattern = if call || matches!(mask, Value::Undefined) { Value::str("*") } else { Value::String(i.to_str(&mask)?) }; + let r = host(i, "list", &[path, pattern])?; + i.rooted(&[r.clone(), mask.clone()], |i| { + let error = field(i, &r, "error")?; + if matches!(error, Value::String(_)) { + set_slot(i, o, ERROR, error)?; + return Ok(Value::Null); + } + let entries = field(i, &r, "entries")?; + let length = field(i, &entries, "length")?; + let n = i.to_number(&length)?; + i.reserve_heap(OBJECT_BYTES)?; + let proto = i.intrinsics.array_proto; + let out = i.heap.alloc_object(Object::new(Class::Array { length: 0, length_writable: true }, Some(proto))); + i.rooted(&[Value::Object(out)], |i| { + let c = classes(i)?; + let mut kept = 0.0; + let mut at = 0.0; + while at < n { + i.use_fuel()?; + let e = i.get_value(&entries, &crate::value::number_to_str(at).flat())?; + let (fs, dir) = i.rooted(std::slice::from_ref(&e), |i| Ok((field(i, &e, "fs")?, field(i, &e, "dir")?.truthy())))?; + let item = instance(i, if dir { c.folder_proto } else { c.file_proto }, fs)?; + let keep = if call { + i.rooted(std::slice::from_ref(&item), |i| i.call(&mask, &Value::Undefined, std::slice::from_ref(&item)))?.truthy() + } else { + true + }; + if keep { + put_at(i, out, kept, item)?; + kept += 1.0; + } + at += 1.0; + } + Ok(Value::Object(out)) + }) + }) +} + +fn get_current(i: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + let c = classes(i)?; + instance(i, c.folder_proto, c.current) +} + +fn set_current(i: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let p = parse(i, &arg(args, 0))?; + let fs = i.rooted(std::slice::from_ref(&p), |i| field(i, &p, "fs"))?; + if let Some(c) = i.files.as_mut() { + c.current = fs; + } + Ok(Value::Undefined) +} + +/// `Folder.`: a new `Folder` for the host's location `place`, or `null`. +fn place(i: &mut Interp, place: &str) -> JsResult { + let all = host(i, "locations", &[])?; + let fs = i.rooted(std::slice::from_ref(&all), |i| field(i, &all, place))?; + if !matches!(fs, Value::String(_)) { + return Ok(Value::Null); + } + let proto = classes(i)?.folder_proto; + instance(i, proto, fs) +} + +macro_rules! places { + ($($f:ident => $name:literal),* $(,)?) => { + $(fn $f(i: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + place(i, $name) + })* + /// The getter of each of [`PLACES`], in its order. + const PLACE_GETTERS: [NativeFn; 10] = [$($f),*]; + }; +} + +places!( + app_data => "appData", + app_package => "appPackage", + common_files => "commonFiles", + desktop => "desktop", + my_documents => "myDocuments", + startup => "startup", + system => "system", + temp => "temp", + trash => "trash", + user_data => "userData", +); + +// ---- install ---- + +/// Defines the accessor `name` on `on`, not enumerable, as the help pages' objects have them. +fn accessor(i: &mut Interp, on: ObjId, name: &'static str, get: NativeFn, set: Option) -> JsResult<()> { + let g = native(i, name, get, 0, NativeKind::Plain, false)?; + i.rooted(&[Value::Object(g)], |i| { + let s = match set { + Some(f) => Some(native(i, name, f, 1, NativeKind::Plain, false)?), + None => None, + }; + i.define(on, key(name), Prop::Accessor { get: Some(g), set: s, flags: CONFIGURABLE }) + }) +} + +/// What `File` and `Folder` both have. +fn common(i: &mut Interp, ctor: ObjId, proto: ObjId) -> JsResult<()> { + accessor(i, proto, "absoluteURI", absolute_uri, None)?; + accessor(i, proto, "fullName", absolute_uri, None)?; + accessor(i, proto, "fsName", fs_name, None)?; + accessor(i, proto, "relativeURI", relative_uri, None)?; + accessor(i, proto, "name", name, None)?; + accessor(i, proto, "displayName", display_name, None)?; + accessor(i, proto, "path", path, None)?; + accessor(i, proto, "parent", parent, None)?; + accessor(i, proto, "exists", exists, None)?; + accessor(i, proto, "created", created, None)?; + accessor(i, proto, "modified", modified, None)?; + accessor(i, proto, "alias", alias, None)?; + accessor(i, proto, "error", get_error, Some(set_error))?; + method(i, proto, "toString", to_string, 0)?; + method(i, proto, "getRelativeURI", get_relative_uri, 1)?; + method(i, proto, "changePath", change_path, 1)?; + method(i, proto, "resolve", resolve, 0)?; + method(i, proto, "execute", execute, 0)?; + method(i, proto, "remove", no_writes, 0)?; + method(i, proto, "rename", no_writes, 1)?; + method(i, ctor, "encode", encode, 1)?; + method(i, ctor, "decode", decode, 1)?; + method(i, ctor, "isEncodingAvailable", encoding_available, 1)?; + let system = host_value(i, "system")?; + data(i, ctor, "fs", system, HIDDEN) +} + +fn file_members(i: &mut Interp, ctor: ObjId, proto: ObjId) -> JsResult<()> { + accessor(i, proto, "length", length, Some(no_writes_setter))?; + accessor(i, proto, "hidden", hidden, Some(no_writes_setter))?; + accessor(i, proto, "readonly", readonly, Some(no_writes_setter))?; + accessor(i, proto, "creator", four_question_marks, None)?; + accessor(i, proto, "type", four_question_marks, None)?; + accessor(i, proto, "eof", eof, None)?; + accessor(i, proto, "lineFeed", get_line_feed, Some(set_line_feed))?; + accessor(i, proto, "encoding", get_encoding, Some(set_encoding))?; + method(i, proto, "open", open, 1)?; + method(i, proto, "close", close, 0)?; + method(i, proto, "read", read, 1)?; + method(i, proto, "readln", read_line, 0)?; + method(i, proto, "readch", read_char, 0)?; + method(i, proto, "seek", seek, 2)?; + method(i, proto, "tell", tell, 0)?; + method(i, proto, "write", no_writes, 1)?; + method(i, proto, "writeln", no_writes, 1)?; + method(i, proto, "copy", no_writes, 1)?; + method(i, proto, "createAlias", no_writes, 1)?; + method(i, proto, "openDlg", no_picker, 0)?; + method(i, proto, "saveDlg", no_picker, 0)?; + method(i, ctor, "openDialog", null_static, 0)?; + method(i, ctor, "saveDialog", null_static, 0) +} + +fn folder_members(i: &mut Interp, ctor: ObjId, proto: ObjId) -> JsResult<()> { + method(i, proto, "getFiles", get_files, 1)?; + method(i, proto, "create", no_writes, 0)?; + method(i, proto, "selectDlg", no_picker, 0)?; + method(i, ctor, "selectDialog", null_static, 0)?; + accessor(i, ctor, "current", get_current, Some(set_current))?; + for (place, get) in PLACES.into_iter().zip(PLACE_GETTERS) { + accessor(i, ctor, place, get, None)?; + } + Ok(()) +} + +/// Adds `File` and `Folder` to the global object, their work done by the host functions in +/// `items` ([`crate::Realm::define_file_classes`]). +pub(crate) fn install(i: &mut Interp, items: Vec<(String, HostItem)>) -> JsResult<()> { + let host_obj = i.host_object("File", items)?; + let object_proto = i.intrinsics.object_proto; + i.rooted(&[Value::Object(host_obj)], |i| { + i.reserve_heap(2 * OBJECT_BYTES)?; + let file_proto = i.heap.alloc_object(Object::new(Class::Ordinary, Some(object_proto))); + let folder_proto = i.heap.alloc_object(Object::new(Class::Ordinary, Some(object_proto))); + i.files = Some(Classes { file_proto, folder_proto, host: host_obj, current: Value::str("") }); + let start = host(i, "current", &[])?; + if let Some(c) = i.files.as_mut() { + c.current = start; + } + let file = native(i, "File", file_ctor, 1, NativeKind::Plain, true)?; + constructor(i, "File", file, file_proto)?; + let folder = native(i, "Folder", folder_ctor, 1, NativeKind::Plain, true)?; + constructor(i, "Folder", folder, folder_proto)?; + common(i, file, file_proto)?; + common(i, folder, folder_proto)?; + file_members(i, file, file_proto)?; + folder_members(i, folder, folder_proto) + }) +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::{Data, HostItem, JsValue, Realm}; + + /// A host whose paths are `/` and a name, where `/d` is a folder holding `a` and `b` and every + /// other path is missing. + fn realm() -> Realm { + let f = |g: fn(&[Data]) -> Data| HostItem::Function(Rc::new(move |a: Vec| Ok(g(&a)))); + let text = |a: &[Data], i: usize| match a.get(i) { + Some(Data::String(s)) => s.clone(), + _ => String::new(), + }; + let obj = |fields: Vec<(&str, Data)>| Data::Object(fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect()); + let parse = move |a: &[Data]| { + let t = text(a, 0); + let fs = if t.starts_with('/') { t } else { format!("/{t}") }; + let name = fs.trim_start_matches('/').to_string(); + obj(vec![ + ("fs", Data::String(fs.clone())), + ("uri", Data::String(fs)), + ("name", Data::String(name.clone())), + ("display", Data::String(name)), + ("path", Data::String("/".into())), + ("parent", Data::String("/".into())), + ]) + }; + let messages = ["notOpen", "noWrites", "noPicker", "badMode"].map(|k| (k.to_string(), HostItem::Value(Data::String(k.into())))); + let stat = move |a: Vec| -> Result { + let dir = text(&a, 0) == "/d"; + Ok(obj(vec![("exists", Data::Bool(dir)), ("dir", Data::Bool(dir))])) + }; + let list = move |_: Vec| -> Result { + let e = |n: &str| obj(vec![("fs", Data::String(format!("/d/{n}"))), ("dir", Data::Bool(false))]); + Ok(obj(vec![("entries", Data::Array(vec![e("a"), e("b")]))])) + }; + let mut r = Realm::new(); + let items: Vec<(String, HostItem)> = vec![ + ("text".into(), HostItem::Object(messages.to_vec())), + ("system".into(), HostItem::Value(Data::String("Unix".into()))), + ("lineFeed".into(), HostItem::Value(Data::String("Unix".into()))), + ("current".into(), f(|_| Data::String("/".into()))), + ("parse".into(), HostItem::Function(Rc::new(move |a: Vec| Ok(parse(&a))))), + ("stat".into(), HostItem::Function(Rc::new(stat))), + ("list".into(), HostItem::Function(Rc::new(list))), + ("tempName".into(), f(|_| Data::String("/tmp1".into()))), + ("locations".into(), f(|_| Data::Object(vec![]))), + ]; + r.define_file_classes(items).unwrap(); + r + } + + #[test] + fn objects_keep_their_classes_through_collections() { + let mut r = realm(); + r.run("var kept = new File('x'); var listed = new Folder('d').getFiles(); File = null; Folder = null;").unwrap(); + r.collect_garbage(); + r.run("var many = []; for (var i = 0; i < 2000; i++) many.push(kept.parent);").unwrap(); + r.collect_garbage(); + let v = r.run( + "[kept.name, kept.fsName, listed.length, listed[1].name, kept.parent.getFiles(function (f) { return f.name === 'd/a'; }).length].join()", + ); + assert_eq!(v.unwrap(), JsValue::String("x,/x,2,d/b,1".into())); + } + + #[test] + fn the_constructor_picks_the_class_only_without_new() { + let mut r = realm(); + let v = r.run( + "var F = File, D = Folder; [F('d') instanceof D, new F('d') instanceof F, D('x') instanceof D, \ + typeof Object.keys(new F('x')).length, Object.keys(new F('x')).length].join()", + ); + assert_eq!(v.unwrap(), JsValue::String("true,true,true,number,0".into())); + let e = r.run("File.prototype.read.call({})").unwrap_err(); + assert!(e.message.contains("is not a File or a Folder"), "{e:?}"); + } +} diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 4ec8f2c72..4e0ef7e78 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -485,7 +485,7 @@ impl Interp { self.define(global, key(name), Prop::Data { value: Value::Object(o), flags: 0 }) } - fn host_object(&mut self, path: &str, items: Vec<(String, HostItem)>) -> JsResult { + pub(crate) fn host_object(&mut self, path: &str, items: Vec<(String, HostItem)>) -> JsResult { let o = self.new_object(); let mark = self.temp_roots.len(); self.temp_roots.push(Value::Object(o)); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index ed3099352..64204423f 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -198,6 +198,8 @@ pub(crate) struct Interp { pub(crate) held_bytes: usize, /// Why installing the built-ins failed; a realm with this set runs no script. pub(crate) install_error: Option, + /// `File` and `Folder`, once a host has added them ([`crate::Realm::define_file_classes`]). + pub(crate) files: Option, /// The clock and time zone `Date` reads. pub(crate) date_host: crate::date_builtins::DateHost, /// The bytes native code holds outside the heap's cells through [`HeldBytes`]. @@ -423,6 +425,7 @@ impl Interp { join_stack: Vec::new(), held_bytes: 0, install_error: None, + files: None, date_host: crate::date_builtins::DateHost::default(), held: Rc::default(), xml_default_uri: Rc::from(Vec::new()), @@ -618,6 +621,7 @@ impl Interp { } roots.objects.push(self.global); roots.objects.extend(self.intrinsics.all()); + roots.objects.extend(self.files.iter().flat_map(crate::file_classes::Classes::objects)); roots.envs.push(self.global_env); self.heap.collect(&roots); // The collection counts the live total again, so bytes already released are not in it. diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index a8478aa65..7df9953a6 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -53,6 +53,7 @@ mod chars; mod compiler; mod date_builtins; mod error; +mod file_classes; mod heap; mod host; mod interp; diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 1c2bb6ecf..0f8aeabc1 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -400,6 +400,20 @@ impl Realm { } } + /// Adds the global constructors `File` and `Folder`, whose file system work the host functions + /// in `items` do: `parse`, `relative`, `tempName`, `stat`, `open`, `close`, `read`, `readln`, + /// `readch`, `seek`, `tell`, `eof`, `encoding`, `setEncoding`, `list`, `resolve`, `execute`, + /// `encode`, `decode`, `encodingAvailable`, `current` and `locations`, with the values `system` + /// and `lineFeed` and the messages `text.notOpen`, `text.noWrites`, `text.noPicker` and + /// `text.badMode`. Each object keeps its path, its `error` text, its open file's id, its + /// encoding and its line feed in properties that are not enumerable. + pub fn define_file_classes(&mut self, items: Vec<(String, crate::HostItem)>) -> Result<(), RunError> { + match crate::file_classes::install(&mut self.interp, items) { + Ok(()) => Ok(()), + Err(e) => self.outcome(Err(e)).map(drop), + } + } + /// Adds the script extensions: the global `localize(object, ...values)`, which gives a /// localization object's text for the locale of [`Realm::localization`], the global /// `toSource(value)` and `Object.prototype.toSource()`, which write plain data as source text, diff --git a/crates/js/src/xml_builtins.rs b/crates/js/src/xml_builtins.rs index 35c578830..07e2a1d12 100644 --- a/crates/js/src/xml_builtins.rs +++ b/crates/js/src/xml_builtins.rs @@ -330,7 +330,7 @@ fn xml_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> J let v = arg(args, 0); interp.rooted(std::slice::from_ref(&v), |i| { let x = to_xml(i, &v)?; - if construct && matches!(i.classify(&v), Xv::Node(_)) { + if construct && matches!(i.classify(&v), Xv::Node(_) | Xv::List(_)) { return Ok(Value::Object(i.node_copy(x)?)); } Ok(Value::Object(x)) @@ -883,8 +883,15 @@ fn insert_relative(interp: &mut Interp, this: Value, args: &[Value], after: bool if after { 0 } else { len } } else { let Xv::Node(r) = interp.classify(&reference) else { return Ok(Value::Undefined) }; - interp.use_fuel()?; - let Some(i) = interp.xml_node(x)?.children.iter().position(|&c| c == r) else { return Ok(Value::Undefined) }; + let mut found = None; + for i in 0..len { + interp.use_fuel()?; + if interp.xml_node(x)?.children.get(i) == Some(&r) { + found = Some(i); + break; + } + } + let Some(i) = found else { return Ok(Value::Undefined) }; if after { i + 1 } else { i } }; interp.rooted(std::slice::from_ref(&v), |i| i.xml_insert(x, at, &c))?; diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 051c676a9..dce24d9d9 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -290,6 +290,10 @@ fn the_constructors_keep_identity_or_copy_as_specified() { truth("XML().nodeKind() === 'text' && XML(null) == '' && XML(undefined) == ''"); truth("var x = ; XML(x) === x && new XML(x) !== x && new XML(x) == x"); truth("var l = <>; XML(l) === l[0]"); + truth("var p =

; var l = p.a; var y = new XML(l); y !== l[0] && y == l[0] && y.parent() === null && l[0].parent() === p"); + truth( + "var p =

; var l = p.a; var y = new XML(l); y.@q = '2'; y.c.@r = '3'; l[0].@q == '1' && l[0].c.@r.length() === 0 && y.c.@r == '3'", + ); assert_eq!(thrown_name("XML(<>)"), "TypeError"); assert_eq!(thrown_name("XML('')"), "SyntaxError"); assert_eq!(thrown_name("XML('')"), "SyntaxError"); @@ -368,7 +372,7 @@ fn the_xml_methods_behave_as_specified() { assert_eq!(string("(1).toString()"), "1"); assert_eq!(string("().toString()"), "\n \n"); truth("var x = ; x.valueOf() === x"); - truth("().xpath === undefined || ().xpath.length() === 0"); + truth("!Object.prototype.hasOwnProperty.call(XML.prototype, 'xpath') && !Object.prototype.hasOwnProperty.call(XMLList.prototype, 'xpath')"); truth("var x = ; x.b.attributes !== undefined"); } @@ -716,6 +720,26 @@ fn catastrophic_queries_end_with_the_fuel_limit() { assert_eq!(kind_of(limits, &serialize), RunErrorKind::Limit(LimitKind::Fuel)); } +/// The fuel that `action` takes when it runs after `setup`. +fn fuel_of(setup: &str, action: &str) -> u64 { + let mut r = realm(); + r.run(setup).unwrap(); + r.run(action).unwrap(); + Limits::default().fuel - r.fuel_left() +} + +#[test] +fn insert_child_before_and_after_take_fuel_for_each_child_they_scan() { + let narrow = "var x = ; var last = x.b[0];"; + let wide = "var x = ; for (var i = 0; i < 2000; i++) x.appendChild(); var last = x.b[1999];"; + for action in + ["x.insertChildAfter(, )", "x.insertChildBefore(, )", "x.insertChildAfter(last, )", "x.insertChildBefore(last, )"] + { + let (n, w) = (fuel_of(narrow, action), fuel_of(wide, action)); + assert!(w >= n + 1999, "{action}: {n} with one child, {w} with 2000"); + } +} + #[test] fn limits_cannot_be_caught_by_the_script() { assert_eq!(kind_of(TIGHT, "try { for (;;) ().toXMLString(); } catch (e) { 1 }"), RunErrorKind::Limit(LimitKind::Fuel)); diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 2480f4ad6..17c7ecc91 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -259,7 +259,7 @@ pub fn tool_definitions() -> Vec { "radius1": num("Star outer radius"), "radius2": num("Star inner radius"), "points": {"type": "integer", "minimum": 3, "description": "Star points (default 5)"}, - "rotation": num("Rotation in degrees (polygon/star)"), + "rotation": num("Rotation in degrees clockwise (polygon/star; 0: a corner or point straight up)"), "x1": num("Line start x"), "y1": num("Line start y"), "x2": num("Line end x"), @@ -471,12 +471,12 @@ pub fn tool_definitions() -> Vec { "ids": {"type": "array", "items": {"type": "integer"}}, "dx": num("Move right"), "dy": num("Move down"), - "rotate": num("Rotation in degrees"), + "rotate": num("Rotation in degrees counter-clockwise"), "scale": num("Uniform scale in %"), "scaleX": num("Horizontal scale in %"), "scaleY": num("Vertical scale in %"), "reflect": {"type": "string", "enum": ["vertical", "horizontal"]}, - "shear": num("Shear angle in degrees (horizontal axis)"), + "shear": num("Shear angle in degrees (horizontal axis: positive moves the top to the right, turning the vertical edges clockwise)"), "origin": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "copy": {"type": "boolean"}, }), diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 87398c1f0..343b42209 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -176,7 +176,8 @@ impl<'a> Scene<'a> { } fn node(&mut self, n: &Node, force: bool) { - if let NodeKind::Layer { template: true, .. } = n.kind { + // Ruler guides draw on screen only: no record and no loss warning. + if n.is_ruler_guide() || n.is_template() { return; } if !force && !n.visible { @@ -237,6 +238,8 @@ impl<'a> Scene<'a> { self.node(c, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } self.opacity = opacity; } diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 75b87209c..143fa848f 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -92,7 +92,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let links = !set.standard.is_pdfx(); let mut linked = false; for layer in doc.layers.iter().filter(|_| !links) { - layer.walk(&mut |n| linked |= n.url().is_some()); + layer.walk(&mut |n| linked |= !n.is_ruler_guide() && n.url().is_some()); } if linked { warnings.push(format!("{} files have no links: the objects' URLs aren't clickable", set.standard.label())); diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 2d9a9cc17..fde13f4d3 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -318,6 +318,8 @@ impl Exporter<'_> { self.node(s, c, page, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } self.knockout = enclosing; for _ in 0..pushes { diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index d0a461962..2238ab41e 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -1195,7 +1195,10 @@ impl Plan { if self.top.as_ref().is_some_and(|(p, _)| *p == parent) && let Some((_, nodes)) = self.top.take() { - out.get_or_insert_with(|| list.to_vec()).extend(nodes.into_iter().map(Arc::new)); + // On a layer, new objects go below its ruler guides, which stay on top. + let o = out.get_or_insert_with(|| list.to_vec()); + let at = Node::guide_run_start(o); + o.splice(at..at, nodes.into_iter().map(Arc::new)); } out } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index ba4cccae2..c04ffe6a0 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -164,6 +164,21 @@ fn compound_guides_are_not_filter_targets() { assert!(targets.contains(&made[1]), "the plain compound path"); } +/// New objects put on top of a layer go below its ruler guides, which stay on top; a ruler guide +/// is never a filter target. +#[test] +fn new_objects_go_below_a_layers_ruler_guides() { + let (mut d, _, layer) = doc(); + let guide = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 5.0, artboard: None }); + let gid = d.insert(Some(layer), usize::MAX, guide).unwrap(); + assert!(!filter_targets(&d, &[layer]).contains(&gid)); + let out = json!({"objects": [{"name": "N", "type": "path", "path": square(0.0)}]}); + let made = apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer), &keep).unwrap(); + let ids: Vec = d.children(Some(layer)).unwrap().iter().map(|n| n.id).collect(); + assert_eq!(ids.last(), Some(&gid)); + assert_eq!(ids.get(ids.len() - 2), made.first()); +} + /// `guide` and `locked` make objects guides (a compound path's members too) and lock them, or undo /// both; locked objects and guides are not [`selectable`]. Other values are refused. #[test] diff --git a/crates/render/src/encode.rs b/crates/render/src/encode.rs index 201931a8a..635f90683 100644 --- a/crates/render/src/encode.rs +++ b/crates/render/src/encode.rs @@ -248,7 +248,7 @@ fn drawn_bounds_with(symbols: &SymbolBoxes, n: &Node, held: Option<&Arc>, return None; } match &n.kind { - NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => None, + NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => None, NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(n) => children .iter() .skip(usize::from(n.extras().shaper.is_some())) @@ -289,7 +289,7 @@ fn without_undrawn(n: &Node) -> Option { if keeps_all || !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) { return None; } - let undrawn = |c: &Node| !c.visible || matches!(c.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. }); + let undrawn = |c: &Node| !c.visible || c.is_guide() || c.is_template(); let mut changed = false; let kept: Vec> = n .children()? diff --git a/crates/render/src/encode/psd.rs b/crates/render/src/encode/psd.rs index 777276e69..98c031c89 100644 --- a/crates/render/src/encode/psd.rs +++ b/crates/render/src/encode/psd.rs @@ -447,7 +447,7 @@ impl Parts<'_> { /// Node `n` (a top-level layer, or with Maximum Editability anything in one) and its parts. fn add(&mut self, n: &Node) { // Templates and guides aren't artwork. - if matches!(n.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. }) || !(n.visible || self.o.hidden_layers) { + if n.is_template() || n.is_guide() || !(n.visible || self.o.hidden_layers) { return; } let (hidden, opacity) = (!n.visible, opacity_byte(n.opacity)); diff --git a/crates/render/src/encode/tests_psd.rs b/crates/render/src/encode/tests_psd.rs index e56278f1d..a488cceff 100644 --- a/crates/render/src/encode/tests_psd.rs +++ b/crates/render/src/encode/tests_psd.rs @@ -271,6 +271,28 @@ fn guides_are_not_layers() { assert_eq!(parse(&export(&b.build(), &o)).count, -3, "the group and the square"); } +/// Ruler guides on a layer and a sublayer change nothing in the export: the same file with and +/// without Maximum Editability, the same drawn bounds and the same cached size of each layer. +#[test] +fn ruler_guides_are_not_exported() { + use vectorcraft_doc::{NodeId, NodeKind}; + let plain = doc(); + let mut guided = plain.clone(); + let guide = |id, vertical, pos| Node::new(NodeId(id), NodeKind::RulerGuide { vertical, pos, artboard: None }); + let (bottom, inner) = (guided.layers[0].id, guided.layers[1].children().unwrap().last().unwrap().id); + guided.insert(Some(bottom), usize::MAX, guide(900, true, 20.0)).unwrap(); + guided.insert(Some(bottom), usize::MAX, guide(901, false, 15.0)).unwrap(); + guided.insert(Some(inner), usize::MAX, guide(902, true, 3.0)).unwrap(); + for psd in [PsdOptions::default(), PsdOptions { max_editability: true, ..PsdOptions::default() }] { + assert_eq!(export(&guided, &opts(psd.clone())), export(&plain, &opts(psd))); + } + assert_eq!(art_bounds(&guided), art_bounds(&plain)); + for (g, p) in guided.layers.iter().zip(&plain.layers) { + assert_eq!(crate::drawn_bounds(&guided, g), crate::drawn_bounds(&plain, p)); + assert_eq!(crate::lru::node_bytes(g), crate::lru::node_bytes(p)); + } +} + #[test] fn a_background_colour_is_the_bottom_layer() { let doc = doc(); diff --git a/crates/render/src/lru.rs b/crates/render/src/lru.rs index 54ab4e51e..85f327b89 100644 --- a/crates/render/src/lru.rs +++ b/crates/render/src/lru.rs @@ -243,10 +243,14 @@ pub(crate) fn path_bytes(p: &BezPath) -> usize { size_of_val(p.elements()) } -/// Approximate bytes of node `n` and its members: the nodes and their path anchors. +/// Approximate bytes of node `n` and its members: the nodes and their path anchors. Ruler guides +/// are not drawn art and are not counted. pub(crate) fn node_bytes(n: &Node) -> usize { let mut b = 0; n.walk(&mut |c| { + if c.is_ruler_guide() { + return; + } b += size_of::() + NODE_OVERHEAD; if let Some(p) = c.path_data() { b += p.subpaths.iter().map(|s| size_of::() + s.anchors.len() * size_of::()).sum::(); diff --git a/crates/render/src/node_checks.rs b/crates/render/src/node_checks.rs index 900a59c83..bc516567d 100644 --- a/crates/render/src/node_checks.rs +++ b/crates/render/src/node_checks.rs @@ -108,9 +108,11 @@ pub(crate) fn holds_instance(n: &Node) -> bool { } /// Is `a` left out of a frame `doc` draws with `opts`, before any culling: hidden, a template or -/// guide the view hides, drawn hidden, or the instance its symbol is being edited from? +/// guide the view hides, a ruler guide (the canvas draws those), drawn hidden, or the instance its +/// symbol is being edited from? pub(crate) fn left_out(opts: &crate::RenderOptions, doc: &vectorcraft_doc::Document, a: &Node) -> bool { - let view_item = (opts.skip_templates || opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. }) + let view_item = a.is_ruler_guide() + || (opts.skip_templates || opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. }) || (opts.skip_templates || opts.hide_guides) && matches!(a.kind, NodeKind::Path { guide: true, .. }); !a.visible || view_item || opts.hidden.contains(&a.id) || edited_instance(opts, doc, a.id) } diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index a722c799e..2372abdbf 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -361,7 +361,8 @@ impl Renderer { let Some(pe) = &f.doc.pattern_edit else { return false }; let Some(layer) = f.doc.layers.iter().find(|l| l.id == pe.layer) else { return false }; let Some(def) = f.doc.pattern(&pe.pattern) else { return false }; - let children: Vec> = layer.children().cloned().unwrap_or_default(); + // The tile's art: the layer's ruler guides are not part of it. + let children: Vec> = layer.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).cloned().collect(); if layer.visible && !children.is_empty() { let dim = (def.dim_copies / 100.0).clamp(0.0, 1.0); let (before, after) = def.preview_offsets_dimmed_sides(); diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 9b59367de..ca6611ea5 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -596,6 +596,8 @@ impl Renderer { Some(a) => self.draw_live_body(ctx, f, &a, true), None => self.draw_live_node(ctx, f, n), }, + // The canvas draws ruler guides over the art; the renderer draws nothing for them. + NodeKind::RulerGuide { .. } => {} } } @@ -680,18 +682,20 @@ impl Renderer { self.draw_knockout(ctx, f, children); } else if let Some((_, path)) = self.isolation_path(f) { // Isolation mode, in a container on the way down to the isolated one: each run of - // members around it draws dimmed as one (#833). + // members around it draws dimmed as one (#833). A run of ruler guides alone draws + // nothing, so it gets no dimmed pass. + let draws = |run: &[Arc]| run.iter().any(|c| !c.is_ruler_guide()); let mut rest = children; while let Some(i) = rest.iter().position(|c| path.contains(&c.id)) { let (around, from) = rest.split_at(i); - if !around.is_empty() { + if draws(around) { self.dimmed(ctx, f, &mut |r, c, fr| around.iter().for_each(|n| r.draw_arc(c, fr, n))); } let Some((on_path, after)) = from.split_first() else { break }; self.draw_arc(ctx, f, on_path); rest = after; } - if !rest.is_empty() { + if draws(rest) { self.dimmed(ctx, f, &mut |r, c, fr| rest.iter().for_each(|n| r.draw_arc(c, fr, n))); } } else { diff --git a/crates/scriptcheck/Cargo.toml b/crates/scriptcheck/Cargo.toml index d0101be96..2e9dac687 100644 --- a/crates/scriptcheck/Cargo.toml +++ b/crates/scriptcheck/Cargo.toml @@ -9,5 +9,8 @@ rust-version.workspace = true [lints] workspace = true +[dependencies] +unicode-general-category = "1" + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/scriptcheck/src/analyze.rs b/crates/scriptcheck/src/analyze.rs index 1e5b21fc2..9a637274d 100644 --- a/crates/scriptcheck/src/analyze.rs +++ b/crates/scriptcheck/src/analyze.rs @@ -116,8 +116,9 @@ pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str> } /// `src` with the `\uXXXX` escapes in its names written as the characters they stand for (ECMA-262 -/// 3rd edition, section 7.6), and `src` itself when no name has one. An escape for a character no -/// name may hold stays as written. Line breaks don't change, so every line keeps its number. +/// 3rd edition, section 7.6), and `src` itself when no name has one. An escape for a character a +/// name may not hold where the escape stands stays as written. Line breaks don't change, so every +/// line keeps its number. pub(crate) fn unescaped<'a>(src: Cow<'a, str>, acc: &mut Acc) -> Cow<'a, str> { match unescape_names(&src, acc) { Some(out) => Cow::Owned(out), @@ -145,20 +146,17 @@ fn unescape_names(src: &str, acc: &mut Acc) -> Option { changed.then_some(out) } -/// The name `text` with each `\uXXXX` escape for a character a name may hold written as that -/// character. +/// The name `text` with each `\uXXXX` escape for a character a name may hold where it stands written +/// as that character: a character a name may start with first, and one a name may hold after that. fn unescape_name(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(i) = rest.find("\\u") { out.push_str(rest.get(..i).unwrap_or_default()); let after = rest.get(i + 2..).unwrap_or_default(); - let c = after - .get(..4) - .filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit())) - .and_then(|hex| u32::from_str_radix(hex, 16).ok()) - .and_then(char::from_u32) - .filter(|&c| crate::lex::ident_part(c)); + let first = out.is_empty(); + let c = crate::lex::escaped(rest.get(i..).unwrap_or_default()) + .filter(|&c| if first { crate::lex::ident_start(c) } else { crate::lex::ident_part(c) }); match c { Some(c) => { out.push(c); diff --git a/crates/scriptcheck/src/lex.rs b/crates/scriptcheck/src/lex.rs index be66dd4b1..92d8c72e0 100644 --- a/crates/scriptcheck/src/lex.rs +++ b/crates/scriptcheck/src/lex.rs @@ -8,6 +8,10 @@ //! level and the levels of XML literals and their code are kept on stacks capped at the nesting //! limit. +use std::cmp::Ordering; + +use unicode_general_category::{GeneralCategory as G, get_general_category}; + use crate::catalog; use crate::report::{TOO_DEEP, UNTERMINATED_COMMENT, UNTERMINATED_REGEXP, UNTERMINATED_STRING, UNTERMINATED_TEMPLATE}; @@ -130,13 +134,105 @@ fn is_blank(c: char) -> bool { !is_break(c) && (c.is_whitespace() || c == '\u{feff}') } -fn ident_start(c: char) -> bool { - c == '$' || c == '_' || c.is_ascii_alphabetic() || (!c.is_ascii() && c.is_alphabetic()) +/// A character a name may start with: a Unicode `ID_Start` character, `$` or `_` (the Names and +/// Keywords clause of current ECMA-262 editions). `ID_Start` is the letters (categories Lu, Ll, Lt, +/// Lm, Lo and Nl) and `Other_ID_Start`, less `Pattern_Syntax` and `Pattern_White_Space` (Unicode +/// Standard Annex #31). The categories come from Unicode 16.0. +pub(crate) fn ident_start(c: char) -> bool { + match c { + '$' | '_' => true, + _ if c.is_ascii() => c.is_ascii_alphabetic(), + _ if is_other_id_start(c) => true, + _ => { + matches!( + get_general_category(c), + G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber + ) && !is_pattern_syntax(c) + } + } } -/// A character a name may hold after its first. +/// A character a name may hold after its first: a Unicode `ID_Continue` character or `$`. +/// `ID_Continue` adds to `ID_Start` the decimal digits (Nd), combining marks (Mn, Mc), connector +/// punctuation (Pc, which holds `_`) and `Other_ID_Continue` (which holds the zero width non-joiner +/// and joiner), less `Pattern_Syntax` and `Pattern_White_Space`. pub(crate) fn ident_part(c: char) -> bool { - ident_start(c) || c.is_ascii_digit() || (!c.is_ascii() && c.is_alphanumeric()) + match c { + '0'..='9' => true, + _ if ident_start(c) => true, + _ if c.is_ascii() => false, + _ if is_other_id_continue(c) => true, + _ => { + matches!(get_general_category(c), G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation) + && !is_pattern_syntax(c) + } + } +} + +/// The character the `\uXXXX` escape at the start of `s` stands for: `None` when `s` doesn't start +/// with `\u` and four hexadecimal digits, or when they give a lone surrogate. +pub(crate) fn escaped(s: &str) -> Option { + s.strip_prefix("\\u")? + .get(..4) + .filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit())) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .and_then(char::from_u32) +} + +/// `Other_ID_Start` (Unicode PropList 16.0): characters kept in `ID_Start` though their category is +/// not a letter. +fn is_other_id_start(c: char) -> bool { + matches!(c, '\u{1885}' | '\u{1886}' | '\u{2118}' | '\u{212E}' | '\u{309B}' | '\u{309C}') +} + +/// `Other_ID_Continue` (Unicode PropList 16.0): characters kept in `ID_Continue` though their +/// category is not a letter, mark, digit or connector. +fn is_other_id_continue(c: char) -> bool { + matches!(c, '\u{B7}' | '\u{387}' | '\u{1369}'..='\u{1371}' | '\u{19DA}' | '\u{200C}' | '\u{200D}' | '\u{30FB}' | '\u{FF65}') +} + +/// Non-ASCII `Pattern_Syntax` ranges (Unicode PropList). Unicode does not change this set, and no +/// identifier character belongs to it. `Pattern_White_Space` outside ASCII (U+0085, U+200E, U+200F, +/// U+2028 and U+2029) holds no letter, mark, digit or connector, so the category checks leave it out. +const PATTERN_SYNTAX: [(char, char); 23] = [ + ('\u{A1}', '\u{A7}'), + ('\u{A9}', '\u{A9}'), + ('\u{AB}', '\u{AC}'), + ('\u{AE}', '\u{AE}'), + ('\u{B0}', '\u{B1}'), + ('\u{B6}', '\u{B6}'), + ('\u{BB}', '\u{BB}'), + ('\u{BF}', '\u{BF}'), + ('\u{D7}', '\u{D7}'), + ('\u{F7}', '\u{F7}'), + ('\u{2010}', '\u{2027}'), + ('\u{2030}', '\u{203E}'), + ('\u{2041}', '\u{2053}'), + ('\u{2055}', '\u{205E}'), + ('\u{2190}', '\u{245F}'), + ('\u{2500}', '\u{2775}'), + ('\u{2794}', '\u{2BFF}'), + ('\u{2E00}', '\u{2E7F}'), + ('\u{3001}', '\u{3003}'), + ('\u{3008}', '\u{3020}'), + ('\u{3030}', '\u{3030}'), + ('\u{FD3E}', '\u{FD3F}'), + ('\u{FE45}', '\u{FE46}'), +]; + +/// Whether a non-ASCII character is `Pattern_Syntax`. +fn is_pattern_syntax(c: char) -> bool { + PATTERN_SYNTAX + .binary_search_by(|&(lo, hi)| { + if hi < c { + Ordering::Less + } else if lo > c { + Ordering::Greater + } else { + Ordering::Equal + } + }) + .is_ok() } /// After `<`, the start of an XML literal: a name, `>` (a list), `!` (a comment or CDATA), `?` (a @@ -300,20 +396,13 @@ impl<'s> Lexer<'s> { } } - /// The rest of a name (its first character included): letters, digits, `$`, `_` and `\u` - /// escapes. + /// The rest of a name (its first character included): the characters [`ident_part`] allows, + /// written out or as `\uXXXX` escapes. An escape for any other character, or one without four + /// hexadecimal digits, ends the name. fn name(&mut self) { loop { match self.peek() { - Some('\\') if self.peek_at(1) == Some('u') => { - self.skip(2); - for _ in 0..4 { - match self.peek() { - Some(h) if h.is_ascii_hexdigit() => self.bump(h), - _ => break, - } - } - } + Some('\\') if escaped(self.rest()).is_some_and(ident_part) => self.skip(6), Some(c) if ident_part(c) => self.bump(c), _ => break, } @@ -820,7 +909,7 @@ impl<'s> Iterator for Lexer<'s> { self.close_level(); self.bump('}'); return Some(self.markup_token(start, line, T::XmlMore, stmt, expr)); - } else if ident_start(c) || (c == '\\' && self.peek_at(1) == Some('u')) { + } else if ident_start(c) || (c == '\\' && escaped(self.rest()).is_some_and(ident_start)) { self.name(); T::Ident } else if c.is_ascii_digit() || (c == '.' && self.peek_at(1).is_some_and(|d| d.is_ascii_digit())) { diff --git a/crates/scriptcheck/src/tests.rs b/crates/scriptcheck/src/tests.rs index e7ead63a4..c7eed0c1a 100644 --- a/crates/scriptcheck/src/tests.rs +++ b/crates/scriptcheck/src/tests.rs @@ -268,6 +268,99 @@ fn names_written_with_escapes_are_read_as_the_names_they_spell() { assert_eq!(r.lines, 3); } +/// `src` with the escapes in its names written as the characters they stand for. +fn unescaped(src: &str) -> String { + let limits = Limits::default(); + crate::analyze::unescaped(std::borrow::Cow::Borrowed(src), &mut crate::report::Acc::new(&limits)).into_owned() +} + +#[test] +fn names_are_made_of_the_unicode_identifier_characters() { + // U+2118 (Other_ID_Start) starts a name, and U+203F (connector punctuation), U+00B7 + // (Other_ID_Continue) and the zero width non-joiner and joiner continue one. + for name in ["\u{2118}a", "a\u{203F}b", "a\u{B7}b", "a\u{200C}b", "a\u{200D}b", "_\u{2118}", "$_a1", "\u{E9}t\u{E9}"] { + assert_eq!(tokens(name), [(T::Ident, name)], "{name:?}"); + } + for (src, want) in [ + // Characters that only continue a name, and digits, don't start one. + ("\u{203F}a", [(T::Other, "\u{203F}"), (T::Ident, "a")]), + ("\u{B7}a", [(T::Other, "\u{B7}"), (T::Ident, "a")]), + ("\u{200C}a", [(T::Other, "\u{200C}"), (T::Ident, "a")]), + ("0a", [(T::Number, "0"), (T::Ident, "a")]), + // A superscript digit (category No) and a letter that is Pattern_Syntax are in no name. + ("a\u{B2}", [(T::Ident, "a"), (T::Other, "\u{B2}")]), + ("a\u{2E2F}", [(T::Ident, "a"), (T::Other, "\u{2E2F}")]), + ("\u{2E2F}a", [(T::Other, "\u{2E2F}"), (T::Ident, "a")]), + // `#` and a name away from the start of a line, and a regular expression's flags. + ("a #\u{2118}b", [(T::Ident, "a"), (T::Other, "#\u{2118}b")]), + ("=/a/g\u{B2}", [(T::RegExp, "/a/g"), (T::Other, "\u{B2}")]), + ] { + let mut got = tokens(src); + if src.starts_with('=') { + got.remove(0); + } + assert_eq!(got, want, "{src:?}"); + } + assert_eq!(tokens("a #\u{B2}"), [(T::Ident, "a"), (T::Other, "#"), (T::Other, "\u{B2}")]); + assert_eq!(tokens("=/a/g\u{200D}"), [(T::Punct, "="), (T::RegExp, "/a/g\u{200D}")]); + // The code in an XML literal's braces reads names by the same rule. + assert!(tokens("var x = {\u{2118}b + c\u{203F}d};").iter().filter(|t| t.0 == T::Ident).any(|t| t.1 == "c\u{203F}d")); + assert!(tokens("var x = {\u{2118}b};").contains(&(T::Ident, "\u{2118}b"))); +} + +#[test] +fn an_escape_stands_for_a_character_only_where_a_name_may_hold_it() { + for (src, want) in [ + ("\\u2118a", "\u{2118}a"), + ("a\\u203Fb", "a\u{203F}b"), + ("a\\u00B7b", "a\u{B7}b"), + ("a\\u200Cb", "a\u{200C}b"), + ("a\\u200Db", "a\u{200D}b"), + ("a\\u0030", "a0"), + ("\\u0024\\u005F", "$_"), + // Not at the start of a name. + ("\\u0030abc", "\\u0030abc"), + ("\\u203Fa", "\\u203Fa"), + ("\\u00B7a", "\\u00B7a"), + // In no name. + ("a\\u00B2", "a\\u00B2"), + ("a\\u2E2F", "a\\u2E2F"), + ("\\u2E2F", "\\u2E2F"), + // Malformed escapes and a lone surrogate. + ("\\u", "\\u"), + ("a\\u12;", "a\\u12;"), + ("a\\uZZZZ", "a\\uZZZZ"), + ("a\\uD800", "a\\uD800"), + ("", ""), + ] { + assert_eq!(unescaped(src), want, "{src:?}"); + } + let r = report("\\u0061pp.a\\u203Fb;\napp.\\u2118c;\n"); + let members: Vec<&str> = r.members.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(members, ["app.a\u{203F}b", "app.\u{2118}c"]); + // An escape a name may not hold where it stands, or a malformed one, is not part of a name. + for (src, want) in [ + ("\\u0030a", [(T::Other, "\\"), (T::Ident, "u0030a")]), + ("a\\u00B2", [(T::Ident, "a"), (T::Other, "\\")]), + ("a\\u12", [(T::Ident, "a"), (T::Other, "\\")]), + ("a\\uD800", [(T::Ident, "a"), (T::Other, "\\")]), + ] { + assert_eq!(tokens(src).get(..2), Some(&want[..]), "{src:?}"); + } + assert_eq!(tokens("a\\u0030\\u200C"), [(T::Ident, "a\\u0030\\u200C")]); + let r = report("app.\\u0030abc;\napp.a\\u00B2;\n"); + assert!(r.members.iter().all(|m| !m.name.contains('\\') && !m.name.contains("0abc")), "{:?}", r.members); +} + +#[test] +fn malformed_escapes_in_bulk_are_read_once_through() { + let src = format!("{}\n{}\nalert(1);\n", "\\u\\uD800\\u12\\u0030\\u2118".repeat(20_000), "a\u{203F}".repeat(20_000)); + let r = report(&src); + assert_eq!(r.lines, 3); + assert_eq!(feature(&r, "notify").map(|f| f.lines.as_slice()), Some(&[3][..])); + assert!(r.steps <= 5 * src.len() + 16, "{} steps for {} bytes", r.steps, src.len()); +} + #[test] fn the_scripts_own_platform_names_are_not_the_platforms() { let r = report(concat!( diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 129bd83e2..746ab84d2 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -633,6 +633,10 @@ impl Writer<'_> { /// An object, inside `` when it links to a URL (Attributes panel). pub(super) fn node(&mut self, n: &Node) { + // Ruler guides are not written, not even as a link. + if n.is_ruler_guide() { + return; + } let layer = usize::from(n.is_layer()); self.layer_nest += layer; match n.url().filter(|_| n.visible && !self.anonymous) { @@ -717,7 +721,7 @@ impl Writer<'_> { self.line(""); } } - NodeKind::Path { guide: true, .. } => {} + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => {} NodeKind::Path { path, rule, .. } => { if path.is_empty() { return; diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index fb06f8005..4f91d6c97 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -170,6 +170,10 @@ impl Writer<'_> { } let mut named: Vec<(NodeId, String)> = Vec::new(); self.doc.walk(|n| { + // Ruler guides are not written, so their names reserve no id. + if n.is_ruler_guide() { + return; + } if let Some(name) = &n.name { named.push((n.id, sanitize_id(name))); } diff --git a/crates/svg/src/glyph.rs b/crates/svg/src/glyph.rs index c019f9b0a..22697f58a 100644 --- a/crates/svg/src/glyph.rs +++ b/crates/svg/src/glyph.rs @@ -44,18 +44,20 @@ pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Opti // usvg resolves `context-fill` and `context-stroke` only for markers. let svg = svg.replace("context-fill", &hex(fill)).replace("context-stroke", &stroke.map_or_else(|| "none".into(), hex)); let (doc, _) = crate::import::import(&svg, &ImportOptions::default(), crate::MAX_NODES).ok()?; + // A layer's art, its ruler guides left out. + let art_of = |l: &Node| l.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).cloned().collect::>(); let mut art = if whole { - Node::group(NodeId(0), doc.layers.iter().map(|l| Arc::new(Node::group(NodeId(0), l.children().cloned().unwrap_or_default()))).collect()) + Node::group(NodeId(0), doc.layers.iter().map(|l| Arc::new(Node::group(NodeId(0), art_of(l)))).collect()) } else { // The glyph, top-level, is a layer when a ``, else an object named after its id. let named = |n: &Node| n.name.as_deref() == Some(id.as_str()); match doc.layers.iter().find(|l| named(l)) { - Some(layer) => Node::group(NodeId(0), layer.children().cloned().unwrap_or_default()), + Some(layer) => Node::group(NodeId(0), art_of(layer)), None => { let mut found = None; for layer in &doc.layers { layer.walk(&mut |n| { - if found.is_none() && named(n) { + if found.is_none() && !n.is_ruler_guide() && named(n) { found = Some(n.clone()); } }); @@ -199,7 +201,7 @@ fn own_art(n: &Node, doc: &Document) -> Option { im.pixels = Some(Arc::new(blob.clone())); } if let Some(children) = n.children_mut() { - *children = children.iter().filter_map(|c| own_art(c, doc).map(Arc::new)).collect(); + *children = children.iter().filter(|c| !c.is_ruler_guide()).filter_map(|c| own_art(c, doc).map(Arc::new)).collect(); } Some(n) } diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index 56efe7b3a..ca31f28b8 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -500,20 +500,23 @@ impl Importer { } /// Put `art` in layer `l` at `index` (at most its end) unclipped: a clipping mask on the layer - /// becomes a clip group in it, holding the art it clipped. + /// becomes a clip group in it, holding the art it clipped. The layer's ruler guides stay last, + /// outside the clip group. pub(super) fn add_unclipped(&mut self, l: &mut Node, index: usize, art: Vec>) { if art.is_empty() { return; } if let NodeKind::Layer { children, clip: clip @ true, .. } = &mut l.kind { *clip = false; - let clipped = std::mem::take(children); + let mut clipped = std::mem::take(children); + let guides = clipped.split_off(Node::guide_run_start(&clipped)); if let Some(group) = self.named("", NodeKind::Group { children: clipped, clip: true }) { children.push(Arc::new(group)); } + children.extend(guides); } if let Some(ch) = l.children_mut() { - let i = index.min(ch.len()); + let i = index.min(Node::guide_run_start(ch)); ch.splice(i..i, art); } } diff --git a/crates/svg/tests/it/ids.rs b/crates/svg/tests/it/ids.rs index 0781397fa..c41dd4bcf 100644 --- a/crates/svg/tests/it/ids.rs +++ b/crates/svg/tests/it/ids.rs @@ -159,3 +159,21 @@ fn unique_ids_differ_when_only_gradient_stops_differ() { assert!(!ids1.is_empty() && !ids2.is_empty()); assert!(ids1.iter().all(|id| !ids2.contains(id)), "IDs must not collide: {ids1:?} vs {ids2:?}"); } + +/// Ruler guides on each layer, named as a layer and an object above them are, with a URL, are not +/// written: the SVG is the same as without them, with every kind of object id. +#[test] +fn ruler_guides_are_not_written_and_take_no_id() { + let plain = doc("a", 0.0); + let mut guided = plain.clone(); + for (i, (l, name)) in [(guided.layers[0].id, "Top & Front"), (guided.layers[1].id, "Logo mark (v2)")].into_iter().enumerate() { + let mut g = Node::new(guided.alloc_id(), vectorcraft_doc::NodeKind::RulerGuide { vertical: i == 0, pos: 30.0, artboard: None }); + g.name = Some(name.into()); + g.edit_attrs(|a| a.url = "https://example.com/".into()); + guided.insert(Some(l), usize::MAX, g).unwrap(); + } + for object_ids in [ObjectIds::LayerNames, ObjectIds::Minimal, ObjectIds::Unique] { + let opts = ExportOptions { object_ids, ..Default::default() }; + assert_eq!(export(&guided, &opts), export(&plain, &opts), "{object_ids:?}"); + } +} diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs index 656bf4a8b..8deb84a45 100644 --- a/crates/testkit/src/fixtures.rs +++ b/crates/testkit/src/fixtures.rs @@ -263,20 +263,20 @@ impl DocBuilder { } } -/// All non-layer nodes of a document in paint order. +/// All nodes of a document other than layers and ruler guides, in paint order. pub fn art_nodes(doc: &Document) -> Vec<&Node> { let mut v = Vec::new(); doc.walk(|n| { - if !n.is_layer() { + if !n.is_layer() && !n.is_ruler_guide() { v.push(n) } }); v } -/// Ids of the top-level art (direct children of layers), bottom first. +/// Ids of the top-level art (direct children of layers other than ruler guides), bottom first. pub fn top_level_ids(doc: &Document) -> Vec { - doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().map(|n| n.id)).collect() + doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().filter(|n| !n.is_ruler_guide()).map(|n| n.id)).collect() } /// Every node id (layers included), paint order. diff --git a/crates/testkit/src/invariants.rs b/crates/testkit/src/invariants.rs index c03b33dd5..8791acf42 100644 --- a/crates/testkit/src/invariants.rs +++ b/crates/testkit/src/invariants.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, NodeKind}; +use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_engine::Session; /// Serialize a document to JSON (the canonical form for equality checks). @@ -11,7 +11,8 @@ pub fn doc_json(doc: &Document) -> Value { serde_json::to_value(doc).expect("document serializes") } -/// Tree invariants: ids unique, `node_count` equals the number of walked nodes, `next_id` above +/// Tree invariants: ids unique, `node_count` equals the number of walked nodes other than ruler +/// guides, ruler guides only at the top of a layer, `next_id` above /// every id, top level made of layers, layers only at the top or inside layers, clip groups have /// a first child, compound children are paths, no image holds its own pixels (the document keeps /// them). @@ -21,11 +22,21 @@ pub fn check_document(doc: &Document) -> Result<(), String> { let mut max = 0u64; let mut err = None; doc.walk(|n| { - walked += 1; + // `node_count` counts objects: ruler guides are left out of it. + walked += usize::from(!n.is_ruler_guide()); max = max.max(n.id.0); if !seen.insert(n.id) && err.is_none() { err = Some(format!("duplicate id {}", n.id)); } + let guides = n.children().map_or(0, |ch| ch.iter().filter(|c| c.is_ruler_guide()).count()); + if guides > 0 && err.is_none() { + let ch = n.children().map(Vec::as_slice).unwrap_or_default(); + if !n.is_layer() { + err = Some(format!("{} holds a ruler guide but is not a layer", n.id)); + } else if ch.len() - Node::guide_run_start(ch) != guides { + err = Some(format!("layer {} has a ruler guide below its art", n.id)); + } + } match &n.kind { NodeKind::Group { children, .. } if children.iter().any(|c| c.is_layer()) && err.is_none() => { err = Some(format!("group {} contains a layer", n.id)); diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index d7d4bb85b..0ac443c73 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -40,7 +40,7 @@ fn a_press_where_a_mask_hides_a_selected_object_drags_it() { #[test] fn direct_and_group_selection_pick_ruler_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 100.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 100.0)); let (s, p) = (Selection::default(), paint()); let c = cx(&d, &s, &p); let down = |t: &mut DirectSelectionTool, y: f64| t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, y)); @@ -62,7 +62,7 @@ fn direct_and_group_selection_pick_ruler_guides() { #[test] fn a_cmd_shift_double_click_releases_the_guide_under_the_pointer() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); let Some(NodeKind::Path { guide, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("the rectangle is a path") }; *guide = true; let (s, p) = (Selection::default(), paint()); @@ -848,7 +848,7 @@ fn crossed_segments_match_testing_a_path_for_each_segment() { #[test] fn marquee_selects_ruler_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 80.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index c50889d03..41b9f517e 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -579,7 +579,7 @@ impl Targets { if cx.guides { self.rulers = cx .doc - .guides + .ruler_guides() .iter() .filter(|g| cx.doc.guide_shown(g)) .map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }) diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index c34f9ab7d..a4f3bc49f 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -442,9 +442,9 @@ fn drawn_points_land_on_intersections() { let id = d.alloc_id(); d.insert(Some(l), 1, Node::path(id, path, Appearance::default_art())).unwrap(); } - d.guides.push(vectorcraft_doc::Guide::new(true, 400.0)); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); - d.guides.push(vectorcraft_doc::Guide::new(false, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 400.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 150.0)); let (s, p) = (Selection::default(), paint()); let c = ToolContext { guides: true, ..cx(&d, &s, &p) }; let t = Targets::for_drawing(&d, &[], None, None).styled(&c).with_rulers(&c); @@ -612,8 +612,8 @@ fn bisection_finds_what_a_scan_finds() { #[test] fn drawn_points_snap_to_ruler_guides_with_smart_guides_on() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 313.0)); - d.guides.push(vectorcraft_doc::Guide::new(true, 331.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 313.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 331.0)); let (s, p) = (Selection::default(), paint()); let c = cx(&d, &s, &p); let mut snap = DrawSnap::default(); diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index 329776938..97d8bed28 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -12,27 +12,34 @@ //! or locked layer, can't be picked. use serde_json::json; +use vectorcraft_doc::{Guide, NodeId}; use vectorcraft_geom::{Point, Rect}; use crate::guides::Targets; use crate::{Action, Cursor, Overlay, PointerEvent, PointerKind, ToolContext}; -/// The ruler guide under `p`: the nearest within the selection tolerance (the newest of equals). +/// The ruler guide under `p`, by its index in `guide.list`: the nearest within the selection +/// tolerance (the newest of equals). pub fn guide_at(cx: &ToolContext, p: Point) -> Option { + guide_under(cx, p).map(|(i, _)| i) +} + +/// [`guide_at`], with the guide. +fn guide_under(cx: &ToolContext, p: Point) -> Option<(usize, Guide)> { if !cx.guides { return None; } let tol = cx.pick_tol(); let off = |pos: f64, vertical: bool| (if vertical { p.x } else { p.y } - pos).abs(); cx.doc - .guides - .iter() + .ruler_guides() + .into_iter() .enumerate() .filter(|(_, g)| cx.doc.guide_editable(g) && cx.doc.guide_passes(g, p, tol)) - .map(|(i, g)| (i, off(g.pos, g.vertical))) - .filter(|(_, d)| *d <= tol) + .map(|(i, g)| (i, off(g.pos, g.vertical), g)) + .filter(|(_, d, _)| *d <= tol) .min_by(|a, b| a.1.total_cmp(&b.1).then(b.0.cmp(&a.0))) - .map(|(i, _)| i) + .map(|(i, _, g)| (i, g)) } /// What a selection tool's marquee `r` does to the ruler guides, after its action selecting the @@ -42,19 +49,20 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option { pub fn marquee_select(cx: &ToolContext, r: Rect, enclosed: bool) -> Option { let mut guides = guides_in_rect(cx, r); if enclosed { - guides.retain(|i| cx.doc.guides.get(*i).is_some_and(|g| guide_inside(cx, r, g))); + guides.retain(|(_, g)| guide_inside(cx, r, g)); } + let guides: Vec = guides.into_iter().map(|(i, _)| i).collect(); (!guides.is_empty()).then(|| Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true }))) } -/// The editable ruler guides intersecting marquee rectangle `r`. -fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec { +/// The editable ruler guides intersecting marquee rectangle `r`, with their `guide.list` indexes. +fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec<(usize, Guide)> { if !cx.guides { return vec![]; } cx.doc - .guides - .iter() + .ruler_guides() + .into_iter() .enumerate() .filter(|(_, g)| cx.doc.guide_editable(g)) .filter(|(_, g)| { @@ -64,14 +72,13 @@ fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec { r.min_y() <= g.pos && g.pos <= r.max_y() && cx.doc.guide_span(g).is_none_or(|(x0, x1)| r.max_x() >= x0 && r.min_x() <= x1) } }) - .map(|(i, _)| i) .collect() } /// Does ruler guide `g`, crossed by marquee `r`, run along its line within the marquee? An artboard /// guide does when the marquee spans its artboard; a canvas guide runs across the whole canvas and /// never does. -fn guide_inside(cx: &ToolContext, r: Rect, g: &vectorcraft_doc::Guide) -> bool { +fn guide_inside(cx: &ToolContext, r: Rect, g: &Guide) -> bool { let Some((a, b)) = cx.doc.guide_span(g) else { return false }; let (lo, hi) = if g.vertical { (r.min_y(), r.max_y()) } else { (r.min_x(), r.max_x()) }; lo <= a.min(b) && a.max(b) <= hi @@ -85,6 +92,8 @@ fn guide_cursor(vertical: bool) -> Cursor { #[derive(Clone, Copy, Debug)] struct Drag { index: usize, + /// The guide's node. + id: NodeId, vertical: bool, /// Where the guide was, and where it is now (x of a vertical guide, y of a horizontal one). from: f64, @@ -262,11 +271,12 @@ impl GuideEdit { /// A press `ev` on a ruler guide: it gets selected and the drag starts. None (and nothing /// done) when no guide is there. pub fn press(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { - let index = guide_at(cx, ev.pos)?; - let g = cx.doc.guides.get(index)?; - let (shift, selected) = (ev.mods.shift, cx.selection.guides.contains(&index)); + let (index, g) = guide_under(cx, ev.pos)?; + let id = g.id?; + let (shift, selected) = (ev.mods.shift, cx.selection.guides.contains(&id)); self.drag = Some(Drag { index, + id, vertical: g.vertical, from: g.pos, at: g.pos, @@ -307,7 +317,7 @@ impl GuideEdit { vec![Action::Commit] } else if d.deselect { vec![Action::Exec("guide.select".into(), json!({ "indexes": [d.index], "toggle": true }))] - } else if d.key && cx.selection.key_guide == Some(d.index) { + } else if d.key && cx.selection.key_guide == Some(d.id) { // A click on the key guide again: no key. vec![Action::Exec("select.key".into(), json!({}))] } else if d.key { @@ -349,7 +359,7 @@ impl GuideEdit { if let Some(d) = &self.drag { return Some(guide_cursor(d.vertical).turned(cx.view_angle())); } - guide_at(cx, p).and_then(|i| cx.doc.guides.get(i)).map(|g| guide_cursor(g.vertical).turned(cx.view_angle())) + guide_under(cx, p).map(|(_, g)| guide_cursor(g.vertical).turned(cx.view_angle())) } /// While a guide is dragged: the smart guide it snapped to and its position by the pointer. diff --git a/crates/tools/src/rulerguide/tests.rs b/crates/tools/src/rulerguide/tests.rs index 4b4f1be72..f80f31c77 100644 --- a/crates/tools/src/rulerguide/tests.rs +++ b/crates/tools/src/rulerguide/tests.rs @@ -10,10 +10,16 @@ fn ev(kind: PointerKind, x: f64, y: f64, mods: Mods) -> PointerEvent { fn guides_doc() -> vectorcraft_doc::Document { let (mut d, _) = doc_with_rect(); - d.guides = vec![Guide::new(true, 300.0), Guide::new(false, 400.0)]; + d.add_ruler_guide(&Guide::new(true, 300.0)); + d.add_ruler_guide(&Guide::new(false, 400.0)); d } +/// The node id of ruler guide `i` of `d` (its index in the guide commands). +fn gid(d: &vectorcraft_doc::Document, i: usize) -> vectorcraft_doc::NodeId { + d.ruler_guides()[i].id.unwrap() +} + #[test] fn picks_the_guide_within_the_tolerance_unless_hidden_or_locked() { let (d, s, p) = (guides_doc(), Selection::default(), paint()); @@ -37,7 +43,7 @@ fn a_drag_moves_the_selected_guides_in_one_step() { assert_eq!(g.press(&c, &ev(PointerKind::Down, 300.0, 50.0, m)), Some(vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))])); // The tool sees the guide selected from now on. let mut s = s; - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); let c = cx(&d, &s, &p); let a = g.pointer(&c, &ev(PointerKind::Drag, 340.0, 60.0, m)).unwrap(); assert_eq!(a[0], Action::Begin("Move Guide".into())); @@ -52,7 +58,7 @@ fn a_drag_moves_the_selected_guides_in_one_step() { fn the_dragged_guide_snaps_to_art_ruler_ticks_and_the_grid() { let d = guides_doc(); let mut s = Selection::default(); - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); let p = paint(); let drag_to = |c: &ToolContext, x: f64, mods: Mods| { let mut g = GuideEdit::default(); @@ -253,7 +259,7 @@ fn new_ruler_guide_alt_snaps_on_the_effective_axis() { fn with_smart_guides_off_snap_to_point_pulls_guides_onto_anchors() { let d = guides_doc(); let mut s = Selection::default(); - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); let p = paint(); let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; let moved_to = |c: &ToolContext, x: f64| { @@ -291,7 +297,7 @@ fn an_artboard_guide_is_picked_and_snapped_to_across_its_artboard_only() { color: None, guide_label: None, }); - d.guides = vec![Guide { artboard: Some(id), ..Guide::new(false, 150.0) }]; + d.add_ruler_guide(&Guide { artboard: Some(id), ..Guide::new(false, 150.0) }); let (s, p) = (Selection::default(), paint()); let c = cx(&d, &s, &p); assert_eq!(guide_at(&c, Point::new(700.0, 151.0)), Some(0)); @@ -306,7 +312,7 @@ fn an_artboard_guide_is_picked_and_snapped_to_across_its_artboard_only() { fn shift_click_toggles_and_alt_drag_copies() { let d = guides_doc(); let mut s = Selection::default(); - s.set_guides([0, 1]); + s.set_guides([gid(&d, 0), gid(&d, 1)]); let p = paint(); let c = cx(&d, &s, &p); let (shift, alt) = (Mods { shift: true, ..Default::default() }, Mods { alt: true, ..Default::default() }); @@ -336,20 +342,20 @@ fn a_click_on_a_guide_selected_with_objects_makes_it_the_key() { }; let mut s = Selection::default(); s.set([rect]); - s.toggle_guide(0); + s.toggle_guide(gid(&d, 0)); let make = Action::Exec("select.key".into(), json!({"guide": 0})); assert_eq!(click(&s, Mods::default()), vec![make]); - s.key_guide = Some(0); + s.key_guide = Some(gid(&d, 0)); assert_eq!(click(&s, Mods::default()), vec![Action::Exec("select.key".into(), json!({}))], "the key guide again"); let shift = Mods { shift: true, ..Default::default() }; assert_eq!(click(&s, shift), vec![Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true}))]); - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); assert_eq!(click(&s, Mods::default()), vec![], "a guide alone has nothing to align"); // Not selected yet: the first click selects it. s.set([rect]); assert_eq!(click(&s, Mods::default()), vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]); // A drag moves it. - s.toggle_guide(0); + s.toggle_guide(gid(&d, 0)); let c = cx(&d, &s, &p); let mut g = GuideEdit::default(); g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, Mods::default())); @@ -362,7 +368,7 @@ fn dropped_on_its_ruler_the_guide_is_deleted() { let d = guides_doc(); let p = paint(); let mut s = Selection::default(); - s.set_guides([1]); + s.set_guides([gid(&d, 1)]); // The window shows the document from (0, 0) at 100 %: above y = 0 is the top ruler. let screen = ScreenFrame { origin: Point::ZERO, right: Vec2::new(1.0, 0.0), down: Vec2::new(0.0, 1.0), size: (800.0, 600.0) }; let c = ToolContext { screen: Some(screen), ..cx(&d, &s, &p) }; @@ -457,7 +463,7 @@ fn ruler_guide_shift_move_uses_document_origin() { let paint = paint(); for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let c = ToolContext { unit, zoom, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; for copy in [false, true] { let mut guide = GuideEdit::default(); @@ -498,7 +504,7 @@ fn ruler_guide_shift_zero_and_invalid_origins_use_zero() { let paint = paint(); for (index, vertical, from, want) in [(0, true, 300.0, 225.0), (1, false, 400.0, 235.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; let mods = Mods { shift: true, ..Default::default() }; let mut new = NewGuide::new(&c, vertical, None); @@ -522,7 +528,7 @@ fn ruler_guide_origin_leaves_other_snap_modes_unchanged() { let paint = paint(); for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let off = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; for (c, pointer, expected) in [ (ToolContext { ..off }, Point::new(226.4, 237.6), Point::new(226.4, 237.6)), @@ -561,7 +567,7 @@ fn ruler_guide_origin_readouts_are_relative_for_new_and_moved_guides() { for unit in [vectorcraft_doc::Unit::Points, vectorcraft_doc::Unit::Millimeters] { for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let c = ToolContext { unit, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; let want = format!("{}: {}", if vertical { "X" } else { "Y" }, c.len(if vertical { expected.x } else { expected.y })); let check = |overlays: Vec| { diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 67f5c91b5..9069cda93 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -502,7 +502,7 @@ fn click_selects_object() { #[test] fn a_press_on_a_ruler_guide_picks_it_over_the_art() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 150.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -524,7 +524,7 @@ fn a_press_on_a_ruler_guide_picks_it_over_the_art() { #[test] fn a_cmd_shift_double_click_releases_the_guide_under_the_pointer() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); let Some(vectorcraft_doc::NodeKind::Path { guide, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("the rectangle is a path") }; *guide = true; let s = Selection::default(); @@ -679,7 +679,7 @@ fn box_cursors_turn_with_the_view() { let mut s = Selection::default(); s.add(id); let p = paint(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); let mut c = cx(&d, &s, &p); c.screen = turned_view(90.0); assert!((c.view_angle() - 90.0).abs() < 1e-9); @@ -752,7 +752,7 @@ fn escape_cancels_a_move_or_an_alt_drag_copy() { #[test] fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() { let (mut d, id) = doc_with_rect(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 173.4)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 173.4)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -775,7 +775,7 @@ fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() { #[test] fn a_shift_move_lands_the_edges_on_a_ruler_guide() { let (mut d, id) = doc_with_rect(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 333.0)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 333.0)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -1118,7 +1118,7 @@ fn a_shift_move_snapped_by_its_grabbed_point_stays_on_its_line() { let other = d.alloc_id(); let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 151.5, 350.0, 201.5)); d.insert(Some(l), 1, vectorcraft_doc::Node::path(other, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 420.5)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 420.5)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -1132,7 +1132,7 @@ fn a_shift_move_snapped_by_its_grabbed_point_stays_on_its_line() { assert!(moved_by(m, (270.5, 270.5)), "{m:?}"); // Smart Guides on, the selection alone with a ruler guide 1.5 below the grabbed point. let (mut d, id) = doc_with_rect(); - d.guides = vec![vectorcraft_doc::Guide::new(false, 330.5)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 330.5)); let mut s = Selection::default(); s.add(id); let (m, _) = shift_moved(&cx(&d, &s, &p), (329.0, 329.0)); @@ -1259,7 +1259,7 @@ fn a_click_on_a_selected_object_makes_it_the_key() { let mut one = Selection::default(); one.add(rect); assert!(click(&one, 150.0, 150.0).is_empty()); - one.guides = vec![0]; + one.guides = vec![vectorcraft_doc::NodeId(999)]; assert_eq!(click(&one, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({"id": rect.0}))]); } @@ -1482,8 +1482,8 @@ fn directional_marquee_uses_screen_direction_and_a_dead_zone() { fn e_during_a_marquee_takes_only_the_guides_inside() { let (mut d, id) = doc_with_rect(); let board = d.artboards[0].id; - d.guides.push(vectorcraft_doc::Guide::new(true, 150.0)); - d.guides.push(vectorcraft_doc::Guide { artboard: Some(board), ..vectorcraft_doc::Guide::new(false, 150.0) }); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide { artboard: Some(board), ..vectorcraft_doc::Guide::new(false, 150.0) }); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -1506,7 +1506,7 @@ fn e_during_a_marquee_takes_only_the_guides_inside() { #[test] fn marquee_selects_ruler_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 80.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -1523,7 +1523,7 @@ fn marquee_selects_ruler_guides() { #[test] fn marquee_selects_only_guide() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 20.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 20.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -1540,7 +1540,7 @@ fn marquee_selects_only_guide() { #[test] fn marquee_ignores_locked_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 80.0)); let s = Selection::default(); let p = paint(); let mut cx_locked = cx(&d, &s, &p); @@ -1769,7 +1769,7 @@ fn handle_drag_snaps_to_anchors_and_ruler_guides_with_snap_to_point() { let other = d.alloc_id(); let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 300.0, 300.0)); d.insert(Some(l), 1, Node::path(other, sq, Appearance::default_art())).unwrap(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 230.5)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 230.5)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -2436,7 +2436,7 @@ fn a_thin_shapes_centre_point_gives_the_handles_the_pointer() { #[test] fn a_ruler_guide_under_the_centre_point_keeps_the_pointer() { let (mut d, id) = doc_with_live_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 150.0)); let mut s = Selection::default(); s.add(id); let p = paint(); diff --git a/crates/tools/src/slice.rs b/crates/tools/src/slice.rs index 8b092d8ad..69bbf9051 100644 --- a/crates/tools/src/slice.rs +++ b/crates/tools/src/slice.rs @@ -41,9 +41,9 @@ fn has_area(r: Rect) -> bool { fn snap(cx: &ToolContext, p: Point) -> (Point, Vec) { let (mut q, ov) = crate::guides::snap_draw(cx, p, &[]); let tol = cx.tol(5.0); + let guides = cx.doc.ruler_guides(); let nearest = |vertical: bool, v: f64| { - cx.doc - .guides + guides .iter() .filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_shown(g) && cx.doc.guide_passes(g, p, tol)) .min_by(|a, b| (a.pos - v).abs().total_cmp(&(b.pos - v).abs())) @@ -390,7 +390,7 @@ mod tests { #[test] fn the_slice_tool_drags_out_a_slice_in_one_undo_step() { let (mut d, _) = doc_with_rect(); - d.guides.push(Guide::new(true, 403.0)); + d.add_ruler_guide(&Guide::new(true, 403.0)); let s = Selection::default(); let p = paint(); let mut c = cx(&d, &s, &p); @@ -557,7 +557,7 @@ mod tests { assert!(near(resize(&neither, (60.0, 25.0), (97.0, 25.0), none), Rect::new(10.0, 10.0, 97.0, 40.0))); // A ruler guide at x = 98 takes the side dragged 1 px from it, over the rectangle's edge // 1 px the other way, with Smart Guides on or off. - d.guides.push(Guide::new(true, 98.0)); + d.add_ruler_guide(&Guide::new(true, 98.0)); for c in [cx(&d, &sel, &p), ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &sel, &p) }] { assert!(near(resize(&c, (60.0, 25.0), (99.0, 25.0), none), Rect::new(10.0, 10.0, 98.0, 40.0))); } diff --git a/crates/tools/src/xform/measure.rs b/crates/tools/src/xform/measure.rs index e3a009056..cccb1ebca 100644 --- a/crates/tools/src/xform/measure.rs +++ b/crates/tools/src/xform/measure.rs @@ -281,7 +281,7 @@ mod tests { #[test] fn shift_drag_meets_a_ruler_guide() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 50.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 50.0)); let s = Selection::default(); let p = paint(); let mut cx = cx(&d, &s, &p); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 56f121ad6..50136f1a9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1119,11 +1119,12 @@ fn guides_of(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, (painter, let look = LineLook::guides(&app.session.prefs, t); let picked = LineLook { color: t.selection, ..look }; let labels = guide_labels(doc); - let selected: &[usize] = &st.selection.guides; + let selected: &[vectorcraft_doc::NodeId] = &st.selection.guides; let key = st.selection.key_guide; let view = view_bounds(xf); // Guides on a hidden layer hide with it. - for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) { + for g in doc.ruler_guides().iter().filter(|g| doc.guide_shown(g)) { + let Some(i) = g.id else { continue }; let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) }); // An artboard guide runs across its artboard, a canvas guide across the window (the part // of its document line that the canvas shows, turned with the view). diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 81008bb0b..efe59c127 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1192,7 +1192,7 @@ fn a_drag_from_a_ruler_onto_the_canvas_makes_a_guide() { frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); frame(app, &ctx, vec![button(to, false)]); }; - let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); + let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect::>(); // From the top ruler: a horizontal guide where the button was released. let to = pos2(rect.center().x, rect.top() + 120.0); drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), to); @@ -1234,8 +1234,8 @@ fn artboard_guide_label_colors_only_its_own_guides() { { let st = app.session.active_mut().unwrap(); let doc = std::sync::Arc::make_mut(&mut st.doc); - let last = doc.guides.len() - 1; - doc.guides[last].artboard = Some(gone); + let last = doc.ruler_guides().last().and_then(|g| g.id).unwrap(); + assert!(doc.edit_ruler_guide(last, |g| g.artboard = Some(gone))); } app.ui.view.guides = true; let ctx = egui::Context::default(); @@ -1304,7 +1304,7 @@ fn a_guide_dragged_out_of_a_ruler_shows_while_dragged() { }; assert!(shapes(&mut app, &ctx).iter().any(across), "real-time drawing {real_time}: the guide shows while dragged"); frame(&mut app, &ctx, vec![button(at, false)]); - assert_eq!(app.session.active().unwrap().doc.guides.len(), 1, "real-time drawing {real_time}: released, it is made"); + assert_eq!(app.session.active().unwrap().doc.ruler_guides().len(), 1, "real-time drawing {real_time}: released, it is made"); } } @@ -1383,7 +1383,7 @@ fn new_ruler_guides_follow_alt_through_preview_release_and_undo() { frame(&mut app, &ctx, vec![button(from, true, initially_alt)]); frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); let assert_guide = |app: &VectorcraftApp, alt: bool, pos: Point| { - let guides = &app.session.active().unwrap().doc.guides; + let guides = &app.session.active().unwrap().doc.ruler_guides(); assert_eq!(guides.len(), 1); assert_eq!(guides[0].vertical, source_vertical != alt); let expected = if source_vertical != alt { pos.x } else { pos.y }; @@ -1463,7 +1463,7 @@ fn new_ruler_guide_alt_cancel_keeps_document_and_redo() { frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(from)]); frame(&mut app, &ctx, vec![button(from, true)]); frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); - let guides = &app.session.active().unwrap().doc.guides; + let guides = &app.session.active().unwrap().doc.ruler_guides(); assert_eq!(guides.len(), 1); assert_eq!(guides[0].vertical, !source_vertical); let artboard = (tool == "artboard").then_some(before.artboards[0].id); @@ -1474,7 +1474,7 @@ fn new_ruler_guide_alt_cancel_keeps_document_and_redo() { assert_eq!(history(&app), before_history); app.session.execute("edit.redo", &json!({})).unwrap(); assert!(app.session.active().unwrap().doc.art_bounds().is_some()); - assert!(app.session.active().unwrap().doc.guides.is_empty()); + assert!(app.session.active().unwrap().doc.ruler_guides().is_empty()); } } } @@ -1513,14 +1513,14 @@ fn cmd_ruler_drags_make_artboard_guides_with_the_preference_on() { let board = app.session.active().unwrap().doc.artboards[0].id; drag(&mut app, Point::new(80.0, 120.0)); let st = app.session.active().unwrap(); - assert_eq!(st.doc.guides.len(), 1, "vertical {source_vertical}, on {on}"); - assert_eq!(st.doc.guides[0].vertical, source_vertical); - assert_eq!(st.doc.guides[0].artboard, on.then_some(board), "vertical {source_vertical}, on {on}"); + assert_eq!(st.doc.ruler_guides().len(), 1, "vertical {source_vertical}, on {on}"); + assert_eq!(st.doc.ruler_guides()[0].vertical, source_vertical); + assert_eq!(st.doc.ruler_guides()[0].artboard, on.then_some(board), "vertical {source_vertical}, on {on}"); // Beyond the artboard's right or bottom edge. let beyond = xf.to_doc(rect.right_bottom() - egui::vec2(2.0, 2.0)); assert!(beyond.x > 400.0 && beyond.y > 300.0, "{beyond:?}"); drag(&mut app, beyond); - assert_eq!(app.session.active().unwrap().doc.guides.len(), if on { 1 } else { 2 }, "vertical {source_vertical}, on {on}"); + assert_eq!(app.session.active().unwrap().doc.ruler_guides().len(), if on { 1 } else { 2 }, "vertical {source_vertical}, on {on}"); } } } @@ -1547,7 +1547,7 @@ fn guides_from_the_rulers_snap_and_artboard_guides_span_their_artboard() { frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); frame(app, &ctx, vec![button(to, false)]); }; - let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); + let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect::>(); // 3 px off the middle of the top and bottom sides: onto the line through them. let mid = xf.to_screen(Point::new(200.0, 150.0)); drag(&mut app, pos2(rect.left() - RULER / 2.0, mid.y), mid + vec2(3.0, 40.0)); @@ -1596,7 +1596,7 @@ fn the_selection_tool_moves_and_deletes_ruler_guides() { frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); frame(app, &ctx, vec![button(to, false)]); }; - let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); + let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect::>(); // Two guides out of the rulers. let (h, v) = (pos2(rect.center().x + 30.0, rect.center().y + 20.0), pos2(rect.center().x - 40.0, rect.center().y)); drag(&mut app, pos2(h.x, rect.top() - RULER / 2.0), h); @@ -1607,7 +1607,7 @@ fn the_selection_tool_moves_and_deletes_ruler_guides() { drag(&mut app, h, to); let st = app.session.active().unwrap(); assert_eq!(guides(&app), [(false, xf.to_doc(to).y), (true, xf.to_doc(v).x)]); - assert_eq!((st.selection.guides.clone(), st.selection.objects.clone()), (vec![0], vec![])); + assert_eq!((vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides), st.selection.objects.clone()), (vec![0], vec![])); assert_eq!(st.history.undo.last().unwrap().label, "Move Guide"); let picked = Tokens::get(&ctx).selection; let s = shapes(&mut app, &ctx); @@ -9512,7 +9512,7 @@ fn ruler_guide_origin_ui(alt: bool, cancel: bool) { frame(&mut app, &ctx, vec![button(from, true)]); frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); let check = |app: &VectorcraftApp, switched: bool, expected: Point| { - let guides = &app.session.active().unwrap().doc.guides; + let guides = &app.session.active().unwrap().doc.ruler_guides(); assert_eq!(guides.len(), 1); let vertical = source_vertical != switched; assert_eq!(guides[0].vertical, vertical); @@ -9522,7 +9522,7 @@ fn ruler_guide_origin_ui(alt: bool, cancel: bool) { assert_eq!(history(&app).0, original_history.0, "preview has no undo entry"); if cancel { // Cancellation is a baseline positive control, independent of snap correctness. - assert_eq!(app.session.active().unwrap().doc.guides.len(), 1); + assert_eq!(app.session.active().unwrap().doc.ruler_guides().len(), 1); frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, false)]); assert_eq!(app.session.active().unwrap().doc, before); assert_eq!(app.session.active().unwrap().selection, selection); @@ -9543,7 +9543,17 @@ fn ruler_guide_origin_ui(alt: bool, cancel: bool) { check(&app, alt, release); let st = app.session.active().unwrap(); assert_eq!(st.doc.ruler_origin_for(0), origin); - assert_eq!(st.doc.layers, before.layers, "the rectangle tool draws no artwork"); + let art = |d: &vectorcraft_doc::Document| { + d.layers + .iter() + .map(|l| { + let mut l = vectorcraft_doc::Node::clone(l); + l.strip_ruler_guides(); + l + }) + .collect::>() + }; + assert_eq!(art(&st.doc), art(&before), "the rectangle tool draws no artwork"); assert_eq!(st.selection, selection); assert_eq!(history(&app), (original_history.0 + 1, 0, true)); assert_eq!(st.history.undo.last().unwrap().label, "New Guide"); @@ -9621,8 +9631,9 @@ fn ruler_guide_origin_tooltip_matches_selected_guide_fields() { frame(&mut app, &ctx, vec![Event::PointerMoved(from)]); frame(&mut app, &ctx, vec![button(from, true)]); frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); - assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4); - assert_eq!(app.session.active().unwrap().selection.guides, vec![0]); + assert!((app.session.active().unwrap().doc.ruler_guides()[0].pos - target).abs() < 1e-4); + let st = app.session.active().unwrap(); + assert_eq!(vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides), vec![0]); // Observe the real selected-guide field; no expected strings are inserted into UI state. let fields = egui::Context::default(); crate::theme::install_fonts(&fields); @@ -9652,7 +9663,7 @@ fn ruler_guide_origin_tooltip_matches_selected_guide_fields() { assert_eq!(app.session.active().unwrap().history.undo.len(), undo); frame(&mut app, &ctx, vec![button(to, false)]); assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); - assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4, "stored coordinate remains absolute"); + assert!((app.session.active().unwrap().doc.ruler_guides()[0].pos - target).abs() < 1e-4, "stored coordinate remains absolute"); app.run("edit.undo", json!({})).unwrap(); assert_eq!(app.session.active().unwrap().doc, before); } diff --git a/crates/ui-egui/src/chrome/hints.rs b/crates/ui-egui/src/chrome/hints.rs index 062059e6d..02f34438a 100644 --- a/crates/ui-egui/src/chrome/hints.rs +++ b/crates/ui-egui/src/chrome/hints.rs @@ -1,4 +1,4 @@ -use super::status_bar::ANIMATED_ZOOM_HINT; +use super::status_bar::{ANIMATED_ZOOM_HINT, clipped_label}; use super::{Cow, Sense, Stroke, Tokens, Ui, VectorcraftApp, menus, theme, vec2, widgets}; fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { @@ -249,7 +249,7 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let font = if bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) }; job.append(&txt, 0.0, egui::TextFormat { font_id: font, color: if bold { t.text_strong } else { t.text }, ..Default::default() }); } - ui.label(job); + clipped_label(ui, job); }); }); widgets::set_menu_floor(ui.ctx(), panel.response.rect.top()); diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 899508021..1ed8df873 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -80,13 +80,48 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown } }) .unwrap_or(""); - ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim)); + // The tool name leaves the status message its least width and ends in an ellipsis first. + let room = (ui.available_width() - MESSAGE_MIN - ui.spacing().item_spacing.x).max(0.0); + ui.scope(|ui| { + ui.set_max_width(room); + clipped_label(ui, egui::RichText::new(tool).size(11.5).color(t.text_dim)); + }); status_readouts(app, ui, &t); }); }); widgets::set_menu_floor(ui.ctx(), panel.response.rect.top()); } +/// The least width the status message keeps when the window is narrow. +const MESSAGE_MIN: f32 = 180.0; + +/// A label that ends in an ellipsis when it is wider than the room left in `ui`. Only a cut label +/// shows its full text in a tooltip; screen readers always receive the full text. +pub(super) fn clipped_label(ui: &mut Ui, text: impl Into) -> egui::Response { + let galley = text.into().into_galley(ui, Some(egui::TextWrapMode::Truncate), ui.available_width(), egui::FontSelection::Default); + let full = galley.elided.then(|| egui::text::LayoutJob { + sections: galley.job.sections.clone(), + text: galley.job.text.clone(), + language: galley.job.language.clone(), + ..Default::default() + }); + let response = ui.add(egui::Label::new(galley).show_tooltip_when_elided(false)); + match full { + Some(job) => full_text_tooltip(response, job), + None => response, + } +} + +/// Show `text` on hover in a tooltip that wraps to fit inside the window. +fn full_text_tooltip(response: egui::Response, text: impl Into) -> egui::Response { + let text = text.into(); + response.on_hover_ui(|ui| { + let margins = ui.spacing().menu_margin.sum().x + 2.0 * ui.spacing().item_spacing.x; + ui.set_max_width(ui.spacing().tooltip_width.min(ui.ctx().content_rect().width() - margins).max(0.0)); + ui.add(egui::Label::new(text).wrap()); + }) +} + /// The most recently measured status widths, scoped to this UI and display settings. pub(super) struct StatusReadoutWidths { pub(super) key: (crate::i18n::Lang, vectorcraft_doc::Unit, f32), @@ -125,7 +160,6 @@ fn status_readout_widths(ui: &Ui, lang: crate::i18n::Lang, unit: vectorcraft_doc /// Allocate the remaining row once, then paint each field inside its own bounded rectangle. /// Child UIs requested with a desired width can shrink to their contents; these rectangles cannot. pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { - const MESSAGE_MIN: f32 = 180.0; let mono = theme::mono(10.5); let proportional = egui::FontId::proportional(10.5); let unit = app.session.general_unit(); @@ -229,7 +263,7 @@ fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egu let response = ui.interact(rect, ui.scope_id().with(("status-field", id)), Sense::hover()); response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &text)); if elided { - response.on_hover_text(text); + full_text_tooltip(response, text); } } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 452fec1cc..745c477c7 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -64,6 +64,18 @@ use crate::theme::Tokens; use serde_json::json; use vectorcraft_engine::Session; +/// `layers` without their ruler guides: the art. +fn art_of(layers: &[Arc]) -> Vec { + layers + .iter() + .map(|l| { + let mut l = vectorcraft_doc::Node::clone(l); + l.strip_ruler_guides(); + l + }) + .collect() +} + #[test] fn help_bar_and_status_bar_render_independently() { for (help, status, screen_mode) in [(true, true, 0), (false, true, 0), (true, false, 0), (false, false, 0), (true, true, 3)] { @@ -163,22 +175,7 @@ fn status_readout_frame(app: &mut crate::VectorcraftApp, ctx: &egui::Context, wi /// Collect status-bar text, positions, clips and elision state with explicit pointer events. fn status_readout_frame_events(app: &mut crate::VectorcraftApp, ctx: &egui::Context, width: f32, events: Vec) -> Vec { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::status_bar(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::Text(t) => Some(StatusText { - text: t.galley.job.text.clone(), - at: t.pos, - clip: s.clip_rect, - bounds: egui::Rect::from_min_size(t.pos, t.galley.size()), - elided: t.galley.elided, - }), - _ => None, - }) - .collect() + bar_frame(app, ctx, width, None, events, super::status_bar).0 } /// Find a required text record by prefix, failing the test when it is absent. @@ -373,6 +370,127 @@ fn narrow_status_bars_preserve_the_message_and_hide_optional_readouts() { assert!(message.clip.left() >= status_text(&texts, "Selection").bounds.right()); } +/// A function that draws one of the bars at the bottom of the window. +type DrawBar = fn(&mut crate::VectorcraftApp, &mut egui::Ui); + +/// The texts (positions, clips and elision state) and the screen-reader tree of one frame of +/// `draw` in a window `width` points wide, at `time` with `events`. +fn bar_frame( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + width: f32, + time: Option, + events: Vec, + draw: DrawBar, +) -> (Vec, Vec) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)); + let input = egui::RawInput { screen_rect: Some(screen), time, events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| draw(app, ui)); + out.textures_delta.clear(); + let texts = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some(StatusText { + text: t.galley.job.text.clone(), + at: t.pos, + clip: s.clip_rect, + bounds: egui::Rect::from_min_size(t.pos, t.galley.size()), + elided: t.galley.elided, + }), + _ => None, + }) + .collect(); + let nodes = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); + (texts, nodes) +} + +/// The texts of `draw` with the pointer away, the screen-reader tree, and the texts after the +/// pointer has rested on the first text that starts with `prefix` for longer than the tooltip delay. +fn bar_hover( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + width: f32, + prefix: &str, + draw: DrawBar, +) -> (Vec, Vec, Vec) { + let t = ctx.input(|i| i.time) + 1.0; + bar_frame(app, ctx, width, Some(t), vec![egui::Event::PointerGone], draw); + let (texts, nodes) = bar_frame(app, ctx, width, Some(t + 0.1), vec![], draw); + let at = status_text(&texts, prefix); + let pos = at.clip.intersect(at.bounds).center(); + bar_frame(app, ctx, width, Some(t + 1.0), vec![egui::Event::PointerMoved(pos)], draw); + bar_frame(app, ctx, width, Some(t + 3.0), vec![], draw); + let (hovered, _) = bar_frame(app, ctx, width, Some(t + 3.1), vec![], draw); + (texts, nodes, hovered) +} + +/// Checks the field whose full text is `full`: in a window `narrow` wide it ends in an ellipsis +/// inside the window, screen readers get the full text, and resting the pointer on it shows the +/// full text in a tooltip no wider than the window; in a window `wide` wide it shows in full with no +/// tooltip. +fn assert_clipped_text_shows_on_hover(app: &mut crate::VectorcraftApp, ctx: &egui::Context, full: &str, narrow: f32, wide: f32, draw: DrawBar) { + let prefix: String = full.chars().take(4).collect(); + let (texts, nodes, hovered) = bar_hover(app, ctx, narrow, &prefix, draw); + let field = status_text(&texts, &prefix); + assert_eq!(field.text, full); + assert!(field.elided, "{narrow}: the text is clipped: {field:?}"); + assert!(field.bounds.right() <= narrow, "{narrow}: the ellipsis is inside the window: {field:?}"); + assert!(nodes.iter().any(|n| n.value() == Some(full)), "{narrow}: screen readers receive the full text"); + let tip: Vec<_> = hovered.iter().filter(|t| t.text == full && !t.elided).collect(); + assert_eq!(tip.len(), 1, "{narrow}: the tooltip shows the full text: {hovered:?}"); + assert!(tip[0].bounds.left() >= 0.0 && tip[0].bounds.right() <= narrow, "{narrow}: the tooltip wraps within the window: {tip:?}"); + + let (texts, _, hovered) = bar_hover(app, ctx, wide, &prefix, draw); + assert!(!status_text(&texts, &prefix).elided, "{wide}: the text fits"); + assert_eq!(hovered.iter().filter(|t| t.text == full).count(), 1, "{wide}: no tooltip: {hovered:?}"); +} + +/// A status message too long for the bar ends in an ellipsis and shows in full on hover, in a +/// tooltip that wraps within the window (#6622). +#[test] +fn a_clipped_status_message_shows_in_full_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + let long = format!("Saved recovery data for {}illustration.vectorcraft", "a-long-folder-name/".repeat(12)); + app.ui.status = long.clone(); + assert_clipped_text_shows_on_hover(&mut app, &ctx, &long, 700.0, 20000.0, super::status_bar); + app.ui.status = "Saved recovery data for Untitled-1".to_owned(); + let (texts, _, hovered) = bar_hover(&mut app, &ctx, 700.0, "Saved", super::status_bar); + assert!(!status_text(&texts, "Saved").elided); + assert_eq!(hovered.iter().filter(|t| t.text.starts_with("Saved")).count(), 1, "a message that fits has no tooltip: {hovered:?}"); +} + +/// The Help Bar's hint ends in an ellipsis when it is wider than the window and shows in full on +/// hover, in a tooltip that wraps within the window (#6622). The Dimension tool's hint is wider +/// than the smallest main window (800 points); 400 points is that window at UI Scaling 2.0. +#[test] +fn a_clipped_help_bar_hint_shows_in_full_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + app.select_tool("dimension"); + let hint: String = super::hint_segments(app.session.tool_id()).into_iter().map(|(s, _)| s).collect(); + for narrow in [800.0, 400.0] { + assert_clipped_text_shows_on_hover(&mut app, &ctx, &hint, narrow, 3000.0, super::hint_bar); + } +} + +/// In a narrow status bar the tool name ends in an ellipsis, shows in full on hover, and leaves +/// the status message its room (#6622). 640 and 500 points are the smallest main window (800 +/// points) at UI Scaling 1.25 and 1.6. +#[test] +fn a_clipped_tool_name_shows_in_full_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + for narrow in [640.0, 500.0] { + assert_clipped_text_shows_on_hover(&mut app, &ctx, "Selection", narrow, 1400.0, super::status_bar); + let (texts, _) = bar_frame(&mut app, &ctx, narrow, None, vec![], super::status_bar); + let tool = status_text(&texts, "Selection"); + let message = status_text(&texts, "Saved recovery data"); + assert!(message.clip.left() >= tool.bounds.right() && message.clip.width() >= 24.0, "{narrow}: the message keeps room: {texts:?}"); + } +} + /// Busy-job transitions preserve message placement, labels and accessibility. #[test] fn background_activity_keeps_the_status_message_origin() { @@ -1556,8 +1674,8 @@ fn artboard_document_tab_roundtrip_properties_delete_uses_shown_board() { assert_eq!(selected_before_delete, json!([1])); assert_eq!(active_before_delete, json!(1)); assert_eq!(st.doc.artboards, vec![before.artboards[0].clone()], "the original first board survives"); - assert_eq!(st.doc.guides, vec![before.guides[0].clone()], "only the deleted board's guide is removed"); - assert_eq!(st.doc.layers, before.layers, "artwork and symbol instances stay unchanged"); + assert_eq!(st.doc.ruler_guides(), vec![before.ruler_guides()[0].clone()], "only the deleted board's guide is removed"); + assert_eq!(art_of(&st.doc.layers), art_of(&before.layers), "artwork and symbol instances stay unchanged"); assert_eq!(st.doc.symbols, before.symbols, "symbol definitions stay unchanged"); assert_eq!(st.selection, selection); assert_eq!(app.run_count, commands.wrapping_add(1), "one command from the actual Properties control"); @@ -1853,9 +1971,9 @@ fn delete_shown_board(app: &mut crate::VectorcraftApp, ctx: &egui::Context, base "ARTBOARD_CLOSE_TARGET_WRONG_SURVIVOR: Properties showing Shown must delete Shown after closing B" ); assert_eq!(st.doc.as_ref(), expected, "only Shown and its attached guide may be removed from the full document"); - assert_eq!(st.doc.layers, before.layers, "all artwork and symbol instances stay unchanged"); + assert_eq!(art_of(&st.doc.layers), art_of(&before.layers), "all artwork and symbol instances stay unchanged"); assert_eq!(st.doc.symbols, before.symbols); - assert_eq!(st.doc.guides, vec![before.guides[0].clone()]); + assert_eq!(st.doc.ruler_guides(), vec![before.ruler_guides()[0].clone()]); assert_eq!(&st.selection, selection); assert_eq!(st.uid, *uid); assert_eq!(st.revision, revision + 1); @@ -1915,13 +2033,14 @@ fn artboard_close_active_tab_properties_delete_uses_shown_board() { let history_before = history_of(st); let history_limits = (st.history.limit, st.history.byte_budget); assert_eq!(before.artboards.iter().map(|a| a.name.as_str()).collect::>(), vec!["Keep", "Shown"]); - assert_eq!(before.guides.len(), 2); + assert_eq!(before.ruler_guides().len(), 2); assert!(!selection.objects.is_empty()); assert!(history_before.1.is_empty()); assert!(!st.is_dirty()); let mut expected = before.as_ref().clone(); expected.artboards = vec![before.artboards[0].clone()]; - expected.guides = vec![before.guides[0].clone()]; + let keep = before.ruler_guides()[0].id; + expected.retain_guides(&mut vectorcraft_doc::Selection::default(), |g| g.id == keep); let base = Baseline { uid, revision, before, selection, history_before, history_limits, expected }; close_b_tab(&mut app, &ctx, &base); delete_shown_board(&mut app, &ctx, &base); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 2ad8bdf21..a7d62b6da 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -32,7 +32,7 @@ fn open(app: &mut VectorcraftApp, kind: &str, fields: serde_json::Value) { } fn objects(app: &VectorcraftApp) -> usize { - app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum() + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.iter().filter(|c| !c.is_ruler_guide()).count())).sum() } #[test] @@ -570,11 +570,11 @@ fn split_into_grid_opens_at_the_selections_size_and_previews() { ]); frame(&mut app, Default::default()); // The preview: 2 × 3 cells and the guides along their edges, not yet an undo step. - assert_eq!((objects(&app), app.session.doc().unwrap().doc.guides.len()), (6, 10)); + assert_eq!((objects(&app), app.session.doc().unwrap().doc.ruler_guides().len()), (6, 10)); assert!(app.session.in_interaction()); confirm(&mut app).unwrap(); let st = app.session.doc().unwrap(); - assert_eq!((objects(&app), st.doc.guides.len(), st.history.undo.len()), (6, 10, undo + 1)); + assert_eq!((objects(&app), st.doc.ruler_guides().len(), st.history.undo.len()), (6, 10, undo + 1)); assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); // Cancel rolls a preview back. app.run("select.all", json!({})).unwrap(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 82adcdb70..a76f144b3 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4755,6 +4755,8 @@ axis Both И двете @msg invalid object id {value}: expected a non-negative integer невалиден идентификатор на обект {value}: очаква се неотрицателно цяло число @msg `{key}` entry {index} is {value}, not a non-negative integer object id елемент {index} на `{key}` е {value}, а не идентификатор на обект (неотрицателно цяло число) @msg no such object id {value} няма обект с идентификатор {value} +@msg id {value} is a ruler guide, not an object идентификатор {value} е на водач, а не на обект +@msg a ruler guide goes only directly in a layer or sublayer водачът може да е само пряко в слой или подслой @msg automation filesystem access is not granted: read authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за четене: {path} @msg automation filesystem access is not granted: write authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за запис: {path} @msg automation path rejected: outside the read root {root}: {path} пътят за автоматизация е отхвърлен: извън корена за четене {root}: {path} @@ -5964,6 +5966,11 @@ axis Y Axis Y ос Dash Trail Пунктирна следа Speed Line Линия на скорост Game Trails Игрови следи + Energy Beams Енергийни лъчи + Laser Bolt Лазерен болт + Plasma Arc Плазмена дъга + Charge Wave Вълна на заряд + Spark Trail Следа от искри Calligraphic Nibs Калиграфски писци Pattern Borders Бордюри с мостри Scatter Shapes Разсеяни фигури diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 70e5ee1ee..0fee3a8c4 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5110,6 +5110,8 @@ imageTrace Outline Obris @msg invalid object id {value}: expected a non-negative integer nevažeći ID objekta {value}: očekuje se nenegativan cijeli broj @msg `{key}` entry {index} is {value}, not a non-negative integer object id stavka {index} u `{key}` je {value}, a ne ID objekta (nenegativan cijeli broj) @msg no such object id {value} nema objekta s ID-om {value} +@msg id {value} is a ruler guide, not an object ID {value} je vodilica, a ne objekat +@msg a ruler guide goes only directly in a layer or sublayer vodilica može biti samo direktno u sloju ili podsloju @msg id {value} is a layer, not an object id {value} je sloj, a ne objekat @msg id {value} is a layer, not an object; move layers with layer.move id {value} je sloj, a ne objekat; slojeve pomjerajte s layer.move @msg automation filesystem access is not granted: read authority is absent: {path} pristup datotečnom sistemu za automatizaciju nije odobren: nema ovlaštenja za čitanje: {path} @@ -6014,6 +6016,11 @@ axis Y Axis Y osa Dash Trail Isprekidani trag Speed Line Linija brzine Game Trails Tragovi igre + Energy Beams Energetski snopovi + Laser Bolt Laserski metak + Plasma Arc Plazma luk + Charge Wave Talas naboja + Spark Trail Trag iskri Calligraphic Nibs Kaligrafski vrhovi Pattern Borders Ivice od uzoraka Scatter Shapes Raspršeni oblici diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9aadc2dd9..4af431332 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -368,6 +368,11 @@ antiAlias Sharp Nítid Dash Trail Estela discontínua Speed Line Línia de velocitat Game Trails Esteles de joc + Energy Beams Feixos d'energia + Laser Bolt Projectil làser + Plasma Arc Arc de plasma + Charge Wave Ona de càrrega + Spark Trail Estela d'espurnes Art using a deleted global swatch keeps its colour. L’art que utilitza una mostra global suprimida conserva el seu color. Artboard Tauler Artboard Options Opcions del tauler @@ -5524,6 +5529,8 @@ digits Malayalam Malaiàlam @msg no such document no existeix aquest document @msg no such layer or object no existeix aquesta capa o objecte @msg no such object id {value} no existeix l’id d’objecte {value} +@msg id {value} is a ruler guide, not an object l’id {value} és una guia, no un objecte +@msg a ruler guide goes only directly in a layer or sublayer una guia només pot anar directament en una capa o subcapa @msg no such object {0} no existeix l’objecte {0} @msg no such perspective grid preset no existeix aquest preajust de quadrícula de perspectiva @msg no such recent file no existeix aquest fitxer recent diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f23f842bc..821d4a90c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5207,6 +5207,8 @@ imageTrace Outline Obrys @msg invalid object id {value}: expected a non-negative integer neplatné ID objektu {value}: očekává se nezáporné celé číslo @msg `{key}` entry {index} is {value}, not a non-negative integer object id položka {index} v `{key}` je {value}, nikoli ID objektu (nezáporné celé číslo) @msg no such object id {value} objekt s ID {value} neexistuje +@msg id {value} is a ruler guide, not an object ID {value} patří vodítku, ne objektu +@msg a ruler guide goes only directly in a layer or sublayer vodítko může být jen přímo ve vrstvě nebo podvrstvě @msg id {value} is a layer, not an object ID {value} je vrstva, nikoli objekt @msg id {value} is a layer, not an object; move layers with layer.move ID {value} je vrstva, nikoli objekt; vrstvy přesunete pomocí layer.move @msg automation filesystem access is not granted: read authority is absent: {path} přístup automatizace k systému souborů není udělen: chybí oprávnění ke čtení: {path} @@ -6198,6 +6200,11 @@ axis Y Axis osa Y Dash Trail Čárkovaná stopa Speed Line Rychlostní čára Game Trails Herní stopy + Energy Beams Energetické paprsky + Laser Bolt Laserový výstřel + Plasma Arc Plazmový oblouk + Charge Wave Nabíjecí vlna + Spark Trail Jiskrová stopa Calligraphic Nibs Kaligrafická pera Pattern Borders Vzorkové okraje Scatter Shapes Rozptýlené tvary diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index bc878124b..ef0d4b9e5 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4802,6 +4802,8 @@ imageTrace Outline Kontur @msg invalid object id {value}: expected a non-negative integer ugyldigt objekt-id {value}: et ikke-negativt heltal var forventet @msg `{key}` entry {index} is {value}, not a non-negative integer object id element {index} i `{key}` er {value}, ikke et objekt-id (et ikke-negativt heltal) @msg no such object id {value} der findes intet objekt med id {value} +@msg id {value} is a ruler guide, not an object id {value} er en hjælpelinje, ikke et objekt +@msg a ruler guide goes only directly in a layer or sublayer en hjælpelinje kan kun ligge direkte i et lag eller underlag @msg automation filesystem access is not granted: read authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: læserettighed mangler: {path} @msg automation filesystem access is not granted: write authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: skriverettighed mangler: {path} @msg automation path rejected: outside the read root {root}: {path} automatiseringssti afvist: uden for læseroden {root}: {path} @@ -5979,6 +5981,11 @@ axis Y Axis Y-akse Dash Trail Streget spor Speed Line Hastighedslinje Game Trails Spilspor + Energy Beams Energistråler + Laser Bolt Laserbolt + Plasma Arc Plasmabue + Charge Wave Opladningsbølge + Spark Trail Gnistsvær Calligraphic Nibs Kalligrafiske penne Pattern Borders Mønsterborter Scatter Shapes Spredte former diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index dea0ce465..f7bc97c90 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4863,6 +4863,8 @@ theme Appearance Erscheinungsbild @msg invalid object id {value}: expected a non-negative integer ungültige Objekt-ID {value}: erwartet wird eine nicht negative ganze Zahl @msg `{key}` entry {index} is {value}, not a non-negative integer object id Eintrag {index} von `{key}` ist {value}, keine Objekt-ID (nicht negative ganze Zahl) @msg no such object id {value} kein Objekt mit der ID {value} +@msg id {value} is a ruler guide, not an object die ID {value} gehört zu einer Hilfslinie, nicht zu einem Objekt +@msg a ruler guide goes only directly in a layer or sublayer eine Hilfslinie kann nur direkt in einer Ebene oder Unterebene liegen @msg id {value} is a layer, not an object die ID {value} ist eine Ebene, kein Objekt @msg id {value} is a layer, not an object; move layers with layer.move die ID {value} ist eine Ebene, kein Objekt; verschieben Sie Ebenen mit layer.move @msg automation filesystem access is not granted: read authority is absent: {path} Dateisystemzugriff für die Automatisierung ist nicht gewährt: Die Leseberechtigung fehlt: {path} @@ -5781,6 +5783,11 @@ axis Y Axis Y-Achse Dash Trail Strichelspur Speed Line Geschwindigkeitslinie Game Trails Spielspuren + Energy Beams Energiestrahlen + Laser Bolt Laserbolzen + Plasma Arc Plasmabogen + Charge Wave Ladewelle + Spark Trail Funkenspur Calligraphic Nibs Kalligrafie-Federn Pattern Borders Musterbordüren Scatter Shapes Streuformen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 42ee612ea..e1189bc98 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4886,6 +4886,8 @@ digits Malayalam Μαλαγιάλαμ @msg invalid object id {value}: expected a non-negative integer μη έγκυρο αναγνωριστικό αντικειμένου {value}: αναμένεται μη αρνητικός ακέραιος @msg `{key}` entry {index} is {value}, not a non-negative integer object id το στοιχείο {index} του `{key}` είναι {value}, όχι αναγνωριστικό αντικειμένου (μη αρνητικός ακέραιος) @msg no such object id {value} δεν υπάρχει αντικείμενο με αναγνωριστικό {value} +@msg id {value} is a ruler guide, not an object το αναγνωριστικό {value} ανήκει σε οδηγό, όχι σε αντικείμενο +@msg a ruler guide goes only directly in a layer or sublayer ένας οδηγός μπορεί να βρίσκεται μόνο απευθείας σε επίπεδο ή υποεπίπεδο @msg id {value} is a layer, not an object το αναγνωριστικό {value} είναι επίπεδο, όχι αντικείμενο @msg id {value} is a layer, not an object; move layers with layer.move το αναγνωριστικό {value} είναι επίπεδο, όχι αντικείμενο· μετακινήστε τα επίπεδα με το layer.move @msg automation filesystem access is not granted: read authority is absent: {path} δεν έχει επιτραπεί πρόσβαση αυτοματισμού στο σύστημα αρχείων: δεν υπάρχει δικαίωμα ανάγνωσης: {path} @@ -6149,6 +6151,11 @@ axis Y Axis Άξονας Y Dash Trail Διακεκομμένο ίχνος Speed Line Γραμμή ταχύτητας Game Trails Ίχνη παιχνιδιού + Energy Beams Δέσμες ενέργειας + Laser Bolt Βολίδα λέιζερ + Plasma Arc Τόξο πλάσματος + Charge Wave Κύμα φόρτισης + Spark Trail Ίχνος σπινθήρων Calligraphic Nibs Καλλιγραφικές πένες Pattern Borders Περιθώρια μοτίβου Scatter Shapes Σχήματα διασποράς diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a92e2bfad..24f5e0e39 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4925,6 +4925,8 @@ axis Both Ambos @msg invalid object id {value}: expected a non-negative integer identificador de objeto no válido {value}: se esperaba un entero no negativo @msg `{key}` entry {index} is {value}, not a non-negative integer object id la entrada {index} de `{key}` es {value}, no un identificador de objeto (un entero no negativo) @msg no such object id {value} no existe el objeto con el identificador {value} +@msg id {value} is a ruler guide, not an object el identificador {value} es de una guía, no de un objeto +@msg a ruler guide goes only directly in a layer or sublayer una guía solo puede estar directamente en una capa o subcapa @msg id {value} is a layer, not an object el identificador {value} es una capa, no un objeto @msg id {value} is a layer, not an object; move layers with layer.move el identificador {value} es una capa, no un objeto; mueva las capas con layer.move @msg automation filesystem access is not granted: read authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de lectura: {path} @@ -6140,6 +6142,11 @@ axis Y Axis Eje Y Dash Trail Estela discontinua Speed Line Línea de velocidad Game Trails Estelas de juego + Energy Beams Haces de energía + Laser Bolt Proyectil láser + Plasma Arc Arco de plasma + Charge Wave Onda de carga + Spark Trail Estela de chispas Calligraphic Nibs Plumillas caligráficas Pattern Borders Bordes de motivo Scatter Shapes Formas de dispersión diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3ba6ebfdb..64b3f2b6f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5109,6 +5109,8 @@ imageTrace Outline Ääriviiva @msg invalid object id {value}: expected a non-negative integer virheellinen objektitunnus {value}: odotettiin ei-negatiivista kokonaislukua @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`-luettelon kohde {index} on {value}, ei objektitunnus (ei-negatiivinen kokonaisluku) @msg no such object id {value} objektia tunnuksella {value} ei ole +@msg id {value} is a ruler guide, not an object tunnus {value} on apuviivan, ei objektin +@msg a ruler guide goes only directly in a layer or sublayer apuviiva voi olla vain suoraan tasossa tai alatasossa @msg id {value} is a layer, not an object tunnus {value} on taso, ei objekti @msg id {value} is a layer, not an object; move layers with layer.move tunnus {value} on taso, ei objekti; siirrä tasoja komennolla layer.move @msg automation filesystem access is not granted: read authority is absent: {path} automaation tiedostojärjestelmäkäyttöä ei ole myönnetty: lukuoikeus puuttuu: {path} @@ -6228,6 +6230,11 @@ axis Y Axis Y-akseli Dash Trail Katkoviiva Speed Line Nopeusviiva Game Trails Pelijäljet + Energy Beams Energiasäteet + Laser Bolt Laserpultti + Plasma Arc Plasma-kaari + Charge Wave Latausaalto + Spark Trail Kipinävanne Calligraphic Nibs Kalligrafiakärjet Pattern Borders Kuvioreunukset Scatter Shapes Sirontamuodot diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1056a5dcc..dad99bcb4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4932,6 +4932,8 @@ axis Both Les deux @msg invalid object id {value}: expected a non-negative integer identifiant d’objet {value} non valide (un entier positif ou nul est attendu) @msg `{key}` entry {index} is {value}, not a non-negative integer object id l’entrée {index} de `{key}` vaut {value}, pas un identifiant d’objet (entier positif ou nul) @msg no such object id {value} aucun objet ne porte l’identifiant {value} +@msg id {value} is a ruler guide, not an object l’identifiant {value} est celui d’un repère, pas d’un objet +@msg a ruler guide goes only directly in a layer or sublayer un repère ne peut se trouver que directement dans un calque ou un sous-calque @msg id {value} is a layer, not an object l’identifiant {value} désigne un calque, pas un objet @msg id {value} is a layer, not an object; move layers with layer.move l’identifiant {value} désigne un calque, pas un objet ; déplacez les calques avec layer.move @msg automation filesystem access is not granted: read authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation de lecture : {path} @@ -6193,6 +6195,11 @@ axis Y Axis Axe Y Dash Trail Traînée tiretée Speed Line Ligne de vitesse Game Trails Traînées de jeu + Energy Beams Faisceaux d'énergie + Laser Bolt Trait laser + Plasma Arc Arc de plasma + Charge Wave Vague de charge + Spark Trail Traînée d'étincelles Calligraphic Nibs Plumes calligraphiques Pattern Borders Bordures de motif Scatter Shapes Formes de dispersion diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index c8b2dc57d..940858470 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -427,6 +427,11 @@ antiAlias Sharp शार्प Dash Trail डैश ट्रेल Speed Line स्पीड लाइन Game Trails गेम ट्रेल्स + Energy Beams ऊर्जा किरणें + Laser Bolt लेज़र बोल्ट + Plasma Arc प्लाज़्मा चाप + Charge Wave चार्ज तरंग + Spark Trail चिंगारी पथ Art using a deleted global swatch keeps its colour. हटाए गए ग्लोबल स्वॉच वाला आर्टवर्क अपना रंग बनाए रखता है। Artboard आर्टबोर्ड Artboard {n} of {total} {total} में से आर्टबोर्ड {n} @@ -6950,6 +6955,8 @@ antiAlias Sharp शार्प @msg no such layer or object ऐसी कोई लेयर या ऑब्जेक्ट नहीं @msg no such object {0} ऐसी कोई ऑब्जेक्ट नहीं {0} @msg no such object id {value} ऐसी कोई ऑब्जेक्ट नहीं id {value} +@msg id {value} is a ruler guide, not an object id {value} एक गाइड है, ऑब्जेक्ट नहीं +@msg a ruler guide goes only directly in a layer or sublayer गाइड केवल सीधे किसी लेयर या सबलेयर में हो सकता है @msg no such objects ऐसी कोई ऑब्जेक्ट नहीं @msg no such path anchor, or the path would reach past the canvas इस तरह के कोई पाथ एंकर नहीं, या पाथ कैनवास तक पहुंच जाएगा @msg no such perspective grid preset ऐसा कोई पर्सपेक्टिव ग्रिड पूर्व निर्धारित नहीं है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d219482bc..8f37efc8c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5222,6 +5222,8 @@ imageTrace Outline Körvonal @msg invalid object id {value}: expected a non-negative integer érvénytelen objektumazonosító: {value} (nemnegatív egész szám kell) @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` {index} indexű eleme {value}, nem objektumazonosító (nemnegatív egész szám kell) @msg no such object id {value} nincs ilyen objektumazonosító: {value} +@msg id {value} is a ruler guide, not an object ez az azonosító segédvonalé, nem objektumé: {value} +@msg a ruler guide goes only directly in a layer or sublayer segédvonal csak közvetlenül rétegben vagy alrétegben lehet @msg id {value} is a layer, not an object a(z) {value} azonosító egy réteg, nem objektum @msg id {value} is a layer, not an object; move layers with layer.move a(z) {value} azonosító egy réteg, nem objektum; a rétegeket a layer.move paranccsal helyezheti át @msg object {0} cannot have children ennek az objektumnak nem lehetnek gyermekei: {0} @@ -6181,6 +6183,11 @@ axis Y Axis Y tengely Dash Trail Szaggatott nyom Speed Line Sebességvonal Game Trails Játéknyomok + Energy Beams Energianyalábok + Laser Bolt Lézervillám + Plasma Arc Plazmaív + Charge Wave Töltéshullám + Spark Trail Szikracsík Calligraphic Nibs Kalligrafikus tollhegyek Pattern Borders Mintázatszegélyek Scatter Shapes Szórt alakzatok diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 1881d15f8..bfc9b611d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4802,6 +4802,8 @@ imageTrace Outline Kontur @msg invalid object id {value}: expected a non-negative integer id objek {value} tidak valid: harus berupa bilangan bulat non-negatif @msg `{key}` entry {index} is {value}, not a non-negative integer object id entri {index} dari `{key}` adalah {value}, bukan id objek (bilangan bulat non-negatif) @msg no such object id {value} tidak ada objek dengan id {value} +@msg id {value} is a ruler guide, not an object id {value} adalah panduan, bukan objek +@msg a ruler guide goes only directly in a layer or sublayer panduan hanya dapat berada langsung di lapisan atau sublapisan @msg automation filesystem access is not granted: read authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas baca tidak ada: {path} @msg automation filesystem access is not granted: write authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas tulis tidak ada: {path} @msg automation path rejected: outside the read root {root}: {path} jalur otomatisasi ditolak: di luar akar baca {root}: {path} @@ -5979,6 +5981,11 @@ axis Y Axis Sumbu Y Dash Trail Jejak putus-putus Speed Line Garis kecepatan Game Trails Jejak game + Energy Beams Sinar Energi + Laser Bolt Bolt Laser + Plasma Arc Busur Plasma + Charge Wave Gelombang Muatan + Spark Trail Jejak Percikan Calligraphic Nibs Pena kaligrafi Pattern Borders Bingkai pola Scatter Shapes Bentuk sebar diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cd0bdf88b..551fd25e4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4968,6 +4968,8 @@ axis Both Entrambi @msg invalid object id {value}: expected a non-negative integer ID oggetto {value} non valido: è richiesto un intero non negativo @msg `{key}` entry {index} is {value}, not a non-negative integer object id la voce {index} di `{key}` è {value}, non un ID oggetto (intero non negativo) @msg no such object id {value} nessun oggetto con ID {value} +@msg id {value} is a ruler guide, not an object l’ID {value} è di una guida, non di un oggetto +@msg a ruler guide goes only directly in a layer or sublayer una guida può stare solo direttamente in un livello o sottolivello @msg id {value} is a layer, not an object l’ID {value} è un livello, non un oggetto @msg id {value} is a layer, not an object; move layers with layer.move l’ID {value} è un livello, non un oggetto; sposta i livelli con layer.move @msg automation filesystem access is not granted: read authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di lettura: {path} @@ -5815,6 +5817,11 @@ axis Y Axis Asse Y Dash Trail Scia tratteggiata Speed Line Linea di velocità Game Trails Scia di gioco + Energy Beams Raggi di energia + Laser Bolt Dardo laser + Plasma Arc Arco al plasma + Charge Wave Onda di carica + Spark Trail Scia di scintille Calligraphic Nibs Pennini calligrafici Pattern Borders Bordi a motivo Scatter Shapes Forme a dispersione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9262cbf06..50a503e7f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5009,6 +5009,8 @@ swatch Bright Violet ビビッドバイオレット @msg invalid object id {value}: expected a non-negative integer オブジェクト ID {value} は正しくありません: 0 以上の整数を指定してください @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` の要素 {index} は {value} で、オブジェクト ID (0 以上の整数) ではありません @msg no such object id {value} ID が {value} のオブジェクトは見つかりません +@msg id {value} is a ruler guide, not an object ID {value} はオブジェクトではなくガイドです +@msg a ruler guide goes only directly in a layer or sublayer ガイドはレイヤーまたはサブレイヤーの直下にのみ置けます @msg id {value} is a layer, not an object ID {value} はオブジェクトではなくレイヤーです @msg id {value} is a layer, not an object; move layers with layer.move ID {value} はオブジェクトではなくレイヤーです。レイヤーは layer.move で移動してください @msg automation filesystem access is not granted: read authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 読み込み権限がありません: {path} @@ -5880,6 +5882,11 @@ axis Y Axis Y 軸 Dash Trail ダッシュトレイル Speed Line スピードライン Game Trails ゲームトレイル + Energy Beams エネルギービーム + Laser Bolt レーザーボルト + Plasma Arc プラズマアーク + Charge Wave チャージウェーブ + Spark Trail スパークトレイル Calligraphic Nibs カリグラフィペン先 Pattern Borders パターンボーダー Scatter Shapes 散布シェイプ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8fc3e844f..a9aa3f2c3 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -92,6 +92,7 @@ Spelling მართლწერა Auto Spell Check მართლწერის ავტომატური შემოწმება Check Spelling… მართლწერის შემოწმება… + Check Spelling მართლწერის შემოწმება Edit Custom Dictionary… მომხმარებლის ლექსიკონის რედაქტირება… Edit Colors ფერების რედაქტირება Recolor Artwork… ნახატის ხელახლა შეფერადება… @@ -362,6 +363,8 @@ Stroke Pattern კონტურის პატერნი Patterns პატერნები Stroke Weight კონტურის სისქე + Stroke Weight Up კონტურის სისქის გაზრდა + Stroke Weight Down კონტურის სისქის შემცირება Graphic Style გრაფიკული სტილი Symbol Instance სიმბოლოს ეგზემპლარი Link Block Series დაკავშირებული ბლოკების სერია @@ -451,6 +454,13 @@ Feather… კიდეების შერბილება… SVG Filters SVG ფილტრები Apply SVG Filter… SVG ფილტრის გამოყენება… + Apply SVG Filter SVG ფილტრის გამოყენება + SVG Filter SVG ფილტრი + New SVG Filter ახალი SVG ფილტრი + Edit SVG Filter SVG ფილტრის რედაქტირება + Delete SVG Filter SVG ფილტრის წაშლა + Import SVG Filter SVG ფილტრის იმპორტირება + Import SVG Filter… SVG ფილტრის იმპორტირება… Warp გაღუნვა Arc… რკალი… Arc Lower… ქვედა რკალი… @@ -1515,6 +1525,13 @@ digits Malayalam მალაიალამური Radial Gradient რადიალური გრადიენტი Reset to White, Black თეთრზე და შავზე დაბრუნება Reverse Gradient გრადიენტის შებრუნება + Gradient Presets გრადიენტის პრესეტები + Apply Gradient Preset გრადიენტის პრესეტის გამოყენება + Save Gradient Preset გრადიენტის პრესეტის შენახვა + Save Gradient Preset… გრადიენტის პრესეტის შენახვა… + Delete Gradient Preset გრადიენტის პრესეტის წაშლა + Show Presets პრესეტების ჩვენება + Hide Presets პრესეტების დამალვა Save to Swatches ფერის ნიმუშებში შენახვა Delete Graphic Style (Alt-click: without asking) გრაფიკული სტილის წაშლა (Alt-დაწკაპუნებით: დაუკითხავად) Delete the graphic style “{name}”? წაიშალოს გრაფიკული სტილი „{name}“? @@ -1553,6 +1570,7 @@ digits Malayalam მალაიალამური Rename Preset პრესეტის სახელის შეცვლა Save Image Trace Preset გამოსახულების ვექტორიზაციის პრესეტის შენახვა Delete Image Trace Preset გამოსახულების ვექტორიზაციის პრესეტის წაშლა + Cancel Image Trace გამოსახულების ვექტორიზაციის გაუქმება Method: მეთოდი: Mode: რეჟიმი: Noise ხმაური @@ -2946,6 +2964,7 @@ theme Appearance იერსახე Reshape Segment სეგმენტის ფორმის შეცვლა Split Path ტრაექტორიის გაყოფა Activate Document დოკუმენტის გააქტიურება + Move Document დოკუმენტის გადატანა Delete წაშლა Document JSON დოკუმენტის JSON Duplicate დუბლირება @@ -2961,6 +2980,8 @@ theme Appearance იერსახე Effects ეფექტები Move Effect ეფექტის გადატანა Remove Effect ეფექტის წაშლა + Duplicate Effects ეფექტების დუბლირება + Remove Effects ეფექტების წაშლა Expand Options გაშლის პარამეტრები File Info ფაილის ინფორმაცია Batch პაკეტური დამუშავება @@ -3364,6 +3385,7 @@ theme Appearance იერსახე Use Japanese Crop Marks იაპონური ჭრის ნიშნების გამოყენება Use Low Resolution Proxy for Linked EPS დაბალი გარჩევადობის პროქსის გამოყენება დაკავშირებული EPS-ისთვის Use Precise Cursors ზუსტი კურსორების გამოყენება + Caps Lock Cursor State Caps Lock-ის მდგომარეობა (კურსორი) Use Preview Bounds გადახედვის საზღვრების გამოყენება When pasting text ტექსტის ჩასმისას XML ID XML ID @@ -3520,6 +3542,8 @@ axis Both ორივე List Folder საქაღალდის სია Area Type Options არის ტექსტის პარამეტრები Reshape Type Area ტექსტის არის ფორმის შეცვლა + Reshape Type Path ტექსტის ტრაექტორიის ფორმის შეცვლა + Move Type Path Handle ტექსტის ტრაექტორიის სახელურის გადაადგილება Scale მასშტაბი Feet & Inches ფუტები და დუიმები Pixels პიქსელები @@ -3765,6 +3789,7 @@ font-technology Variable ცვლადი Flip არეკვლა Couldn't {} {name}: {e} ვერ მოხერხდა {}: {name}: {e} Delete Character Style სიმბოლოს სტილის წაშლა + Edit Character Style სიმბოლოს სტილის რედაქტირება Delete Layer შრის წაშლა Delete Paragraph Style აბზაცის სტილის წაშლა Duplicate Character Style სიმბოლოს სტილის დუბლირება @@ -4599,6 +4624,8 @@ font-technology Variable ცვლადი @msg invalid object id {value}: expected a non-negative integer ობიექტის არასწორი id {value}: მოსალოდნელია არაუარყოფითი მთელი რიცხვი @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`-ის ელემენტი {index} არის {value} და არა ობიექტის id (არაუარყოფითი მთელი რიცხვი) @msg no such object id {value} ობიექტი id-ით {value} არ არსებობს +@msg id {value} is a ruler guide, not an object id {value} დამხმარე ხაზისაა და არა ობიექტის +@msg a ruler guide goes only directly in a layer or sublayer დამხმარე ხაზი შეიძლება იყოს მხოლოდ უშუალოდ შრეში ან ქვეშრეში @msg id {value} is a layer, not an object id {value} შრეა და არა ობიექტი @msg id {value} is a layer, not an object; move layers with layer.move id {value} შრეა და არა ობიექტი; შრეების გადასატანად გამოიყენეთ layer.move @msg automation filesystem access is not granted: read authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: წაკითხვის უფლება არ არის: {path} @@ -5183,6 +5210,7 @@ mark-style Japanese იაპონური imageTrace Outline კონტური Run გაშვება Run Script სკრიპტის გაშვება + List Installed Scripts დაინსტალირებული სკრიპტების სია Stop Script სკრიპტის შეჩერება Show External Script Warning გარე სკრიპტის გაფრთხილების ჩვენება A Script Is Running სკრიპტი მუშაობს @@ -5204,7 +5232,7 @@ imageTrace Outline კონტური Some scripts were not listed ზოგიერთი სკრიპტი არ არის ჩამოთვლილი The Scripts folder cannot be read Scripts საქაღალდის წაკითხვა შეუძლებელია @msg the script's run is no longer available სკრიპტის გაშვება აღარ არის ხელმისაწვდომი -@msg a failed command changed undo steps that could not be put back, so the script was stopped წარუმატებელმა ბრძანებამ შეცვალა გაუქმების ნაბიჯები, რომელთა აღდგენა ვერ მოხერხდა, ამიტომ სკრიპტი შეჩერდა +@msg a failed command changed undo steps that could not be put back, so the script was stopped წარუმატებელმა ბრძანებამ დაბრუნების ნაბიჯები შეცვალა და მათი პირვანდელ მდგომარეობაში მოყვანა ვერ მოხერხდა, ამიტომ სკრიპტი შეჩერდა @msg a recovery copy could not be written, so the script was not run აღდგენის ასლის ჩაწერა ვერ მოხერხდა, ამიტომ სკრიპტი არ გაეშვა @msg Running a script სკრიპტი მუშაობს @msg Copied the script report კოპირებულია სკრიპტის ანგარიში @@ -5722,7 +5750,7 @@ imageTrace Outline კონტური @msg Flat Logo mode does not use keepColors: it traces the colors named in logoColors, or the colors it finds. ბრტყელი ლოგოს რეჟიმი keepColors-ს არ იყენებს: ის ვექტორიზებს logoColors-ში მითითებულ ფერებს ან მის მიერ ნაპოვნ ფერებს. @msg Nothing was traced: none of the colors the trace found matches keepColors ({_1}). არაფერი ვექტორიზდა: ვექტორიზაციის მიერ ნაპოვნი არცერთი ფერი არ შეესაბამება keepColors-ს ({_1}). @msg Nothing was traced: Ignore Color leaves out every area of the colors keepColors lists. არაფერი ვექტორიზდა: ფერის იგნორირება გამოტოვებს keepColors-ში ჩამოთვლილი ფერების ყველა არეს. -@msg Nothing was traced: the colors that match keepColors produce no paths. არაფერი ვექტორიზდა: keepColors-ის შესაბამისი ფერებიდან არცერთი კონტური არ მიიღება. +@msg Nothing was traced: the colors that match keepColors produce no paths. არაფერი ვექტორიზდა: keepColors-ის შესაბამისი ფერებიდან არცერთი ტრაექტორია არ მიიღება. @msg keepColors: none of the colors the trace found matches {c}, so nothing of it is traced. keepColors: ვექტორიზაციის მიერ ნაპოვნი არცერთი ფერი არ შეესაბამება {c}-ს, ამიტომ მისგან არაფერი ვექტორიზდება. @msg `colorGroup` needs `library: "document"` `colorGroup`-ს სჭირდება `library: "document"` @msg `colorGroup` needs `palette: "documentLibrary"` `colorGroup`-ს სჭირდება `palette: "documentLibrary"` @@ -5776,6 +5804,8 @@ imageTrace Outline კონტური Clear History ისტორიის გასუფთავება New Document from State ახალი დოკუმენტი მდგომარეობიდან Continue in Background ფონურ რეჟიმში გაგრძელება + Cancel Opening გახსნის გაუქმება + Reading the file… ფაილი იკითხება… Artboard {n} of {total} სამუშაო დაფა {n} / {total} File {n} of {total} ფაილი {n} / {total} Auto Leading: ავტომატური სტრიქონთა დაშორება: @@ -6121,6 +6151,11 @@ axis Y Axis Y ღერძი Dash Trail წყვეტილი კვალი Speed Line სიჩქარის ხაზი Game Trails თამაშის კვალი + Energy Beams ენერგიის სხივები + Laser Bolt ლაზერული ჭავლი + Plasma Arc პლაზმის რკალი + Charge Wave მუხტის ტალღა + Spark Trail ნაპერწკლის კვალი Calligraphic Nibs კალიგრაფიული წვერები Pattern Borders პატერნის ბორდიურები Scatter Shapes გაბნეული ფიგურები @@ -6331,6 +6366,7 @@ anchor Points: წერტილები: Parametric Shape Tool პარამეტრული ფიგურის ხელსაწყო Parametric Shape პარამეტრული ფიგურა Edit Parametric Shape პარამეტრული ფიგურის რედაქტირება + Configure Shape ფიგურის პარამეტრების შეცვლა Convert to Parametric Shape პარამეტრულ ფიგურად გარდაქმნა Ring რგოლი Heart გული @@ -6611,10 +6647,28 @@ breakText Glyphs გლიფები Malayalam მალაიალამური Odia ორია Urdu ურდუ + Assamese ასამური + Sanskrit სანსკრიტი + German: Swiss გერმანული: შვეიცარია + Catalan კატალანური + Danish დანიური + Swedish შვედური + Finnish ფინური + Norwegian ნორვეგიული + Polish პოლონური + Czech ჩეხური + Russian რუსული + Bulgarian ბულგარული + Serbian: Cyrillic სერბული: კირილიცა + Serbian: Latin სერბული: ლათინიცა + Croatian ხორვატული + Turkish თურქული + Chinese ჩინური VectorCraft {release} and later (version {n}) VectorCraft {release} და უფრო ახალი (ვერსია {n}) # Document Tools add-on: Export All Open Documents (#4920). Export All Open Documents as PDF ყველა ღია დოკუმენტის ექსპორტი PDF-ად Export All Open Documents as SVG ყველა ღია დოკუმენტის ექსპორტი SVG-ად + Export All Open Documents ყველა ღია დოკუმენტის ექსპორტირება @msgplural Exported {n} document|Exported {n} documents ექსპორტირებულია დოკუმენტი ({n}) @msgplural Exported {n} document; {title} wasn't written: {error}|Exported {n} documents; {title} wasn't written: {error} ექსპორტირებულია დოკუმენტი ({n}); {title} არ ჩაიწერა: {error} # Document Tools add-on: Batch Trace Folder (#4921). @@ -6628,6 +6682,7 @@ breakText Glyphs გლიფები Static Symbol სტატიკური სიმბოლო Symbol Instance Appearance სიმბოლოს ეგზემპლარის იერსახე Reset Symbol Instance სიმბოლოს ეგზემპლარის ჩამოყრა + Select Symbol Instance Part სიმბოლოს ეგზემპლარის ნაწილის მონიშვნა @msg type must be static or dynamic, not {v} type უნდა იყოს static ან dynamic და არა {v} @msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` სტატიკური სიმბოლოა: ჯერ სიმბოლოს ტიპად დააყენეთ „დინამიკური“ @msg Only the fill, stroke, opacity and blend of a path, compound path or type selected in a symbol instance can change სიმბოლოს ეგზემპლარში მონიშნული მრუდის, შედგენილი მრუდის ან ტექსტის მხოლოდ შევსება, კონტური, გამჭვირვალობა და შერევა შეიძლება შეიცვალოს @@ -6847,7 +6902,12 @@ font-script Japanese იაპონური Ignore All ყველას იგნორირება Add to Dictionary ლექსიკონში დამატება No Suggestions შემოთავაზებები არ არის + Copies per Sheet (as many as fit, in a grid) ასლები ფურცელზე (რამდენიც დაეტევა, ბადედ) Edit Custom Dictionary მომხმარებლის ლექსიკონის რედაქტირება + Add to Custom Dictionary მომხმარებლის ლექსიკონში დამატება + Change in Custom Dictionary მომხმარებლის ლექსიკონში შეცვლა + Delete from Custom Dictionary მომხმარებლის ლექსიკონიდან წაშლა + Spelling Suggestions მართლწერის შეთავაზებები Entry: ჩანაწერი: # Script Console Script Console სკრიპტების კონსოლი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 33efe3fc0..e51fd6c3d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4936,6 +4936,8 @@ theme Appearance 외관 @msg invalid object id {value}: expected a non-negative integer 개체 id {value}이(가) 올바르지 않습니다: 음이 아닌 정수여야 합니다 @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`의 항목 {index}은(는) {value}이며, 음이 아닌 정수인 개체 id가 아닙니다 @msg no such object id {value} id가 {value}인 개체가 없습니다 +@msg id {value} is a ruler guide, not an object id {value}은(는) 개체가 아니라 안내선입니다 +@msg a ruler guide goes only directly in a layer or sublayer 안내선은 레이어나 하위 레이어 바로 아래에만 둘 수 있습니다 @msg id {value} is a layer, not an object id {value}은(는) 개체가 아닌 레이어입니다 @msg id {value} is a layer, not an object; move layers with layer.move id {value}은(는) 개체가 아닌 레이어입니다. 레이어는 layer.move로 이동하세요 @msg automation filesystem access is not granted: read authority is absent: {path} 자동화 파일 시스템 접근이 허용되지 않았습니다: 읽기 권한이 없습니다: {path} @@ -5776,6 +5778,11 @@ axis Y Axis Y축 Dash Trail 대시 트레일 Speed Line 스피드 라인 Game Trails 게임 트레일 + Energy Beams 에너지 빔 + Laser Bolt 레이저 볼트 + Plasma Arc 플라즈마 아크 + Charge Wave 차지 웨이브 + Spark Trail 스파크 트레일 Calligraphic Nibs 서예 펜촉 Pattern Borders 패턴 테두리 Scatter Shapes 흩뿌리기 모양 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 940c29c87..59fb283ac 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4814,6 +4814,8 @@ imageTrace Outline Contour @msg invalid object id {value}: expected a non-negative integer ongeldige object-id {value}: een niet-negatief geheel getal verwacht @msg `{key}` entry {index} is {value}, not a non-negative integer object id item {index} van `{key}` is {value}, geen object-id (een niet-negatief geheel getal) @msg no such object id {value} er is geen object met id {value} +@msg id {value} is a ruler guide, not an object id {value} is van een hulplijn, niet van een object +@msg a ruler guide goes only directly in a layer or sublayer een hulplijn kan alleen direct in een laag of sublaag staan @msg automation filesystem access is not granted: read authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de leesbevoegdheid ontbreekt: {path} @msg automation filesystem access is not granted: write authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de schrijfbevoegdheid ontbreekt: {path} @msg automation path rejected: outside the read root {root}: {path} automatiseringspad afgewezen: buiten de leesroot {root}: {path} @@ -6030,6 +6032,11 @@ axis Y Axis Y-as Dash Trail Streepspoor Speed Line Snelheidslijn Game Trails Spelsporen + Energy Beams Energiestralen + Laser Bolt Laserbout + Plasma Arc Plasmaboog + Charge Wave Laadgolf + Spark Trail Vonkenspoor Calligraphic Nibs Kalligrafische penpunten Pattern Borders Patroonranden Scatter Shapes Strooivormen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3508f8ff7..0760b61ec 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5033,6 +5033,8 @@ imageTrace Outline Kontur @msg invalid object id {value}: expected a non-negative integer nieprawidłowy identyfikator obiektu {value}: oczekiwano nieujemnej liczby całkowitej @msg `{key}` entry {index} is {value}, not a non-negative integer object id element {index} w `{key}` to {value}, a nie identyfikator obiektu (nieujemna liczba całkowita) @msg no such object id {value} brak obiektu o identyfikatorze {value} +@msg id {value} is a ruler guide, not an object identyfikator {value} należy do linii pomocniczej, a nie do obiektu +@msg a ruler guide goes only directly in a layer or sublayer linia pomocnicza może się znajdować tylko bezpośrednio w warstwie lub podwarstwie @msg id {value} is a layer, not an object identyfikator {value} oznacza warstwę, a nie obiekt @msg id {value} is a layer, not an object; move layers with layer.move identyfikator {value} oznacza warstwę, a nie obiekt; warstwy przenosi się za pomocą layer.move @msg automation filesystem access is not granted: read authority is absent: {path} nie przyznano dostępu automatyzacji do systemu plików: brak uprawnień do odczytu: {path} @@ -6052,6 +6054,11 @@ axis Y Axis Oś Y Dash Trail Ślad kreskowany Speed Line Linia prędkości Game Trails Ślady gry + Energy Beams Wiązki energii + Laser Bolt Pocisk laserowy + Plasma Arc Łuk plazmowy + Charge Wave Fala ładunku + Spark Trail Ślad iskier Calligraphic Nibs Stalówki kaligraficzne Pattern Borders Obramowania wzorków Scatter Shapes Kształty rozproszone diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4420ed889..1e37e7f55 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4433,6 +4433,8 @@ axis Both Ambos @msg invalid object id {value}: expected a non-negative integer id de objeto inválido {value}: era esperado um inteiro não negativo @msg `{key}` entry {index} is {value}, not a non-negative integer object id a entrada {index} de `{key}` é {value}, não um id de objeto (inteiro não negativo) @msg no such object id {value} não existe objeto com o id {value} +@msg id {value} is a ruler guide, not an object o id {value} é de uma guia, não de um objeto +@msg a ruler guide goes only directly in a layer or sublayer uma guia só pode ficar diretamente em uma camada ou subcamada @msg id {value} is a layer, not an object o id {value} é uma camada, não um objeto @msg id {value} is a layer, not an object; move layers with layer.move o id {value} é uma camada, não um objeto; mova camadas com layer.move @msg automation filesystem access is not granted: read authority is absent: {path} o acesso da automação ao sistema de arquivos não foi concedido: falta autorização de leitura: {path} @@ -6113,6 +6115,11 @@ axis Y Axis Eixo Y Dash Trail Rastro tracejado Speed Line Linha de velocidade Game Trails Rastros de jogo + Energy Beams Feixes de energia + Laser Bolt Raio laser + Plasma Arc Arco de plasma + Charge Wave Onda de carga + Spark Trail Rastro de faíscas Calligraphic Nibs Penas Caligráficas Pattern Borders Bordas de Padrão Scatter Shapes Formas de Dispersão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ed2885af4..de1d31fd1 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4964,6 +4964,8 @@ axis Both Оба @msg invalid object id {value}: expected a non-negative integer недопустимый идентификатор объекта {value}: ожидается неотрицательное целое число @msg `{key}` entry {index} is {value}, not a non-negative integer object id элемент {index} в `{key}` равен {value}, а не идентификатору объекта (неотрицательному целому числу) @msg no such object id {value} объект с идентификатором {value} не существует +@msg id {value} is a ruler guide, not an object идентификатор {value} принадлежит направляющей, а не объекту +@msg a ruler guide goes only directly in a layer or sublayer направляющая может находиться только непосредственно в слое или подслое @msg id {value} is a layer, not an object идентификатор {value} относится к слою, а не к объекту @msg id {value} is a layer, not an object; move layers with layer.move идентификатор {value} относится к слою, а не к объекту; перемещайте слои с помощью layer.move @msg automation filesystem access is not granted: read authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на чтение: {path} @@ -5809,6 +5811,11 @@ axis Y Axis Ось Y Dash Trail Пунктирный след Speed Line Линия скорости Game Trails Игровые следы + Energy Beams Энергетические лучи + Laser Bolt Лазерный заряд + Plasma Arc Плазменная дуга + Charge Wave Волна заряда + Spark Trail Искра-след Calligraphic Nibs Каллиграфические перья Pattern Borders Узорные каймы Scatter Shapes Рассеянные фигуры diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 86b31cb8b..a3939998e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4841,6 +4841,8 @@ axis Both Båda @msg invalid object id {value}: expected a non-negative integer ogiltigt objekt-id {value}: ett icke-negativt heltal förväntades @msg `{key}` entry {index} is {value}, not a non-negative integer object id post {index} i `{key}` är {value}, inte ett objekt-id (ett icke-negativt heltal) @msg no such object id {value} det finns inget objekt med id {value} +@msg id {value} is a ruler guide, not an object id {value} tillhör en stödlinje, inte ett objekt +@msg a ruler guide goes only directly in a layer or sublayer en stödlinje kan bara ligga direkt i ett lager eller underlager @msg id {value} is a layer, not an object id {value} är ett lager, inte ett objekt @msg id {value} is a layer, not an object; move layers with layer.move id {value} är ett lager, inte ett objekt; flytta lager med layer.move @msg automation filesystem access is not granted: read authority is absent: {path} automatiseringen har inte fått åtkomst till filsystemet: läsbehörighet saknas: {path} @@ -6221,6 +6223,11 @@ axis Y Axis Y-axel Dash Trail Streckat spår Speed Line Hastighetslinje Game Trails Spår från spel + Energy Beams Energistrålar + Laser Bolt Laserbult + Plasma Arc Plasmabåge + Charge Wave Laddningsvåg + Spark Trail Gnistsvår Calligraphic Nibs Kalligrafiska pennspetsar Pattern Borders Mönsterbårder Scatter Shapes Spridningsformer diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 61035b2e1..17f25cf77 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5672,6 +5672,8 @@ theme Appearance Görünüm @msg invalid object id {value}: expected a non-negative integer geçersiz nesne kimliği {value}: negatif olmayan bir tam sayı bekleniyordu @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` içindeki {index} dizinli öğe {value}, negatif olmayan tam sayı bir nesne kimliği değil @msg no such object id {value} böyle bir nesne kimliği yok: {value} +@msg id {value} is a ruler guide, not an object bu kimlik bir nesneye değil, bir kılavuza ait: {value} +@msg a ruler guide goes only directly in a layer or sublayer bir kılavuz yalnızca doğrudan bir katmanda veya alt katmanda bulunabilir @msg id {value} is a layer, not an object {value} kimliği bir katmana ait, bir nesneye değil @msg id {value} is a layer, not an object; move layers with layer.move {value} kimliği bir katmana ait, bir nesneye değil; katmanları layer.move ile taşıyın @msg {key} must be Unix seconds (an integer) or null {key}, Unix saniyesi (bir tam sayı) veya null olmalıdır @@ -6154,6 +6156,11 @@ axis Y Axis Y ekseni Dash Trail Kesik iz Speed Line Hız çizgisi Game Trails Oyun izleri + Energy Beams Enerji Işınları + Laser Bolt Lazer Mermisi + Plasma Arc Plazma Yayı + Charge Wave Şarj Dalgası + Spark Trail Kıvılcım İzi Calligraphic Nibs Kaligrafi Uçları Pattern Borders Desen Kenarlıkları Scatter Shapes Dağılım Şekilleri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ea09c80e3..f9412ed97 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4952,6 +4952,8 @@ axis Both Обидві @msg invalid object id {value}: expected a non-negative integer неприпустимий ідентифікатор об’єкта {value}: очікується невід’ємне ціле число @msg `{key}` entry {index} is {value}, not a non-negative integer object id елемент {index} у `{key}` дорівнює {value}, а не ідентифікатору об’єкта (невід’ємному цілому числу) @msg no such object id {value} об’єкт з ідентифікатором {value} не існує +@msg id {value} is a ruler guide, not an object ідентифікатор {value} належить напрямній, а не об’єкту +@msg a ruler guide goes only directly in a layer or sublayer напрямна може бути лише безпосередньо в шарі або підшарі @msg id {value} is a layer, not an object ідентифікатор {value} належить шару, а не об’єкту @msg id {value} is a layer, not an object; move layers with layer.move ідентифікатор {value} належить шару, а не об’єкту; переміщуйте шари за допомогою layer.move @msg automation filesystem access is not granted: read authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на читання: {path} @@ -5795,6 +5797,11 @@ axis Y Axis Вісь Y Dash Trail Пунктирний слід Speed Line Лінія швидкості Game Trails Ігрові сліди + Energy Beams Енергетичні промені + Laser Bolt Лазерний заряд + Plasma Arc Плазмова дуга + Charge Wave Хвиля заряду + Spark Trail Слід іскор Calligraphic Nibs Каліграфічні пера Pattern Borders Візерункові облямівки Scatter Shapes Розсіяні фігури diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 27a6b28fb..e26e81852 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4877,6 +4877,8 @@ theme Appearance Giao diện @msg invalid object id {value}: expected a non-negative integer id đối tượng {value} không hợp lệ: cần một số nguyên không âm @msg `{key}` entry {index} is {value}, not a non-negative integer object id mục {index} của `{key}` là {value}, không phải id đối tượng (số nguyên không âm) @msg no such object id {value} không có đối tượng nào có id {value} +@msg id {value} is a ruler guide, not an object id {value} là của đường căn, không phải của đối tượng +@msg a ruler guide goes only directly in a layer or sublayer đường căn chỉ có thể nằm trực tiếp trong lớp hoặc lớp con @msg id {value} is a layer, not an object id {value} là một lớp, không phải đối tượng @msg id {value} is a layer, not an object; move layers with layer.move id {value} là một lớp, không phải đối tượng; hãy di chuyển lớp bằng layer.move @msg object {0} cannot have children đối tượng {0} không thể có phần tử con @@ -5796,6 +5798,11 @@ axis Y Axis Trục Y Dash Trail Vệt đứt đoạn Speed Line Đường tốc độ Game Trails Vệt game + Energy Beams Chùm năng lượng + Laser Bolt Tia laser + Plasma Arc Cung plasma + Charge Wave Sóng nạp + Spark Trail Vệt tia lửa Calligraphic Nibs Ngòi bút thư pháp Pattern Borders Viền họa tiết Scatter Shapes Hình phân tán diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ab565b637..855001a82 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4713,6 +4713,8 @@ imageTrace Outline 轮廓 @msg invalid object id {value}: expected a non-negative integer 无效的对象 id {value}:应为非负整数 @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` 中索引 {index} 处的项为 {value},不是非负整数对象 id @msg no such object id {value} 没有 id 为 {value} 的对象 +@msg id {value} is a ruler guide, not an object id {value} 是参考线,不是对象 +@msg a ruler guide goes only directly in a layer or sublayer 参考线只能直接位于图层或子图层中 @msg id {value} is a layer, not an object id {value} 是图层,不是对象 @msg id {value} is a layer, not an object; move layers with layer.move id {value} 是图层,不是对象;请使用 layer.move 移动图层 @msg automation filesystem access is not granted: read authority is absent: {path} 未授予自动化文件系统访问权限:缺少读取权限:{path} @@ -6036,6 +6038,11 @@ axis Y Axis Y 轴 Dash Trail 虚线轨迹 Speed Line 速度线 Game Trails 游戏轨迹 + Energy Beams 能量光束 + Laser Bolt 激光弹 + Plasma Arc 等离子弧 + Charge Wave 充能波 + Spark Trail 火花尾迹 Calligraphic Nibs 书法笔尖 Pattern Borders 图案边框 Scatter Shapes 散布形状 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index fec78601a..2002c06ef 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5558,6 +5558,8 @@ lighting Light 光源 @msg invalid object id {value}: expected a non-negative integer 無效的物件 id {value}:應為非負整數 @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` 中索引 {index} 處的項目為 {value},不是非負整數物件 id @msg no such object id {value} 沒有 id 為 {value} 的物件 +@msg id {value} is a ruler guide, not an object id {value} 是參考線,不是物件 +@msg a ruler guide goes only directly in a layer or sublayer 參考線只能直接位於圖層或子圖層中 @msg id {value} is a layer, not an object id {value} 是圖層,不是物件 @msg id {value} is a layer, not an object; move layers with layer.move id {value} 是圖層,不是物件;請使用 layer.move 移動圖層 Move Anchor Point 移動錨點 @@ -5833,6 +5835,11 @@ axis Y Axis Y 軸 Dash Trail 虛線軌跡 Speed Line 速度線 Game Trails 遊戲軌跡 + Energy Beams 能量光束 + Laser Bolt 雷射彈 + Plasma Arc 電漿弧 + Charge Wave 充能波 + Spark Trail 火花尾跡 Calligraphic Nibs 書法筆尖 Pattern Borders 圖樣邊框 Scatter Shapes 散佈形狀 diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 0c322f0a0..e1076145d 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -210,7 +210,9 @@ fn has_key(app: &VectorcraftApp) -> bool { /// The first selected ruler guide while objects are selected too: the one Align to Key Object /// makes the key when there is no other object to be it. fn guide_for_key(app: &VectorcraftApp) -> Option { - app.session.active().filter(|st| !st.selection.is_empty()).and_then(|st| st.selection.guides.first().copied()) + let st = app.session.active().filter(|st| !st.selection.is_empty())?; + let first = st.selection.guides.first().copied()?; + vectorcraft_engine::cmd::guide_indexes(&st.doc, &[first]).first().copied() } /// The number of selected ruler guides, which Distribute spaces with the objects (#1597). diff --git a/crates/ui-egui/src/panels/align/tests.rs b/crates/ui-egui/src/panels/align/tests.rs index 76b7e3b60..6cd9364cd 100644 --- a/crates/ui-egui/src/panels/align/tests.rs +++ b/crates/ui-egui/src/panels/align/tests.rs @@ -28,11 +28,13 @@ fn align_to_key_object_makes_a_selected_guide_the_key() { let ctx = egui::Context::default(); let ([_, key, _], left) = panel_buttons(&mut app, &ctx); click(&ctx, &mut app, key.center(), show); - assert_eq!(app.session.active().unwrap().selection.key_guide, Some(0), "{}", app.ui.status); + let st = app.session.active().unwrap(); + let key = st.selection.key_guide.map(|k| vectorcraft_engine::cmd::guide_indexes(&st.doc, &[k])); + assert_eq!(key, Some(vec![0]), "{}", app.ui.status); assert_eq!(align_to(&app), AlignTo::Key); click(&ctx, &mut app, left.center(), show); assert_eq!(left_edge(&app, &a), 120.0, "on the guide, not the artboard: {}", app.ui.status); - assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 120.0); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].pos, 120.0); } /// A Shift-click that leaves the key object selected on its own lets the key go, and the align @@ -532,13 +534,13 @@ fn the_distribute_buttons_send_the_spacing_with_a_key_guide() { app.ui.align_spacing = 15.0; click(&ctx, &mut app, left.center(), show); assert_eq!(lefts(&app), [70.0, 85.0, 115.0], "15 pt apart, outward from the guide: {}", app.ui.status); - assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put"); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].pos, 100.0, "the guide stays put"); // Horizontal Distribute Space with 15: the object before the guide ends 15 pt before it, // the one after it starts 15 pt after it. click(&ctx, &mut app, space.center(), show); assert_eq!(sent(&app), ("object.distributeSpacing".to_string(), json!("key"), Some(json!(15.0)))); assert_eq!(lefts(&app), [10.0, 45.0, 115.0], "{}", app.ui.status); - assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put"); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].pos, 100.0, "the guide stays put"); } /// Three vertical ruler guides selected without objects: Align Objects buttons are on, and @@ -560,7 +562,7 @@ fn the_align_buttons_align_guides_selected_alone_to_artboard() { row.sort_by(|a, b| a.left().total_cmp(&b.left())); assert!(row[0].width() > 0.0, "Horizontal Align Left is enabled"); click(&ctx, &mut app, row[0].center(), show); - let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::>(); assert_eq!(pos(&app), [0.0, 0.0, 0.0], "{}", app.ui.status); } @@ -585,7 +587,7 @@ fn the_distribute_buttons_space_guides_selected_alone() { assert_eq!(row.len(), 6, "the Distribute row"); // Its fifth button is Horizontal Distribute Center. click(&ctx, &mut app, row[4].center(), show); - let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::>(); assert_eq!(pos(&app), [20.0, 100.0, 180.0], "{}", app.ui.status); app.run("edit.undo", json!({})).unwrap(); assert_eq!(pos(&app), [20.0, 50.0, 180.0], "one undo step"); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 313242bb4..f027f2c71 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -151,8 +151,10 @@ struct View<'a> { /// Where the row thumbnails come from. thumbs: ThumbSource<'a>, sel: HashSet, - /// The selected ruler guides. + /// The selected ruler guides, as indexes into [`Self::ruler_guides`]. guides: Vec, + /// The document's ruler guides ([`Document::ruler_guides`]). + ruler_guides: Vec, target: Option, current: Option, rows: Vec, @@ -262,7 +264,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { doc: &doc, thumbs: ThumbSource::of(app, st, &doc), sel: st.selection.objects.iter().copied().collect(), - guides: st.selection.guides.clone(), + guides: vectorcraft_engine::cmd::guide_indexes(&doc, &st.selection.guides), + ruler_guides: doc.ruler_guides(), target: st.selection.target, current: st.current_layer(), rows, @@ -468,7 +471,8 @@ fn listed_when_opened(doc: &Document, open: &OpenRows, opts: &PanelOptions, quer open.set(*a, true); } let mut rows = vec![]; - push_rows(layer, 0, false, (Color32::PLACEHOLDER, true), (&open, opts, query, filter, doc), &mut rows); + // Only object rows are looked for, so the guide rows are left out. + push_rows(layer, 0, false, (Color32::PLACEHOLDER, true), (&open, opts, query, filter, &[]), &mut rows); rows.iter().any(|r| r.node.id == id && r.guide.is_none() && !r.spine) } diff --git a/crates/ui-egui/src/panels/layers/model.rs b/crates/ui-egui/src/panels/layers/model.rs index bbafbb755..c24863ffb 100644 --- a/crates/ui-egui/src/panels/layers/model.rs +++ b/crates/ui-egui/src/panels/layers/model.rs @@ -1,5 +1,5 @@ use super::rows::key; -use super::{Arc, Color32, Document, LiveShape, Node, NodeKind, OpenRows, PanelOptions, Ui, Value, VectorcraftApp}; +use super::{Arc, Color32, Document, LiveShape, Node, NodeId, NodeKind, OpenRows, PanelOptions, Ui, Value, VectorcraftApp}; /// Where a dragged row goes relative to the row it is dropped on. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -42,7 +42,8 @@ pub(crate) fn shown_name(app: &VectorcraftApp, n: &Node, lang: crate::i18n::Lang pub(super) fn opens(n: &Node, opts: &PanelOptions) -> bool { match n.kind { NodeKind::Compound { .. } => false, - _ => n.children().is_some_and(|c| c.iter().any(|c| !opts.layers_only || c.is_layer())), + // Ruler guide nodes are not object rows (a layer's guide rows are counted by its caller). + _ => n.children().is_some_and(|c| c.iter().any(|c| !c.is_ruler_guide() && (!opts.layers_only || c.is_layer()))), } } @@ -226,25 +227,29 @@ struct RowsKey { /// contains `query` (lowercase) and that `filter` keeps, and every row holding one. pub(super) fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str, filter: &Filter) -> Vec { let mask_layer = doc.mask_edit.map(|m| m.layer); + // The ruler guides' ids by `guide.list` index, listed once for every layer. + let guides: Vec = doc.ruler_guides().into_iter().filter_map(|g| g.id).collect(); let mut out = vec![]; for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) { - push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query, filter, doc), &mut out); + push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query, filter, &guides), &mut out); } out } /// Add the rows of `n` and those open inside it to `out`; `(colour, shown)` are those of what holds -/// it (the layer's colour, and whether it is visible). +/// it (the layer's colour, and whether it is visible). The last item of `cx` is the document's +/// ruler guide ids by `guide.list` index. pub(super) fn push_rows( n: &Arc, depth: usize, clip_path: bool, (colour, shown): (Color32, bool), - cx: (&OpenRows, &PanelOptions, &str, &Filter, &Document), + cx: (&OpenRows, &PanelOptions, &str, &Filter, &[NodeId]), out: &mut Vec, ) { - let (open_rows, opts, query, filter, doc) = cx; - if opts.layers_only && !n.is_layer() { + let (open_rows, opts, query, filter, all_guides) = cx; + // A ruler guide node gets no object row: its layer lists the guide rows. + if n.is_ruler_guide() || (opts.layers_only && !n.is_layer()) { return; } let colour = match &n.kind { @@ -257,10 +262,16 @@ pub(super) fn push_rows( let shown = shown && n.visible; let searching = !query.is_empty(); let narrowing = searching || filter.is_on(); - // A layer's ruler guides are rows in it, above its art (not while searching, nor while the - // filter leaves them out). - let guides: Vec = - if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { doc.guides_on(n.id).map(|(i, _)| i).collect() } else { vec![] }; + // A layer's ruler guides are rows in it, above its art, the highest `guide.list` index first + // (not while searching, nor while the filter leaves them out). + let guides: Vec = if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { + let mut found: Vec = + n.children().into_iter().flatten().filter(|c| c.is_ruler_guide()).filter_map(|c| all_guides.binary_search(&c.id).ok()).collect(); + found.sort_unstable(); + found + } else { + vec![] + }; let opens = opens(n, opts) || !guides.is_empty(); let open = opens && (narrowing || open_rows.contains(n.id)); let at = out.len(); diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index 376c021c4..13b6ff1e2 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -122,7 +122,7 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { let (t, h) = (&view.t, view.h); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); - let Some(g) = view.doc.guides.get(i) else { return }; + let Some(g) = view.ruler_guides.get(i) else { return }; let selected = view.guides.contains(&i); let editable = view.doc.guide_editable(g); if editable && resp.hovered() { diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index cad2530e5..212778957 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -650,14 +650,17 @@ fn guide_rows_select_and_move_their_guides() { let (circles, _) = frame(&mut app, &ctx, vec![], false); assert_eq!(circles.len(), 3); let guide_row = egui::pos2(150.0, (circles[1].y + circles[2].y) / 2.0); - let guides = |app: &VectorcraftApp| app.session.active().unwrap().selection.guides.clone(); + let guides = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides) + }; click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); assert_eq!(guides(&app), [0], "its row selects it"); // Dragged onto Layer 2's row, it goes on Layer 2, in one undo step. let undo = app.session.active().unwrap().history.undo.len(); drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[0].y), false); let st = app.session.active().unwrap(); - assert_eq!((st.doc.guides[0].layer, st.history.undo.len()), (Some(second), undo + 1)); + assert_eq!((st.doc.ruler_guides()[0].layer, st.history.undo.len()), (Some(second), undo + 1)); // Now under Layer 2: locked, its row neither selects nor drags it. run(&mut app, "layer.setProps", json!({"ids": [second.0], "locked": true})); assert!(guides(&app).is_empty(), "deselected with its layer locked"); @@ -666,7 +669,7 @@ fn guide_rows_select_and_move_their_guides() { click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); assert!(guides(&app).is_empty()); drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[2].y), false); - assert_eq!(app.session.active().unwrap().doc.guides[0].layer, Some(second)); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].layer, Some(second)); } /// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows @@ -1783,3 +1786,48 @@ fn a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend() { click_with(&mut app, &ctx, spine_row, egui::Modifiers::NONE); assert!(selected(&app).is_empty(), "a locked blend's spine can't be selected"); } + +/// A ruler guide node gets no object row, and a layer that holds only one has no rows to open. +#[test] +fn ruler_guide_nodes_get_no_object_row() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let rect = NodeId(app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap()); + expand(&mut app, &json!({})).unwrap(); + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let (first, second) = (doc.layers[0].id, doc.add_layer(Some("Guides only"))); + for l in [first, second] { + let g = Node::new(doc.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 20.0, artboard: None }); + doc.insert(Some(l), usize::MAX, g).unwrap(); + } + let rows = list_rows(&st.doc, &st.layers_open, &PanelOptions::default(), "", &Filter::default()); + let ids: Vec = rows.iter().filter(|r| r.guide.is_none()).map(|r| r.node.id).collect(); + assert_eq!(ids, [second, first, rect]); + // Each guide is a guide row of its layer instead. + let guide_rows: Vec = rows.iter().filter(|r| r.guide.is_some()).map(|r| r.node.id).collect(); + assert_eq!(guide_rows, [first], "the open layer lists its guide"); + assert!(rows[0].opens, "a layer with only a ruler guide opens to its guide row"); +} + +/// A layer lists its guide rows by `guide.list` index, the highest first, whatever the order of +/// the guide nodes in the layer (`guide.setLayer` puts a moved guide at the top of its new layer). +#[test] +fn guide_rows_list_by_index_whatever_the_node_order() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + for pos in [10, 20, 30] { + app.session.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); + } + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let layer = doc.layers[0].id; + // The guide of index 0 moves to the top of the layer's guides. + let first = doc.ruler_guides()[0].id.unwrap(); + doc.move_node(first, Some(layer), usize::MAX).unwrap(); + let kids: Vec = doc.layers[0].children().unwrap().iter().map(|c| c.id).collect(); + assert_eq!(kids.last(), Some(&first)); + let rows = list_rows(&st.doc, &st.layers_open, &PanelOptions::default(), "", &Filter::default()); + let indexes: Vec = rows.iter().filter_map(|r| r.guide).collect(); + assert_eq!(indexes, [2, 1, 0]); +} diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs index 2f1587813..dacf77425 100644 --- a/crates/ui-egui/src/panels/layers/thumbs.rs +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -230,7 +230,8 @@ fn weigh(n: &Node, nodes: usize, anchors: usize, masks: bool, doc: Option<&Docum return false; } let mask = n.extras().mask.as_deref().filter(|m| left.masks && !m.disabled).map(|m| &m.art); - n.children().into_iter().flatten().chain(mask).all(|c| left.object() && count(c, doc, left, depth)) + // A layer's ruler guides draw nothing in its thumbnail and use no budget. + n.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).chain(mask).all(|c| left.object() && count(c, doc, left, depth)) } let mut left = Left { nodes, anchors, steps: THUMB_COUNT_STEPS, masks }; let small = count(n, doc, &mut left, 0); diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index ea1a60095..a9853d317 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -1733,7 +1733,7 @@ fn selected_guides_show_their_position_in_a_transform_section() { app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); let v = app.run("guide.add", json!({"vertical": true, "pos": 30})).unwrap()["index"].as_u64().unwrap(); let h = app.run("guide.add", json!({"vertical": false, "pos": 20})).unwrap()["index"].as_u64().unwrap(); - let pos = |app: &VectorcraftApp, i: u64| app.session.doc().unwrap().doc.guides[i as usize].pos; + let pos = |app: &VectorcraftApp, i: u64| app.session.doc().unwrap().doc.ruler_guides()[i as usize].pos; let ctx = egui::Context::default(); // Steps the field labelled `label` up by one with ↑. let step = |app: &mut VectorcraftApp, label: &str| { diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 22f863fbd..6918e9118 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -127,7 +127,17 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { let under = |e: &crate::menus::MenuEntry| e.path == ["Window", "Brush Libraries"]; let opened: Vec<_> = entries.iter().filter(|e| under(e) && e.command.as_deref() == Some("window.brushLibrary")).map(|e| e.params["library"].clone()).collect(); - assert_eq!(opened, [json!("art-ribbons"), json!("calligraphic-nibs"), json!("pattern-borders"), json!("scatter-shapes"), json!("game-trails")]); + assert_eq!( + opened, + [ + json!("art-ribbons"), + json!("calligraphic-nibs"), + json!("pattern-borders"), + json!("scatter-shapes"), + json!("game-trails"), + json!("energy-beams") + ] + ); let enabled = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled); assert_eq!(enabled("window.brushLibrary.other"), Some(true)); assert_eq!(enabled("ui.saveBrushLibrary"), Some(true), "a new document has the default brushes"); diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 2596b218b..25ddd4076 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -223,7 +223,7 @@ pub(crate) fn selected_guides(app: &VectorcraftApp) -> Option<(Option, Opti return None; } let least = |vertical: bool| { - st.selection.guides.iter().filter_map(|i| st.doc.guides.get(*i)).filter(|g| g.vertical == vertical).map(|g| g.pos).min_by(f64::total_cmp) + st.selection.guides.iter().filter_map(|i| st.doc.ruler_guide(*i)).filter(|g| g.vertical == vertical).map(|g| g.pos).min_by(f64::total_cmp) }; Some((least(true), least(false))) } diff --git a/crates/ui-egui/src/tests/guide_escape.rs b/crates/ui-egui/src/tests/guide_escape.rs index 90c77f47d..113d2b1e2 100644 --- a/crates/ui-egui/src/tests/guide_escape.rs +++ b/crates/ui-egui/src/tests/guide_escape.rs @@ -90,7 +90,7 @@ impl GuideDrag { let point = |at| if vertical { Point::new(at, 60.0) } else { Point::new(60.0, at) }; (f.from, f.to, f.end) = (xf.to_screen(point(100.0)), xf.to_screen(point(140.0)), xf.to_screen(point(160.0))); assert!([f.from, f.to, f.end].into_iter().all(|p| rect.contains(p))); - assert_eq!(f.selection.guides, vec![0, 1]); + assert_eq!(vectorcraft_engine::cmd::guide_indexes(&f.before, &f.selection.guides), vec![0, 1]); f.app.session.active_mut().unwrap().mark_saved(); f.unchanged(); f @@ -140,17 +140,30 @@ impl GuideDrag { } fn guides_at_target(&self) { - let mut moved = self.before.guides.clone(); + let line = |g: &vectorcraft_doc::Guide| (g.vertical, g.pos, g.artboard, g.layer); + let before: Vec<_> = self.before.ruler_guides().iter().map(line).collect(); + let mut moved = before.clone(); for guide in &mut moved { - if guide.vertical == self.vertical { - guide.pos += 40.0; + if guide.0 == self.vertical { + guide.1 += 40.0; } } - let expected = if self.copy { self.before.guides.iter().cloned().chain(moved).collect::>() } else { moved }; + let expected = if self.copy { before.into_iter().chain(moved).collect::>() } else { moved }; let st = self.app.session.active().unwrap(); - assert_eq!(st.doc.guides, expected); - assert_eq!(st.doc.layers, self.before.layers); - assert_eq!(st.selection.guides, if self.copy { vec![2, 3] } else { vec![0, 1] }); + assert_eq!(st.doc.ruler_guides().iter().map(line).collect::>(), expected); + let art = |d: &vectorcraft_doc::Document| { + d.layers + .iter() + .map(|l| { + let mut l = vectorcraft_doc::Node::clone(l); + l.strip_ruler_guides(); + l + }) + .collect::>() + }; + assert_eq!(art(&st.doc), art(&self.before)); + let selected = vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides); + assert_eq!(selected, if self.copy { vec![2, 3] } else { vec![0, 1] }); } fn unchanged(&self) { diff --git a/crates/ui-egui/src/tests/ruler_origin.rs b/crates/ui-egui/src/tests/ruler_origin.rs index de4a0501e..365cc92ef 100644 --- a/crates/ui-egui/src/tests/ruler_origin.rs +++ b/crates/ui-egui/src/tests/ruler_origin.rs @@ -80,7 +80,7 @@ fn ruler_origin_drag_uses_the_rotated_zoomed_view_and_commits_only_on_release() assert!(st.doc.ruler_origin_for(0).distance(goal) < 1e-4, "{:?}", st.doc.ruler_origin_for(0)); assert_eq!(st.history.undo.len(), undo + 1); assert_eq!(st.doc.art_bounds(), None, "the drawing tool never receives the corner drag"); - assert!(st.doc.guides.is_empty()); + assert!(st.doc.ruler_guides().is_empty()); let origin = st.doc.ruler_origin_for(0); app.run("edit.undo", json!({})).unwrap(); assert_eq!(&*app.session.doc().unwrap().doc, &*before); diff --git a/crates/ui-egui/src/tests/sysclip.rs b/crates/ui-egui/src/tests/sysclip.rs index 595d12b51..1da5b9332 100644 --- a/crates/ui-egui/src/tests/sysclip.rs +++ b/crates/ui-egui/src/tests/sysclip.rs @@ -174,8 +174,9 @@ pub(super) fn pasted(app: &VectorcraftApp) -> Vec { st.selection.objects.iter().map(|id| st.doc.node(*id).unwrap().clone()).collect() } +/// The objects on the first layer (its ruler guides left out). pub(super) fn count(app: &VectorcraftApp) -> usize { - app.session.active().unwrap().doc.layers[0].children().unwrap().len() + app.session.active().unwrap().doc.layers[0].children().unwrap().iter().filter(|c| !c.is_ruler_guide()).count() } /// An egui `PasteImage` from the host is the signal to read the rich system clipboard: it must diff --git a/crates/ui-egui/src/tests/sysclip_guides.rs b/crates/ui-egui/src/tests/sysclip_guides.rs index a54bd57eb..50c4535c3 100644 --- a/crates/ui-egui/src/tests/sysclip_guides.rs +++ b/crates/ui-egui/src/tests/sysclip_guides.rs @@ -14,7 +14,7 @@ use crate::tests_sysclip::{app, app_with_busy_worker, copy_elsewhere, copy_rect, use crate::{Services, VectorcraftApp, background}; fn guides(app: &VectorcraftApp) -> usize { - app.session.active().unwrap().doc.guides.len() + app.session.active().unwrap().doc.ruler_guides().len() } /// Add a vertical guide at `pos`, select only it and copy it. @@ -35,7 +35,8 @@ fn a_guides_only_copy_pastes_the_guides_not_what_another_app_copied_before() { run(&mut app, "edit.paste", json!({})); assert_eq!(guides(&app), 2, "the copied guide was pasted"); assert_eq!(count(&app), 0, "and not the other app's text"); - assert_eq!(app.session.active().unwrap().selection.guides, [1]); + let st = app.session.active().unwrap(); + assert_eq!(vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides), [1]); // A second Paste takes the guides again. run(&mut app, "edit.paste", json!({})); assert_eq!((guides(&app), count(&app)), (3, 0)); diff --git a/docs/gaps.md b/docs/gaps.md index ecc1b2cf1..b722ace84 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -630,8 +630,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (five original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter - Shapes and Game Trails; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a + its own (six original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter + Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo step (applied to the selected paths in the same step), dragging one onto a path adds and applies it and @@ -649,7 +649,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert, and Status Effects with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the five shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the six shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). @@ -854,6 +854,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | G5: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Missing trimmed; estimate 5–15 → 4–14 h; MCP Brush libraries list Energy Beams. | | 2026-11-11 | minor | G5: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); Missing trimmed; estimate 6–16 → 5–15 h; MCP Symbol libraries list Skill Icons. | | 2026-11-10 | minor | G6: MCP catalogue (#6460): headless lists `UI_COMMANDS` (`ui: true`), eight prompts, effect vs command ids, 33 tools, commands/reference; estimate unchanged. | | 2026-11-11 | minor | G5: built-in Status Effects graphic style library (Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade, Shield Buff); Missing trimmed; estimate 7–17 → 6–16 h; MCP docs list UI Frames and Map Markers under Symbol libraries. | diff --git a/docs/mcp.md b/docs/mcp.md index 2ba77e5b8..2bd8494d2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -482,7 +482,7 @@ opens the picker through `run_command` with the remote backend. | `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default, and a `backquote` that is not a boolean is an error. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | | `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. Remote, the events wait until the keys and other input sent before them (`press_key`, `type_text`) have reached the app, so a key pressed between two calls of one drag applies before the release. When that input is dropped because the document, a dialog or the focus changed first, the call fails and its events are not sent. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. | | `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. | -| `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. | +| `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. Polygons and stars take `rotation` in degrees clockwise (0: a corner or point straight up). `line`: `x1,y1,x2,y2`. | | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. | | `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee; `c` and `f` while dragging out an arc; `f`, `v`, `x` and `c` while dragging out a rectangular or polar grid). | | `type_text` | `{text}` | Remote only. | @@ -496,7 +496,7 @@ opens the picker through `run_command` with the remote backend. | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). In remote desktop mode, `run_command` and `invoke_menu` can both call `pathfinder.repeat` with `{}`. Successful panel operations, direct `object.pathfinder.*` commands, and `command.batch` steps (including Actions panel playback) set its history. Repeat applies it to the current selection using current Pathfinder Options. Trap, compound-shape edits, live effects and `action.batch` do not set history. With no history it returns "no Pathfinder operation applied yet". | -| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. | +| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. `rotate` is in degrees counter-clockwise; a positive `shear` moves the top to the right. With `copy`, the first step duplicates. | | `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. Type in the design that holds a `%` followed by two digits shows each column's value, with at least the first digit's places before the decimal point and the second digit's places after it. | | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | | `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted `index` uses the current state. An `index` that names no state is an error and changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. | @@ -1486,9 +1486,9 @@ path to a value inside a list or an object, and the document and its undo histor An omitted value with a `default` takes it (`rotation` 0 in `view.saved.new`); the journal keeps the params as they were given. Saved steps played from a journal or an action keep a key the schema does not name, unless its value nests -more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 2 +more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 3 (`vectorcraft_engine::schemas::API_VERSION`); a change to any of them raises the level. Level 2 added -`engine.apiReference`. Commands that plug-ins add have no schema here. +`engine.apiReference`; level 3 says that `rotation` in `view.saved.new` and `view.saved.list` turns clockwise. Commands that plug-ins add have no schema here. `engine.apiReference {}` → `{markdown}` returns the command API reference, written from these schemas each time it runs; no file of it is kept. It needs no open document and changes nothing. The reference has a `##` section for each @@ -1731,6 +1731,8 @@ VectorCraft sets these limits itself: | A dialog's message, title and prompt preset | 16 KiB, 256 bytes and 16 KiB | | `answers` | 10,000 answers and 1 MiB as JSON | | `dialogs` in the report | 10,000 dialogs and 1 MiB as JSON | +| Files `File.open` reads | 8 MiB a file, 32 MiB in a run, 256 open at once | +| `Folder.getFiles` | 2,048 entries, 65,536 names looked at | | Growth before a person is asked | 100,000 objects and 512 MiB of new document data | In the app, a script past the growth limit stops at a question to continue or to stop and undo. Stop, Escape and @@ -1743,7 +1745,9 @@ another file than the script) and `fuelLeft`. - `runTimeMs` is the time the script ran, its commands included. Waits between frames and for a person's answer are left out. `timingAvailable` is false on the web, where `runTimeMs` is 0. -- `hostCallCounts` is `{vcRun, documentReads, commandListReads, vcLog, consoleWrites}`. +- `hostCallCounts` is `{vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}`. + `fileCalls` counts the calls `File` and `Folder` make to the app, and `fileExecutes` the `execute` calls, which open + nothing. - `slowestCommands` lists at most 5 commands as `{id, calls, totalMs, maxMs}`, the longest in all first, then by id. - `console` is `{lines, droppedLines, truncatedLines}`: the run's own output, `vc.log` lines and uncaught error, as text. @@ -1802,6 +1806,54 @@ empty object are the same. vectorcraft-cli run --cmd script.run --params '{"source": "if (confirm(\"Add a frame?\")) vc.log(prompt(\"Name\", \"Frame\"));", "answers": [{"kind": "confirm", "value": true}, {"kind": "prompt", "value": "Border"}]}' ``` +The help pages https://extendscript.docsforadobe.dev/file-system-access/file-object/ and +https://extendscript.docsforadobe.dev/file-system-access/folder-object/ describe the `File` and `Folder` objects. A +script reads files with them: + +- `new File(path)` and `new Folder(path)` name a path without looking it up. A path is a platform path or a URI + path: a relative path is taken from `Folder.current`, which starts at the script's folder, `~` is the home folder, + `%xx` escapes are read as UTF-8, and on Windows `/` and `\` both separate names and `/c/` names drive C. `File(path)` + without `new` gives a `Folder` for an existing folder, and `Folder(path)` gives a `File` for an existing file. Two + objects for the same path are different objects. +- `name`, `displayName`, `fsName`, `absoluteURI`, `fullName`, `relativeURI`, `path`, `parent` and `toString()` come + from the path. `exists`, `length`, `created`, `modified`, `hidden`, `readonly` and `alias` read the file system. + `File.encode`, `File.decode` and `File.isEncodingAvailable` (and the same on `Folder`), `File.fs`, and the folders + `Folder.temp`, `Folder.desktop`, `Folder.myDocuments`, `Folder.userData` and the others are there too. +- `open('r')` opens a file to read, and `read([chars])`, `readln()`, `readch()`, `seek(pos[, mode])`, `tell()`, `eof` + and `close()` read it. `read`, `readln` and `readch` count characters, and `seek` and `tell` count bytes. `readln` + and `readch` take CR, LF, CRLF and LF CR as one line end. `folder.getFiles([mask])` lists a folder, sorted by name; + the mask matches `*` and `?` without regard to case, and a function as the mask keeps the entries it returns true + for. +- A method that fails returns `false`, `null` or an empty string and sets `error`, such as `Script file access + denied`, `Script file does not exist` or `Script file is not open`. A count or a position that is not a finite + number, or a negative count, throws a `RangeError`. + +VectorCraft reads files by these rules of its own: + +- A script run from a file reads only in that file's folder and the folders below it, as its includes are read, and + a script given as `source` reads no files. When automation read roots are set, as for a run over MCP or the command + line, the files must also be inside them. A path outside, a Windows network path (`\\host\share`) and a device path + (`\\?\`, `\\.\`) are refused before anything is looked up: `exists` is `false`, the dates and `getFiles` give `null`, + and `error` is `Script file access denied`. A path that leads out through a link or a junction is refused too, and + `getFiles` leaves such an entry out. +- `open` reads the whole file. A file larger than 8 MiB is refused from its size before any of it is read, with + `Script file exceeds 8 MiB`, and a run that opens more than 32 MiB in all ends with an error. A run has at most 256 + files open at once; another `open` returns `false` with `Script has 256 files open` until one is closed. +- The encoding is the one the file's byte order mark gives (UTF-8, UTF-16LE or UTF-16BE), otherwise the `encoding` + the script set before `open`, otherwise UTF-8 when the bytes are valid UTF-8, otherwise CP1252. `BINARY` reads each + byte as one character. A byte the encoding cannot read, such as one above 127 in `ASCII`, fails the read with + `Script file cannot be decoded` and leaves the position where it was. +- `getFiles` gives at most 2,048 entries and looks at most 65,536 names; past that it gives `null` with `Script folder + has more than 2,048 entries`. A mask that takes more than 16,777,216 steps to match against the names of one + folder gives `null` with `Script folder mask takes too many steps to match`. +- A path longer than 32,767 characters throws a `RangeError` (`Script file path exceeds 32,767 characters`) when a + `File` or `Folder` is made from it. +- Writing is not available yet: `open('w')`, `open('e')`, `open('a')`, `write`, `writeln`, `copy`, `remove`, + `rename`, `create` and `createAlias` return `false` with `Script file writing is not available`, and setting + `length`, `hidden` or `readonly` sets `error` to that message and changes nothing. The file + and folder dialogs return `null`, and `execute` opens nothing and returns `false`. In the web build every lookup + fails with `Script file access is unavailable on the web`. + Window › Script Console shows the session's script lines, oldest first: each run's start, its `$.write` and `$.writeln` output, its `vc.log` entries, its uncaught error as `name:line:column: message` (or `name: file:line:column: message` when the error is in another file), its end, and a report of @@ -2171,7 +2223,7 @@ repeated names, and ids the document doesn't have are dropped. View → New View… keeps a view under a name, in the document: `view.saved.new {name?, center: [x, y], zoom, rotation?}` (the desktop app fills in its current view; default the first free "View N"; at most 25 per document, -names up to 255 characters, each name once) → `{name, index}`, one undo step. `view.saved.list` → +names up to 255 characters, each name once; `rotation` is in degrees clockwise on screen, as `view.rotate` turns the view) → `{name, index}`, one undo step. `view.saved.list` → `{views: [{name, center, zoom, rotation}…]}` in menu order. Edit Views… renames and deletes: `view.saved.edit {name, newName?, delete?}`, or several at once as `{edits: [{name, newName?, delete?}…]}` in one undo step (→ `{views}`), where every `name` is the name before the edit and the names must stay unique; it is @@ -2212,7 +2264,11 @@ kept with the workspace, and built-in workspaces and workspaces saved without it ## Ruler guides -Ruler guides are numbered in the order they were made. `guide.add {vertical, pos, artboard?}` makes one (the x of a +Each ruler guide is an object of a layer or sublayer, directly in it and above its art, and takes an id from the +document's object ids. Ruler guides are numbered by id, which is the order they were made (a guide read from an `.ai` +file takes its place in the file). An object command given a ruler guide's id (in `ids` or `id`) fails with an error, +and `node.move` cannot put a ruler guide into a group or out of its layers; the guide commands below take guides by +number. `guide.add {vertical, pos, artboard?}` makes one (the x of a vertical guide, the y of a horizontal one, in points) → `{index}`. With `artboard` (an artboard's index) it is an artboard guide: it runs across that artboard only, moves with it (`artboard.move`, a pure move in `artboard.setProps`, `artboard.rearrange`), scales with it (`artboard.setProps {scaleArt}`), is copied with it (`artboard.duplicate`, `artboard.move {copy}`) and is @@ -2236,8 +2292,8 @@ with guides unlocked, as the preference is off until `prefs.set` turns it on. `view.guides.release {index?, ids?}` (View › Guides › Release Guides) turns guides into paths → `{count}`. With `index` it releases that ruler guide, and with `ids` it releases those guide paths (and the guide paths inside them). Otherwise it releases the selected guide paths and ruler guides, and with no guide selected it releases every guide -path. A released ruler guide becomes a straight path with no fill or stroke, at the top of the guide's layer (or where -new art goes, for a guide on no layer). An artboard guide becomes a line across its artboard, and a canvas guide +path. A released ruler guide becomes a straight path with no fill or stroke, at the top of the art of the guide's +layer. An artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the bounds of all the artboards. `index` and `ids` are refused while guides are locked. The release is one undo step, and the released paths are selected. With the Selection, Direct Selection or Group Selection tool, a double-click with Cmd/Ctrl and Shift held releases the ruler guide or guide path under the pointer @@ -3163,8 +3219,8 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: five generated in code (Art Ribbons, Calligraphic Nibs, Pattern -Borders, Scatter Shapes, Game Trails), the `.vectorcraft` files in the user library folder of the desktop app (User +Brush libraries are read-only sets of brushes: six generated in code (Art Ribbons, Calligraphic Nibs, Pattern +Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `brush.library {library}` returns one's brushes (`name`, `type`; `library` is an id or a name). diff --git a/docs/roadmap.md b/docs/roadmap.md index 1326c4213..1b5574061 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -149,7 +149,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, five generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, six generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | @@ -190,6 +190,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | M10/G5: Energy Beams built-in brush library; MCP docs catch-up; estimates unchanged | | 2026-11-11 | minor | M10/G5: Skill Icons built-in symbol library; MCP docs catch-up; estimates unchanged | | 2026-11-10 | minor | M10/G5: Status Effects built-in graphic style library; MCP docs catch-up for UI Frames and Map Markers; estimates unchanged | | 2026-11-10 | minor | M10/G5: Inventory Icons built-in symbol library (Sword, Shield, Potion, Key, Gem); estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 80186e7bc..82ab399a2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -218,7 +218,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (five generated libraries including Game Trails, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (six generated libraries including Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | @@ -487,7 +487,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (five generated in code including Game Trails are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (six generated in code including Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -539,7 +539,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~44% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries five (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries six (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -574,6 +574,7 @@ since 2026-10-01. **Review follow-up:** The fit-query follow-up covers fitting after draft dimensions change, updated artwork after a document revision, cache scope and new-artboard fitting/history. Selection uses the existing application bounds cache; global artwork queries include all drawn document art and are cached by active document and revision; draft geometry and target artboards do not limit membership. This is not a native timing claim. - **Stable status-bar readouts:** The status message keeps its left anchor and clipping allocation as live values, canvas hover and background-job state change. Coordinate and spinner slots stay reserved; narrow windows hide optional diagnostics, then coordinates, to preserve message space. Widths are measured from the active font, translated labels and fixed numeric samples, never current readings. Coordinates through +/-9999.99 selected units, timings through 9999.9 ms and FPS through 9999 remain readable. Extreme values are elided with full tooltip/accessibility text; only elided fields show a tooltip. Coordinate inspection retains the last point per document and clears on PointerGone. Regressions cover message origin/clip/bounds/elision invariance, all registered languages and ten units, responsive layouts, coordinate inspection and document switching, and accessible busy transitions. The contributor rebuilt and confirmed the original status-bar issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. Sample widths use a bounded single-entry UI cache keyed by language, unit and pixels per point; two regressions verify reuse during live updates and invalidation after each display-setting change. +- **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. @@ -736,6 +737,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: the Script Console panel's report line reads as `script.console` gives it, `time unavailable` included on a platform without a clock; counts of host calls, dropped lines and cut lines take the singular for one, in each language's own plural forms; and a run that is no longer available still gets its end line (`crates/engine/src/tests/script_console.rs`, `crates/ui-egui/src/panels/tests_script_console.rs`). - Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), and `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). + - Scripts: `File` and `Folder` ([File object](https://extendscript.docsforadobe.dev/file-system-access/file-object/), [Folder object](https://extendscript.docsforadobe.dev/file-system-access/folder-object/)) name files and folders by platform or URI path, with `name`, `fsName`, `absoluteURI`, `relativeURI`, `parent`, `Folder.current` and `encode`/`decode`, and read them: `exists`, `length`, the dates, `getFiles` with a mask or a function, and `open('r')` with `read`, `readln`, `readch`, `seek`, `tell` and `close`, decoding by byte order mark, `encoding`, UTF-8 or CP1252. VectorCraft's own rules: a script reads only in its own folder and below (and inside the automation read roots when they are set); any other path and a network or device path are refused before anything is looked up, and a link that leads out is refused as well (`false` or `null`, with `error` set); a file is read whole up to 8 MiB and a run reads up to 32 MiB; writing, the file dialogs and `execute` are not available yet. `crates/engine/src/tests/script_io.rs`. - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. @@ -746,6 +748,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. + - Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 16.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. A `\uXXXX` escape is read as its character only where a name may hold it, so `0abc` does not become a name that starts with a digit. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`). - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - `vectorcraft-js` identifiers, written out or as `\uXXXX` and `\u{...}` escapes, take the characters Unicode's `ID_Start` and `ID_Continue` properties allow (https://www.unicode.org/reports/tr31/), plus `$` and, at the start, `_`, as later ECMAScript editions define them (https://tc39.es/ecma262/#sec-names-and-keywords). `Other_ID_Start` characters such as U+2118 and `Other_ID_Continue` characters such as U+00B7 and the zero width non-joiner and joiner are accepted, and no `Pattern_Syntax` or `Pattern_White_Space` character is. Letter categories come from Unicode 16.0, so characters assigned in later Unicode versions are not yet identifier characters. Tests: `crates/js/src/tests.rs` (`identifier_characters_are_unicode_16_id_start_and_id_continue`, which checks every code point, `identifiers_take_the_other_id_start_and_continue_characters`, `identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes`, `no_pattern_syntax_or_white_space_character_is_an_identifier_character`). - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. @@ -819,7 +822,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. @@ -1976,6 +1979,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. | | 2026-11-11 | minor | Bundled content: built-in Status Effects graphic style library; style libraries evidence updated; ~40% → ~42%; MCP Symbol libraries list UI Frames and Map Markers; more built-in graphic-style libraries remain. |