diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index a6505a057..3e097f8fc 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -37,6 +37,7 @@ use dynamic::{symbol_instance_reset, symbol_instance_set_appearance}; use symbols::selected_instances; pub(crate) use symbols::{ expand_instances_under, expandable_instances, instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol, + stored_natural_size, }; use symbols::{ symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_list, symbol_new, symbol_options, symbol_place, symbol_replace, diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 6ab7acad0..406511ff1 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -8,11 +8,12 @@ use super::{ // ---------- symbols ---------- pub(crate) fn natural_size(d: &Document, name: &str) -> (f64, f64) { - d.unknown - .get(SIZES_KEY) - .and_then(|m| m.get(name)) - .and_then(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))) - .unwrap_or((2.0 * HALF, 2.0 * HALF)) + stored_natural_size(d, name).unwrap_or((2.0 * HALF, 2.0 * HALF)) +} + +/// The natural size `d` records for symbol `name` (`None` when it records none). +pub(crate) fn stored_natural_size(d: &Document, name: &str) -> Option<(f64, f64)> { + d.unknown.get(SIZES_KEY).and_then(|m| m.get(name)).and_then(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))) } pub(crate) fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) { diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index aa8f3b7df..1f2707fbe 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -24,7 +24,7 @@ use vectorcraft_geom::{PathData, Rect}; pub use flavours::{AI, BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture}; pub use flavours::{ImageRead, read_image}; pub(crate) use resources::SwatchChoices; -pub use resources::{ArtResources, art_resources}; +pub use resources::{ArtResources, SymbolOptions, art_resources}; use super::{CommandSpec, Result, Session, always, bad, cmd, f64_or, has_doc, point_param, str_param}; use crate::DocState; @@ -70,6 +70,8 @@ pub struct Clipboard { pub images: BTreeMap, /// Symbols the objects place (also inside other symbols and patterns). pub symbols: Vec, + /// The Symbol Options of [`Self::symbols`] (which the document keeps by name), by symbol name. + pub symbol_options: BTreeMap, /// Patterns the objects paint with (pasting makes their swatches again). pub patterns: Vec, /// Global and spot swatches the objects' colours link to, the tint swatches of the tints they diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 6a436ab31..22159ca2a 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -20,7 +20,12 @@ use std::sync::Arc; use serde_json::{Map, Value, json}; use vectorcraft_brush::Brush; use vectorcraft_color::{Color, Paint, Swatch}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, GraphicStyle, Node, NodeKind, PatternDef, Symbol, TextStyleDef}; +use vectorcraft_doc::{ + Appearance, AppearanceItem, Document, GraphicStyle, Node, NodeKind, PatternDef, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration, + TextStyleDef, +}; + +use super::super::brushsym::{set_natural_size, stored_natural_size}; use super::super::patterncmds::add_pattern; use super::super::{Result, bad, unique_name}; @@ -628,6 +633,38 @@ fn shows_definitions(b: &Brush) -> bool { found } +/// A symbol's Symbol Options, which its document keeps by symbol name: copied with the symbol and +/// given to it under the name pasting adds it as. +#[derive(Clone, Debug, PartialEq)] +pub struct SymbolOptions { + /// Natural size `(w, h)` (`None`: the document records none). + pub size: Option<(f64, f64)>, + pub registration: SymbolRegistration, + pub export_type: SymbolExportType, + /// 9-slice guides (`None`: 9-slice scaling off). + pub nine_slice: Option, +} + +impl SymbolOptions { + /// The options `d` keeps for symbol `name`. + fn of(d: &Document, name: &str) -> Self { + Self { + size: stored_natural_size(d, name), + registration: d.symbol_registration(name), + export_type: d.symbol_export_type(name), + nine_slice: d.symbol_nine_slice(name), + } + } + + /// Make them the options of `d`'s symbol `name`. + fn give(&self, d: &mut Document, name: &str) { + set_natural_size(d, name, self.size); + d.set_symbol_registration(name, self.registration); + d.set_symbol_export_type(name, self.export_type); + d.set_symbol_nine_slice(name, self.nine_slice); + } +} + /// What pasting did to the document's resources (part of the paste result). #[derive(Debug, Default)] pub(crate) struct Imported { @@ -671,6 +708,12 @@ impl Clipboard { Self { images: src.images.iter().filter(|(k, _)| used.has(Res::Image, k)).map(|(k, b)| (k.clone(), b.clone())).collect(), symbols: src.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)).cloned().collect(), + symbol_options: src + .symbols + .iter() + .filter(|s| used.has(Res::Symbol, &s.name)) + .map(|s| (s.name.clone(), SymbolOptions::of(src, &s.name))) + .collect(), patterns: src.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)).cloned().collect(), swatches: src.swatches_iter().filter(|w| used.carries(w)).cloned().collect(), graphic_styles: src.graphic_styles.iter().filter(|g| g.id != 0 && used.styles.contains(&g.id)).cloned().collect(), @@ -725,13 +768,27 @@ impl Clipboard { out.placed(r, &def.name, &p); } } - // Symbols and patterns, compared once relinked. Those added are relinked at the end, when - // every new name and graphic style id is known. + // Symbols and patterns, compared once relinked (symbols with their options too). Those + // added are relinked at the end, when every new name and graphic style id is known. let mut added_symbols = HashSet::new(); for def in self.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)) { + let options = self.symbol_options.get(&def.name); + let theirs: HashMap = match options { + Some(_) => { + d.symbols.iter().filter(|s| copy_of(&def.name, &s.name)).map(|s| (s.name.clone(), SymbolOptions::of(d, &s.name))).collect() + } + None => HashMap::new(), + }; let r = &out.relink; - let p = place(&mut d.symbols, def, same_doc, |a, b| same_art(r, std::slice::from_ref(&a.art), std::slice::from_ref(&b.art))); - added_symbols.extend(p.new_name(&def.name)); + let p = place(&mut d.symbols, def, same_doc, |a, b| { + options.is_none_or(|o| theirs.get(&a.name) == Some(o)) && same_art(r, std::slice::from_ref(&a.art), std::slice::from_ref(&b.art)) + }); + if let Some(name) = p.new_name(&def.name) { + if let Some(o) = options { + o.give(d, &name); + } + added_symbols.insert(name); + } out.placed(Res::Symbol, &def.name, &p); } let mut added_patterns = HashSet::new(); diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index e7ddbc7c0..79b336df0 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -850,3 +850,94 @@ fn artwork_drop_uses_the_current_layer_even_when_paste_remembers_hidden_or_locke assert!(doc(&s).paste_remembers_layers, "the preference still applies to ordinary Paste"); } } + +/// Two touching rectangles made into 9-slice symbol `Audit`, its instance scaled 150% × 75% and +/// copied (#5902). +fn copied_nine_slice_instance(s: &mut Session) -> Document { + let red = rect(s, 30.0, 30.0, 80.0, 60.0); + s.execute("paint.setFill", &json!({"ids": [red.0], "color": "#ff0000"})).unwrap(); + let blue = rect(s, 110.0, 90.0, 40.0, 30.0); + s.execute("paint.setFill", &json!({"ids": [blue.0], "color": "#0000ff"})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("symbol.new", &json!({"name": "Audit", "nineSlice": true, "registration": 0, "exportType": "graphic"})).unwrap(); + s.execute("object.scale", &json!({"sx": 150, "sy": 75})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + doc(s).clone() +} + +/// The symbol options pasted symbol `to` should have: those of `from` in `src`. +fn assert_same_symbol_options(src: &Document, from: &str, d: &Document, to: &str) { + assert!(src.symbol_nine_slice(from).is_some()); + assert_eq!(d.symbol_nine_slice(to), src.symbol_nine_slice(from)); + assert_eq!(d.symbol_registration(to), src.symbol_registration(from)); + assert_eq!(d.symbol_export_type(to), src.symbol_export_type(from)); + assert_eq!(d.symbol_natural_xf(to), src.symbol_natural_xf(from)); +} + +#[test] +fn a_nine_slice_symbol_pasted_into_another_document_keeps_its_options_and_look() { + let mut s = session(); + let src = copied_nine_slice_instance(&mut s); + let before = render(&s); + new_doc(&mut s); + s.execute("edit.pasteInPlace", &json!({})).unwrap(); + assert_same_symbol_options(&src, "Audit", doc(&s), "Audit"); + assert!(before == render(&s), "the pasted instance draws as in the source"); + // Pasting again uses the symbol the first paste added. + let v = s.execute("edit.pasteInPlace", &json!({})).unwrap(); + assert_eq!(v["added"], 0, "{v}"); +} + +#[test] +fn a_renamed_nine_slice_symbol_keeps_its_options_under_the_new_name() { + let mut s = session(); + let src = copied_nine_slice_instance(&mut s); + new_doc(&mut s); + let other = ellipse(&mut s); + sel(&mut s, &[other]); + s.execute("symbol.new", &json!({"name": "Audit"})).unwrap(); + let own = doc(&s).clone(); + let v = s.execute("edit.pasteInPlace", &json!({})).unwrap(); + assert!(renamed(&v).contains(&("symbol".into(), "Audit".into(), "Audit 2".into())), "{v}"); + assert_same_symbol_options(&src, "Audit", doc(&s), "Audit 2"); + // The document's own symbol keeps its options. + assert_eq!(doc(&s).symbol_nine_slice("Audit"), None); + assert_eq!(doc(&s).symbol_natural_xf("Audit"), own.symbol_natural_xf("Audit")); +} + +#[test] +fn a_same_named_symbol_with_other_options_is_not_reused() { + let mut s = session(); + let src = copied_nine_slice_instance(&mut s); + new_doc(&mut s); + s.execute("edit.pasteInPlace", &json!({})).unwrap(); + // The document's `Audit` has the same art but 9-slice scaling off now. + s.execute("symbol.options", &json!({"name": "Audit", "nineSlice": false})).unwrap(); + let v = s.execute("edit.pasteInPlace", &json!({})).unwrap(); + assert!(renamed(&v).contains(&("symbol".into(), "Audit".into(), "Audit 2".into())), "{v}"); + assert_same_symbol_options(&src, "Audit", doc(&s), "Audit 2"); + assert_eq!(doc(&s).symbol_nine_slice("Audit"), None, "the document's own symbol keeps its options"); +} + +#[test] +fn a_symbol_without_a_recorded_natural_size_is_pasted_without_one() { + let mut s = session(); + let r = rect(&mut s, 30.0, 30.0, 80.0, 60.0); + sel(&mut s, &[r]); + s.execute("symbol.new", &json!({"name": "Plain"})).unwrap(); + s.edit("Forget size", |d, _| { + if let Some(m) = d.unknown.get_mut(vectorcraft_doc::SYMBOL_SIZES).and_then(Value::as_object_mut) { + m.remove("Plain"); + } + Ok(()) + }) + .unwrap(); + assert_eq!(doc(&s).unknown.get(vectorcraft_doc::SYMBOL_SIZES).and_then(|m| m.get("Plain")), None); + s.execute("edit.copy", &json!({})).unwrap(); + let before = render(&s); + new_doc(&mut s); + s.execute("edit.pasteInPlace", &json!({})).unwrap(); + assert!(doc(&s).symbols.iter().any(|x| x.name == "Plain")); + assert_eq!(doc(&s).unknown.get(vectorcraft_doc::SYMBOL_SIZES).and_then(|m| m.get("Plain")), None, "no size is made up for it"); + assert!(before == render(&s), "the pasted instance draws as in the source"); +}