Fix #4926: New Symbols from Layers

This commit is contained in:
bflatastic
2026-11-01 02:44:16 +07:00
parent 916b60898a
commit 68391bdde4
37 changed files with 393 additions and 34 deletions
+106 -13
View File
@@ -1,29 +1,45 @@
//! Document Tools (`addon.document.*`), a built-in add-on: Save a Timestamped Copy writes a copy of
//! the document named with the date and time through File › Save a Copy's save. Its submenu
//! (Object › Plug-ins › Document Tools) is the UI's.
//! the document named with the date and time through File › Save a Copy's save; New Symbols from
//! Layers makes a symbol of each layer's art as New Symbol does. Its submenu (Object › Plug-ins ›
//! Document Tools) is the UI's.
#[cfg(test)]
mod tests;
use serde_json::{Value, json};
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeId, NodeKind, SymbolRegistration};
use super::super::brushsym::{instance_in_place, normalize, place_xf, store_symbol};
use super::super::fileio::{self, SaveMode};
use super::super::{CommandSpec, bad, clock_date, cmd, date_param, has_doc, str_param};
use super::super::{CommandSpec, bad, checked_ids_param, clock_date, cmd, date_param, has_doc, str_param, switch_or};
use crate::{Result, Session};
/// The most numbers (`-2`, `-3`…) tried after a timestamped name that is taken.
const MAX_NUMBER: u32 = 999;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"addon.document.saveTimestampedCopy",
"Save a Timestamped Copy",
["Object", "Plug-ins", "Document Tools"],
None,
"{folder? (default: the document's own folder; needed for a document never saved), format?: vectorcraft|pdf|svg|svgz|ai|eps (default: the document's own, else vectorcraft), time?: Unix seconds (default: now)} write a copy of the document named <name>_YYYY-MM-DD_HH-MM.<ext> (the date and time in UTC; -2, -3… added when that name is taken) as File › Save a Copy writes it: the document keeps its path, title and modified state, and no undo step is made. Off where there is no clock (the web) → {path, format, bytes, warnings}",
has_clock,
save_timestamped_copy
)]
vec![
cmd!(
"addon.document.saveTimestampedCopy",
"Save a Timestamped Copy",
["Object", "Plug-ins", "Document Tools"],
None,
"{folder? (default: the document's own folder; needed for a document never saved), format?: vectorcraft|pdf|svg|svgz|ai|eps (default: the document's own, else vectorcraft), time?: Unix seconds (default: now)} write a copy of the document named <name>_YYYY-MM-DD_HH-MM.<ext> (the date and time in UTC; -2, -3… added when that name is taken) as File › Save a Copy writes it: the document keeps its path, title and modified state, and no undo step is made. Off where there is no clock (the web) → {path, format, bytes, warnings}",
has_clock,
save_timestamped_copy
),
cmd!(
"addon.document.symbolsFromLayers",
"New Symbols from Layers",
["Object", "Plug-ins", "Document Tools"],
None,
"{layers?: [layer id…] (top-level layers; default: those highlighted in the Layers panel when two or more are, else every one), replace?: bool (false: the art stays; true: each layer's art is replaced by an instance of its symbol in the same place)} make one symbol of each layer's art as symbol.new makes one (registration in the centre), named after the layer (with a number added when a symbol has that name), as one undo step. Layers with no art are skipped, and with replace so are locked layers; with replace the instances are selected → {symbols: [name…], ids: [instance id…] (with replace), skipped: [{layer, name, reason: \"empty\"|\"locked\"}…]}",
has_doc,
symbols_from_layers
),
]
}
/// A document is open and the time can be told.
@@ -71,3 +87,80 @@ fn save_timestamped_copy(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| bad(C, "every numbered name for the copy is taken"))?;
fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id }))
}
/// `n` with each layer in it, and itself, made a group of the same art and clipping (a symbol holds
/// no layers).
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 };
}
if let Some(children) = n.children_mut() {
for c in children.iter_mut().filter(|c| matches!(c.kind, NodeKind::Layer { .. })) {
*c = Arc::new(without_layers(c));
}
}
n
}
fn symbols_from_layers(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.document.symbolsFromLayers";
let replace = switch_or(p, "replace", false, C)?;
let st = s.doc()?;
let d = &st.doc;
let layers: Vec<NodeId> = match p.get("layers").filter(|v| !v.is_null()) {
// The layers highlighted in the Layers panel when two or more are (one is mostly just the
// current layer), else every one.
None => {
let rows = st.highlighted_rows();
let picked: Vec<NodeId> = d.layers.iter().map(|l| l.id).filter(|id| rows.contains(id)).collect();
if picked.len() > 1 { picked } else { d.layers.iter().map(|l| l.id).collect() }
}
Some(_) => {
let ids = checked_ids_param(s, p, "layers", C)?;
if let Some(NodeId(id)) = ids.iter().find(|id| !d.layers.iter().any(|l| l.id == **id)) {
return Err(bad(C, format!("{id} is no top-level layer")));
}
d.layers.iter().map(|l| l.id).filter(|id| ids.contains(id)).collect()
}
};
// Each layer with art: its id and name, the art's roots, and the art ready for a symbol.
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<NodeId> = 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() {
[] => None,
[one] => Some(without_layers(one)),
many => Some(without_layers(&Node::group(NodeId(0), many.to_vec()))),
};
let reason = if replace && layer.locked { Some("locked") } else { None };
let ready = art.and_then(|mut art| {
crate::cmd::threads::keep_shown_text(d, &mut art);
normalize(art).ok()
});
match (reason, ready) {
(None, Some(ready)) => made.push((name, roots, ready)),
(reason, _) => skipped.push(json!({ "layer": layer.id.0, "name": name, "reason": reason.unwrap_or("empty") })),
}
}
if made.is_empty() {
return Ok(json!({ "symbols": [], "ids": [], "skipped": skipped }));
}
let (names, ids) = s.edit("New Symbols from Layers", |d, sel| {
let (mut names, mut ids) = (vec![], vec![]);
for (name, roots, (art, size, center)) in &made {
let name = d.unique_symbol_name(name);
store_symbol(d, art, &name, *size);
if replace {
ids.push(instance_in_place(d, roots, &name, place_xf(*center, *size, SymbolRegistration::CENTER))?);
}
names.push(name);
}
if replace {
sel.set(ids.iter().copied());
}
Ok((names, ids))
})?;
Ok(json!({ "symbols": names, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "skipped": skipped }))
}
+100 -2
View File
@@ -1,9 +1,10 @@
//! Document Tools' commands: the copy's name and place, the document left as it was, and bad
//! params refused.
//! Document Tools' commands: the timestamped copy's name and place with the document left as it
//! was, symbols made of layers, and bad params refused.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use crate::Session;
@@ -96,3 +97,100 @@ fn bad_params_are_refused_and_writes_stay_inside_the_automation_roots() {
fn enabled_without_document() -> bool {
Session::new().commands().into_iter().find(|c| c.id == "addon.document.saveTimestampedCopy").unwrap().enabled
}
fn layer_ids(s: &Session) -> Vec<u64> {
s.doc().unwrap().doc.layers.iter().map(|l| l.id.0).collect()
}
fn symbol_names(s: &Session) -> Vec<String> {
s.doc().unwrap().doc.symbols.iter().map(|x| x.name.clone()).collect()
}
/// A document of three layers: `Layer 1` with two rectangles, an empty one, and `Icons` with one
/// ellipse.
fn layered() -> Session {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 50, "height": 50})).unwrap();
s.execute("layer.new", &json!({})).unwrap();
s.execute("layer.new", &json!({"name": "Icons"})).unwrap();
s.execute("shape.ellipse", &json!({"x": 0, "y": 100, "width": 40, "height": 20})).unwrap();
s
}
#[test]
fn a_symbol_is_made_of_each_layer_and_empty_layers_are_reported() {
let mut s = layered();
let before = symbol_names(&s).len();
let n = s.doc().unwrap().history.undo.len();
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "one undo step");
let names: Vec<String> = serde_json::from_value(r["symbols"].clone()).unwrap();
assert_eq!(names.len(), 2, "{r}");
assert!(names.contains(&"Icons".to_string()));
assert_eq!(symbol_names(&s).len(), before + 2);
assert_eq!(r["skipped"].as_array().unwrap().len(), 1, "the empty layer: {r}");
assert_eq!(r["skipped"][0]["reason"], json!("empty"));
assert_eq!(r["ids"], json!([]), "without replace the art stays");
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
// Again: the names are taken, so they get numbers.
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
assert!(r["symbols"].as_array().unwrap().contains(&json!("Icons 2")), "{r}");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(symbol_names(&s).len(), before);
}
#[test]
fn replace_puts_an_instance_where_the_layers_art_was() {
let mut s = layered();
let first = layer_ids(&s)[0];
let bounds = |s: &Session, id: NodeId| s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
let layer = NodeId(first);
let art = bounds(&s, layer);
let r = s.execute("addon.document.symbolsFromLayers", &json!({"layers": [first], "replace": true})).unwrap();
let ids = r["ids"].as_array().unwrap();
assert_eq!(ids.len(), 1);
let inst = NodeId(ids[0].as_u64().unwrap());
let st = s.doc().unwrap();
let kids = st.doc.layers[0].children().unwrap();
assert_eq!(kids.len(), 1);
assert!(matches!(&kids[0].kind, NodeKind::SymbolInstance { .. }));
assert_eq!(st.selection.objects, [inst]);
let now = bounds(&s, layer);
assert!((now.x0 - art.x0).abs() < 1e-6 && (now.y1 - art.y1).abs() < 1e-6 && (now.width() - art.width()).abs() < 1e-6, "{art:?} {now:?}");
assert_eq!(s.doc().unwrap().doc.layers.len(), 3, "the other layers untouched");
}
#[test]
fn locked_layers_keep_their_art_and_bad_layers_are_refused() {
let mut s = layered();
let ids = layer_ids(&s);
s.execute("layer.setProps", &json!({"id": ids[2], "locked": true})).unwrap();
let r = s.execute("addon.document.symbolsFromLayers", &json!({"replace": true})).unwrap();
let skipped: Vec<&str> = r["skipped"].as_array().unwrap().iter().map(|k| k["reason"].as_str().unwrap()).collect();
assert_eq!(skipped, ["empty", "locked"]);
let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let rect = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
for p in [json!({"layers": [rect]}), json!({"layers": [u64::MAX]}), json!({"layers": "all"}), json!({"layers": [-1]}), json!({"replace": "yes"})]
{
assert!(s.execute("addon.document.symbolsFromLayers", &p).is_err(), "{p}");
}
assert!(*s.doc().unwrap().doc == *before.0 && s.doc().unwrap().history.undo.len() == before.1);
// Only an empty layer: nothing made, no step.
let r = s.execute("addon.document.symbolsFromLayers", &json!({"layers": [ids[1]]})).unwrap();
assert_eq!((r["symbols"].clone(), s.doc().unwrap().history.undo.len()), (json!([]), before.1));
}
#[test]
fn the_layers_highlighted_in_the_layers_panel_are_the_default() {
let mut s = layered();
let ids = layer_ids(&s);
// One highlighted row is just the current layer: every layer.
s.execute("layer.setCurrent", &json!({"id": ids[0]})).unwrap();
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
assert_eq!(r["symbols"].as_array().unwrap().len(), 2);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("layer.highlight", &json!({"ids": [ids[1], ids[2]]})).unwrap();
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
assert_eq!((r["symbols"].clone(), r["skipped"].as_array().unwrap().len()), (json!(["Icons"]), 1));
}
+1 -1
View File
@@ -32,7 +32,7 @@ use brushes::{
brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove,
brush_selection_art, brush_set_current, brush_unused, expand_brush,
};
pub(crate) use symbols::{natural_size, normalize, place_xf, set_natural_size};
pub(crate) use symbols::{instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol};
use symbols::{
symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_edit, symbol_list, symbol_new, symbol_options, symbol_place,
symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_update,
+22 -12
View File
@@ -134,6 +134,25 @@ pub(super) fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) }))
}
/// Store `art` ([`normalize`]d, of natural size `size`) as symbol `name`, which becomes the
/// current symbol.
pub(crate) fn store_symbol(d: &mut Document, art: &Node, name: &str, size: (f64, f64)) {
let art = d.reid(art);
d.symbols.push(Symbol { name: name.into(), art: Arc::new(art) });
set_natural_size(d, name, Some(size));
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
}
/// Replace the root nodes `ids` with one instance of symbol `name` (already stored) placed by
/// `xf`. Frames of threaded text among them leave their threads with the text they show. Returns
/// the instance's id.
pub(crate) fn instance_in_place(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result<NodeId> {
let threaded = crate::cmd::threads::frames(d);
let id = replace_with_instance(d, ids, name, xf)?;
crate::cmd::threads::leave_threads(d, &threaded);
Ok(id)
}
/// Replace the root nodes `ids` with one instance of `name` (art already stored). Returns the id.
fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result<NodeId> {
let top = *ids.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?;
@@ -163,17 +182,11 @@ pub(super) fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
// Keep the art where it was: the geometric centre stays at `c`, whatever the registration is.
let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg);
let id = s.edit("New Symbol", |d, sel| {
let art = d.reid(&art);
d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) });
set_natural_size(d, &name, Some(size));
store_symbol(d, &art, &name, size);
d.set_symbol_registration(&name, reg);
d.set_symbol_export_type(&name, export);
d.set_symbol_nine_slice(&name, nine);
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
let threaded = crate::cmd::threads::frames(d);
let id = replace_with_instance(d, &ids, &name, place)?;
// Frames of threaded text in the symbol leave their threads with the text they show.
crate::cmd::threads::leave_threads(d, &threaded);
let id = instance_in_place(d, &ids, &name, place)?;
sel.set([id]);
Ok(id)
})?;
@@ -370,10 +383,7 @@ pub(super) fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let reg = d.symbol_registration(&name);
let threaded = crate::cmd::threads::frames(d);
let id = replace_with_instance(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?;
// Frames of threaded text in the symbol leave their threads with the text they show.
crate::cmd::threads::leave_threads(d, &threaded);
let id = instance_in_place(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?;
sel.set([id]);
Ok(id)
})?;
+3
View File
@@ -595,6 +595,9 @@ fn structured_junk() {
// Refused before anything is written: no format of that name, a time that is no time.
("addon.document.saveTimestampedCopy", json!({"format": "png", "folder": 7})),
("addon.document.saveTimestampedCopy", json!({"format": "svg", "time": [1e308], "folder": null})),
("addon.document.symbolsFromLayers", json!({"layers": "x", "replace": 3})),
("addon.document.symbolsFromLayers", json!({"layers": [1e308, -1, null, u64::MAX], "replace": true})),
("addon.document.symbolsFromLayers", json!({"layers": [], "replace": true})),
// Smart Pen add-on.
("addon.pen.append", json!({"id": 2, "x": 1e308, "y": 0, "in": [1e308, -1e308]})),
("addon.pen.append", json!({"id": 2, "subpath": u64::MAX, "x": 0, "y": 0, "segment": "curved"})),