From d8187b496744998e151a82d476ec1748fb0c4f7b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 05:39:27 -0800 Subject: [PATCH 01/10] Text styles: load character and paragraph styles from another document The Character Styles and Paragraph Styles panel menus get Load Character Styles... or Load Paragraph Styles..., and Load All Styles..., which add the styles of a document picked in an open dialog in one undo step (charStyle.load, paraStyle.load, textStyle.loadAll). The swatches, patterns and symbols the styles use come along as Paste brings them, in use order, so a style or pattern renamed on the way in keeps its links. A style the document already has, under its name or a numbered copy of it, is not added again; another style whose name is taken is added under a free name. The file's Normal styles, and styles with a blank name or attributes that do not read, are not loaded. --- crates/engine/src/cmd/clipboard/resources.rs | 322 +++++++++++++++--- .../engine/src/cmd/fileio/tests_affinity.rs | 4 +- crates/engine/src/cmd/textstyles.rs | 81 +++++ crates/engine/src/cmd/textstyles/tests.rs | 254 ++++++++++++++ crates/engine/tests/import_fuzz.rs | 41 +++ crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/menus/run.rs | 3 + crates/ui-egui/src/menus/ui_commands.rs | 18 + crates/ui-egui/src/panels/text_styles.rs | 37 +- .../ui-egui/src/panels/text_styles/tests.rs | 38 +++ docs/mcp.md | 11 +- docs/target-app-parity.md | 1 + 35 files changed, 825 insertions(+), 57 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 98824d142..bc934ecf0 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -241,9 +241,28 @@ pub(super) struct Used { impl Used { fn of<'a>(defs: &Defs<'a>, nodes: impl IntoIterator) -> Self { let mut used = Used::default(); - let mut todo: Vec> = nodes.into_iter().map(Source::Node).collect(); + used.follow(defs, nodes.into_iter().map(Source::Node).collect()); + used + } + + /// Every character and paragraph style of `defs`, and the resources they use. + fn of_text_styles(defs: &Defs<'_>) -> Self { + let mut used = Used::default(); + let mut todo = vec![]; + for s in defs.char_styles { + used.note(defs, Ref::Name(Res::CharStyle, &s.name), &mut todo); + } + for s in defs.para_styles { + used.note(defs, Ref::Name(Res::ParaStyle, &s.name), &mut todo); + } + used.follow(defs, todo); + used + } + + /// Note the references of `todo`'s sources and of the definitions they lead to. + fn follow<'a>(&mut self, defs: &Defs<'a>, mut todo: Vec>) { while let Some(src) = todo.pop() { - let mut found = |r: Ref<'_>| used.note(defs, r, &mut todo); + let mut found = |r: Ref<'_>| self.note(defs, r, &mut todo); match src { Source::Node(n) => node_refs(n, &mut found), Source::Appearance(a) => appearance_refs(a, &mut found), @@ -251,7 +270,6 @@ impl Used { Source::Paint(p) => paint_refs(p, &mut found), } } - used } /// Note `r`; a definition seen for the first time joins `todo`. @@ -505,7 +523,7 @@ impl SwatchChoices { } /// How pasted art is relinked to the document's resources. -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] struct Relink { renames: BTreeMap>, /// Graphic style id on the clipboard → its id in the document. Links to other ids are dropped. @@ -665,6 +683,115 @@ impl SymbolOptions { } } +/// A character or paragraph style, symbol (with the Symbol Options copied with it) or pattern to +/// place ([`Clipboard::import_used`]). +#[derive(Clone, Copy)] +enum Def<'c> { + Style(Res, &'c TextStyleDef), + Symbol(&'c Symbol, Option<&'c SymbolOptions>), + Pattern(&'c PatternDef), +} + +impl<'c> Def<'c> { + fn key(&self) -> (Res, &'c str) { + match self { + Def::Style(r, s) => (*r, &s.name), + Def::Symbol(s, _) => (Res::Symbol, &s.name), + Def::Pattern(p) => (Res::Pattern, &p.name), + } + } + + /// Does it use one of `defs`, including itself? + fn uses_any(&self, defs: &[Def<'_>]) -> bool { + let mut found = false; + let mut f = |r: Ref<'_>| { + if let Ref::Name(res, n) = r { + found |= defs.iter().any(|o| o.key() == (res, n)); + } + }; + match self { + Def::Style(_, s) => attrs_refs(&s.attrs, &mut f), + Def::Symbol(s, _) => node_refs(&s.art, &mut f), + Def::Pattern(p) => p.art.iter().for_each(|a| node_refs(a, &mut f)), + } + found + } + + /// Is it the definition `d` has under `name`, once relinked by `r`? + fn same_in(&self, d: &Document, name: &str, r: &Relink) -> bool { + match self { + Def::Style(res, s) => { + let theirs = if *res == Res::CharStyle { &d.char_styles } else { &d.para_styles }; + let mut attrs = s.attrs.clone(); + r.attrs(&mut attrs); + theirs.iter().any(|x| x.name == name && x.attrs == attrs) + } + Def::Symbol(s, options) => d.symbols.iter().any(|x| { + x.name == name + && options.is_none_or(|o| SymbolOptions::of(d, name) == *o) + && same_art(r, std::slice::from_ref(&x.art), std::slice::from_ref(&s.art)) + }), + Def::Pattern(p) => { + let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; + d.pattern(name).is_some_and(|x| bare(x) == bare(p) && same_art(r, &x.art, &p.art)) + } + } + } + + /// The names of its kind in `d` that are its name or a numbered copy of it. + fn names_in<'d>(&self, d: &'d Document) -> Vec<&'d str> { + let (res, name) = self.key(); + let names: Vec<&str> = match res { + Res::CharStyle => d.char_styles.iter().map(|s| s.name.as_str()).collect(), + Res::ParaStyle => d.para_styles.iter().map(|s| s.name.as_str()).collect(), + Res::Symbol => d.symbols.iter().map(|s| s.name.as_str()).collect(), + _ => d.patterns.iter().map(|p| p.name.as_str()).collect(), + }; + names.into_iter().filter(|n| copy_of(name, n)).collect() + } +} + +/// `defs` in rounds: each round takes those that use none of the others left, in their order. +/// → (those, the rest, which use each other in a loop or use one that does, in their order) +fn use_order(mut left: Vec>) -> (Vec>, Vec>) { + let mut order = Vec::with_capacity(left.len()); + while !left.is_empty() { + let ready: Vec = left.iter().map(|d| !d.uses_any(&left)).collect(); + if !ready.contains(&true) { + break; + } + let mut flags = ready.into_iter(); + let (now, later): (Vec<_>, Vec<_>) = left.into_iter().partition(|_| flags.next().unwrap_or(false)); + order.extend(now); + left = later; + } + (order, left) +} + +/// The names in `d` that `looped` (definitions that use each other, [`use_order`]) all have at +/// once, each the same once relinked by `r` with those names: each one's name or a numbered copy +/// of it, at most 256 combinations tried. `None` when no combination fits. +fn names_of_loop<'d>(d: &'d Document, looped: &[Def<'_>], r: &Relink) -> Option> { + let names: Vec> = looped.iter().map(|def| def.names_in(d)).collect(); + let count = names.iter().try_fold(1usize, |n, l| n.checked_mul(l.len()).filter(|n| *n <= 256))?; + (0..count).find_map(|mut i| { + let pick: Vec<&str> = names + .iter() + .map(|l| { + let n = l.get(i % l.len()).copied().unwrap_or_default(); + i /= l.len(); + n + }) + .collect(); + let mut guess = r.clone(); + for (def, name) in looped.iter().zip(&pick) { + let (res, own) = def.key(); + guess.renames.entry(res).or_default().insert(own.to_string(), name.to_string()); + } + looped.iter().zip(&pick).all(|(def, name)| def.same_in(d, name, &guess)).then_some(pick) + }) +} + /// What pasting did to the document's resources (part of the paste result). #[derive(Debug, Default)] pub(crate) struct Imported { @@ -704,6 +831,12 @@ impl Clipboard { pub(crate) fn gather(src: &Document, nodes: Vec) -> Self { let library = OnceCell::new(); let used = Used::of(&Defs::of(src, &library), &nodes); + Self::with_used(src, &used, &library, nodes) + } + + /// `nodes` and the resources of `src` that `used` names (`library`: the brush library of `src`, + /// read when a brush was used). + fn with_used(src: &Document, used: &Used, library: &OnceCell>, nodes: Vec) -> Self { let pick = |r: Res, list: &[TextStyleDef]| list.iter().filter(|s| used.has(r, &s.name)).cloned().collect::>(); // Read when a brush was used. let brushes = match (used.names.get(&Res::Brush), library.get()) { @@ -757,61 +890,108 @@ impl Clipboard { /// Add the resources the objects use to `d` (inside the paste's undo step) and work out how /// pasted copies relink to them. pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Imported { - let used = self.used(); + self.import_used(d, &self.used(), choices, same_doc, false) + } + + /// [`Self::import_into`] for the resources `used` names. The character and paragraph styles + /// come before the symbols and patterns, whose art may set type in them; with `in_use_order`, + /// each style, symbol and pattern comes after the styles, symbols and patterns it uses + /// ([`use_order`]), so that it is compared and relinked with their names here. + fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported { let mut out = Imported::default(); - self.import_images(d, &used, same_doc, &mut out); - self.import_swatches(d, &used, choices, same_doc, &mut out); + self.import_images(d, used, same_doc, &mut out); + self.import_swatches(d, used, choices, same_doc, &mut out); // Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art // they may brush; the others once those are in. - let mut added_brushes: HashSet = self.import_brushes(d, &used, same_doc, false, &mut out).into_iter().collect(); - // Character and paragraph styles (their colours relinked first). - for (r, mine, theirs) in [(Res::CharStyle, &self.char_styles, &mut d.char_styles), (Res::ParaStyle, &self.para_styles, &mut d.para_styles)] { - for def in mine.iter().filter(|s| used.has(r, &s.name)) { - let mut s = def.clone(); + let mut added_brushes: HashSet = self.import_brushes(d, used, same_doc, false, &mut out).into_iter().collect(); + let mut defs: Vec> = vec![]; + for (r, list) in [(Res::CharStyle, &self.char_styles), (Res::ParaStyle, &self.para_styles)] { + defs.extend(list.iter().filter(|s| used.has(r, &s.name)).map(|s| Def::Style(r, s))); + } + defs.extend(self.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)).map(|s| Def::Symbol(s, self.symbol_options.get(&s.name)))); + defs.extend(self.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)).map(Def::Pattern)); + let mut looped = vec![]; + if in_use_order { + (defs, looped) = use_order(defs); + } + let first_looped = defs.len(); + // Styles (their colors relinked first), symbols and patterns (compared once relinked, + // symbols with their options too). The symbols and patterns added are relinked at the end, + // when every new name and graphic style id is known; with `in_use_order`, so are the + // styles added, which a loop of uses may place before a pattern they paint with. + let (mut added_symbols, mut added_patterns, mut added_styles) = (HashSet::new(), HashSet::new(), vec![]); + for (i, def) in defs.into_iter().chain(looped.iter().copied()).enumerate() { + // Definitions in a loop of uses that the document has all at once are known under + // those names before they are compared. + if i == first_looped + && let Some(names) = names_of_loop(d, &looped, &out.relink) + { + let names: Vec = names.into_iter().map(str::to_string).collect(); + for (def, name) in looped.iter().zip(names) { + let (res, own) = def.key(); + if own != name { + out.relink.renames.entry(res).or_default().insert(own.to_string(), name); + } + } + } + match def { + Def::Style(r, def) => { + let mut s = def.clone(); + out.relink.attrs(&mut s.attrs); + let theirs = if r == Res::CharStyle { &mut d.char_styles } else { &mut d.para_styles }; + let p = place(theirs, &s, same_doc, PartialEq::eq); + if in_use_order && let Some(name) = p.new_name(&def.name) { + added_styles.push((r, name, def)); + } + out.placed(r, &def.name, &p); + } + Def::Symbol(def, options) => { + 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| { + 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); + } + Def::Pattern(def) => { + // Tile options alike, and the art once relinked. + let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; + let same = |n: &str| d.pattern(n).is_some_and(|x| bare(x) == bare(def) && same_art(&out.relink, &x.art, &def.art)); + let p = existing(&def.name, d.patterns.iter().map(|p| p.name.as_str()), same_doc, same).unwrap_or_else(|| { + // Its swatch comes along under the same name. + let name = unique_name(&def.name, |n| d.pattern(n).is_some() || d.swatch_name_taken(n)); + add_pattern(d, PatternDef { name: name.clone(), ..def.clone() }); + Placed::added(&def.name, name) + }); + added_patterns.extend(p.new_name(&def.name)); + out.placed(Res::Pattern, &def.name, &p); + } + } + } + added_brushes.extend(self.import_brushes(d, used, same_doc, true, &mut out)); + self.import_graphic_styles(d, used, same_doc, &mut out); + for (r, name, def) in added_styles { + let theirs = if r == Res::CharStyle { &mut d.char_styles } else { &mut d.para_styles }; + if let Some(s) = theirs.iter_mut().find(|s| s.name == name) { + s.attrs = def.attrs.clone(); out.relink.attrs(&mut s.attrs); - let p = place(theirs, &s, same_doc, PartialEq::eq); - out.placed(r, &def.name, &p); } } - // 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| { - 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(); - for def in self.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)) { - // Tile options alike, and the art once relinked. - let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; - let same = |n: &str| d.pattern(n).is_some_and(|x| bare(x) == bare(def) && same_art(&out.relink, &x.art, &def.art)); - let p = existing(&def.name, d.patterns.iter().map(|p| p.name.as_str()), same_doc, same).unwrap_or_else(|| { - // Its swatch comes along under the same name. - let name = unique_name(&def.name, |n| d.pattern(n).is_some() || d.swatch_name_taken(n)); - add_pattern(d, PatternDef { name: name.clone(), ..def.clone() }); - Placed::added(&def.name, name) - }); - added_patterns.extend(p.new_name(&def.name)); - out.placed(Res::Pattern, &def.name, &p); - } - added_brushes.extend(self.import_brushes(d, &used, same_doc, true, &mut out)); - self.import_graphic_styles(d, &used, same_doc, &mut out); for s in d.symbols.iter_mut().filter(|s| added_symbols.contains(&s.name)) { out.relink.node(Arc::make_mut(&mut s.art)); } @@ -925,6 +1105,42 @@ impl Clipboard { } } +/// Where a loaded text style is in the document ([`import_text_styles`]). +pub(crate) struct LoadedStyle { + /// A paragraph style (else a character style). + pub para: bool, + /// Its name in the document. + pub name: String, + /// It was added (else the document's own was the same). + pub added: bool, +} + +/// Add every character and paragraph style of `src` to `d`, with the swatches, patterns and +/// what their art uses that the styles' fill and stroke lead to, as Paste adds those of pasted +/// type ([`Clipboard::import_into`]; a global or spot color whose name `d` gives another color +/// is `d`'s). Each style, symbol and pattern is placed after those it uses, so that a style +/// follows a pattern added under a free name and a pattern follows a style renamed on the way +/// in. A style `d` has under its name, or a numbered copy of it ("Body 2"), that is the +/// same once relinked is used; any other is added under a free name. → each style of `src`, in +/// order, character styles first. +pub(crate) fn import_text_styles(src: &Document, d: &mut Document) -> Vec { + let library = OnceCell::new(); + let used = Used::of_text_styles(&Defs::of(src, &library)); + let clip = Clipboard::with_used(src, &used, &library, vec![]); + let before = [&d.char_styles, &d.para_styles].map(|l| l.iter().map(|s| s.name.clone()).collect::>()); + let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true); + let mut placed: Vec = vec![]; + for (para, r, list) in [(false, Res::CharStyle, &clip.char_styles), (true, Res::ParaStyle, &clip.para_styles)] { + for s in list { + let name = out.relink.renamed(r, &s.name); + let again = placed.iter().any(|p| p.para == para && p.name == name); + let added = !again && !before[usize::from(para)].contains(&name); + placed.push(LoadedStyle { para, name, added }); + } + } + placed +} + /// Swatch `w` in `d` ([`existing`]), else added under a free name (swatches and colour groups /// share one namespace). fn add_swatch(d: &mut Document, w: &Swatch, same_doc: bool) -> Placed { diff --git a/crates/engine/src/cmd/fileio/tests_affinity.rs b/crates/engine/src/cmd/fileio/tests_affinity.rs index 70b9b992a..05ca07019 100644 --- a/crates/engine/src/cmd/fileio/tests_affinity.rs +++ b/crates/engine/src/cmd/fileio/tests_affinity.rs @@ -98,7 +98,9 @@ fn templates_and_libraries_cannot_silently_extract_preview_content() { let original = s.doc().unwrap().doc.clone(); for name in ["test.af", "renamed.svg"] { let p = json!({"name":name, "dataBase64":vectorcraft_format::base64_encode(&file(&png()))}); - for cmd in ["file.newFromTemplate", "swatch.library.load", "graphicStyle.loadLibrary"] { + for cmd in + ["file.newFromTemplate", "swatch.library.load", "graphicStyle.loadLibrary", "charStyle.load", "paraStyle.load", "textStyle.loadAll"] + { let e = s.execute(cmd, &p).unwrap_err().to_string(); assert!(e.contains("File › Open"), "{cmd}: {e}"); } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index c6442c193..62d94a89a 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -10,6 +10,7 @@ use serde::de::DeserializeOwned; use serde_json::{Map, Value, json}; use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TextStyleDef}; +use super::clipboard::resources::import_text_styles; use super::typecmd::{refresh_bounds, text_targets}; use super::{CommandSpec, Result, Run, Session, bad, bool_or, has_doc, id_param, index, ok, str_param}; @@ -111,10 +112,21 @@ macro_rules! style_cmds { |s, _| unused(s, $kind), false, ), + spec( + concat!($p, ".load"), + concat!("Load ", $what, " Styles…"), + &["Window", "Type", $menu], + LOAD_PARAMS, + |s, p| load(s, p, &[$kind]), + true, + ), ] }; } +/// The params of `charStyle.load`, `paraStyle.load` and `textStyle.loadAll`. +const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}})"; + pub fn specs() -> Vec { let mut v = style_cmds!( Kind::Char, @@ -132,6 +144,7 @@ pub fn specs() -> Vec { true, )); v.extend(style_cmds!(Kind::Para, "paraStyle", "Paragraph Styles", "Paragraph", "")); + v.push(spec("textStyle.loadAll", "Load All Styles…", &["Window", "Type"], LOAD_PARAMS, |s, p| load(s, p, &[Kind::Char, Kind::Para]), true)); v } @@ -899,5 +912,73 @@ fn unused(s: &mut Session, kind: Kind) -> Result { Ok(json!({ "names": names })) } +/// The styles of `kind` in `src` that load, with their source names so that art in the file keeps +/// referring to them: not the Normal style, and not one whose attributes don't read or whose name is empty, +/// which are skipped. → (the styles, the names in `src` of those skipped) +fn loadable(src: &Document, kind: Kind) -> (Vec, Vec) { + let (mut defs, mut skipped) = (vec![], vec![]); + for def in kind.defs(src) { + let name = def.name.as_str(); + if name.trim() == kind.normal() { + continue; + } + match attrs_param(src, &json!({ "attrs": def.attrs }), kind, "load", Some(&def.attrs)).ok().flatten().filter(|_| !name.trim().is_empty()) { + Some(attrs) => defs.push(TextStyleDef { name: name.to_string(), attrs }), + None => skipped.push(def.name.clone()), + } + } + (defs, skipped) +} + +/// Load Character Styles, Load Paragraph Styles and Load All Styles: the `kinds` of style of the +/// document `p` gives (`path`, `data` or `dataBase64`) added to the active one with the swatches and +/// patterns they paint with, as Paste adds those of pasted type ([`import_text_styles`]). +fn load(s: &mut Session, p: &Value, kinds: &[Kind]) -> Result { + let label = match kinds { + [Kind::Char] => "Load Character Styles", + [Kind::Para] => "Load Paragraph Styles", + _ => "Load All Styles", + }; + let c = &match kinds { + [kind] => kind.cmd("load"), + _ => "textStyle.loadAll".to_string(), + }; + let path = str_param(p, "path"); + let bytes = match (path, str_param(p, "data"), str_param(p, "dataBase64")) { + (Some(path), ..) => super::fileio::read_file(path)?, + (None, Some(text), _) => text.as_bytes().to_vec(), + (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(c, "bad dataBase64"))?, + _ => return Err(bad(c, "give `path`, `data` or `dataBase64`")), + }; + let file = str_param(p, "name").or(path).unwrap_or("Styles"); + let src = super::fileio::load(file, &bytes).map_err(|e| bad(c, e.to_string()))?; + if src.preview_only { + return Err(bad(c, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning")); + } + // The source with only the styles that load. + let mut src = src.doc; + let mut skipped = [vec![], vec![]]; + for kind in [Kind::Char, Kind::Para] { + let (defs, names) = if kinds.contains(&kind) { loadable(&src, kind) } else { (vec![], vec![]) }; + *kind.defs_mut(&mut src) = defs; + skipped[usize::from(kind == Kind::Para)] = names; + } + let current = s.doc()?.doc.clone(); + let mut trial = (*current).clone(); + let mut placed = import_text_styles(&src, &mut trial); + // An undo step only when the document changes. + if trial != *current { + placed = s.edit(label, |d, _| Ok(import_text_styles(&src, d)))?; + } + let reply = |para: bool| { + let names = |added: bool| placed.iter().filter(|p| p.para == para && p.added == added).map(|p| p.name.as_str()).collect::>(); + json!({ "added": names(true), "existing": names(false), "skipped": skipped[usize::from(para)] }) + }; + Ok(match kinds { + [kind] => reply(*kind == Kind::Para), + _ => json!({ "charStyles": reply(false), "paraStyles": reply(true) }), + }) +} + #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 7c0fd9963..fe06c6aa3 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -733,6 +733,260 @@ fn several_styles_are_deleted_in_one_step() { assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Delete Style"); } +/// A saved document whose character and paragraph styles are `char` and `para` (name, attrs), +/// stored as they are, as `charStyle.load`'s `dataBase64`. +fn styles_file(char: &[(&str, Value)], para: &[(&str, Value)]) -> String { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let mut d = (*s.doc().unwrap().doc).clone(); + let defs = |v: &[(&str, Value)]| { + v.iter().map(|(n, a)| TextStyleDef { name: n.to_string(), attrs: a.as_object().cloned().unwrap_or_default() }).collect::>() + }; + d.char_styles = defs(char).into(); + d.para_styles = defs(para).into(); + vectorcraft_format::base64_encode(&vectorcraft_format::save(&d, false)) +} + +/// Load Character Styles and Load Paragraph Styles copy the other document's styles of their kind +/// after the document's own, in one undo step. A style with the same attributes as the style here +/// of its name, or of its name with a number, is not added again; any other gets the first free of +/// those names, and the style here and the type using it keep their attributes. The file's Normal +/// style is not loaded, and a style whose attributes do not read, or whose name is empty, is +/// skipped. +#[test] +fn styles_load_from_another_document_in_one_step() { + for p in ["charStyle", "paraStyle"] { + let (mut s, id) = session_with_text("Title"); + let normal = if p == "charStyle" { NORMAL_CHAR } else { NORMAL_PARA }; + let key = if p == "charStyle" { "size" } else { "space_after" }; + let size = |v: f64| Value::Object(Map::from_iter([(key.to_string(), json!(v))])); + for (n, v) in [("Body", 12.0), ("Accent", 9.0), ("Accent 2", 1.0)] { + s.execute(&format!("{p}.new"), &json!({"name": n, "attrs": size(v)})).unwrap(); + } + s.execute(&format!("{p}.apply"), &json!({"name": "Accent", "id": id})).unwrap(); + let file = + [("Body", size(12.0)), ("Accent", size(20.0)), ("Fresh", size(5.0)), (normal, size(30.0)), ("Bad", json!({"nope": 1})), (" ", size(2.0))]; + let data = styles_file(&file, &file); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute(&format!("{p}.load"), &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + assert_eq!(r, json!({"added": ["Accent 3", "Fresh"], "existing": ["Body"], "skipped": ["Bad", " "]}), "{p}"); + assert_eq!(listed(&mut s, p), [normal, "Body", "Accent", "Accent 2", "Accent 3", "Fresh"], "{p}"); + let d = s.doc().unwrap().doc.clone(); + let attrs = |name: &str| super::style_attrs(&d, p == "paraStyle", name).map(Value::Object); + assert_eq!((attrs("Accent"), attrs("Accent 3"), attrs(normal)), (Some(size(9.0)), Some(size(20.0)), Some(json!({}))), "{p}"); + // The other kind and the type are as they were. + let (other, mine) = if p == "charStyle" { (&d.para_styles, &d.char_styles) } else { (&d.char_styles, &d.para_styles) }; + let (other_before, mine_before) = + if p == "charStyle" { (&before.para_styles, &before.char_styles) } else { (&before.char_styles, &before.para_styles) }; + assert_eq!((other, &mine[..mine_before.len()]), (other_before, &mine_before[..]), "{p}"); + assert_eq!(d.node(NodeId(id)), before.node(NodeId(id)), "{p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}"); + // Loading the same file again adds nothing and makes no undo step. + let again = s.execute(&format!("{p}.load"), &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + assert_eq!(again["added"], json!([]), "{p}"); + assert_eq!(again["existing"], json!(["Body", "Accent 3", "Fresh"]), "{p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}"); + let undone = s.execute("edit.undo", &json!({})).unwrap()["undone"].clone(); + assert_eq!(undone, if p == "charStyle" { "Load Character Styles" } else { "Load Paragraph Styles" }); + assert_eq!(s.doc().unwrap().doc, before, "{p}"); + } +} + +/// Load All Styles loads both kinds in one undo step, from a path too; a file that does not open, +/// or no file, loads nothing. +#[test] +fn load_all_styles_loads_both_kinds_in_one_step() { + let (mut s, _) = session_with_text("Title"); + let data = styles_file(&[("Lead", json!({"size": 30.0}))], &[("Centered", json!({"justify": "Center"}))]); + let dir = std::env::temp_dir().join(format!("vc-load-styles-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Other.vectorcraft"); + std::fs::write(&path, vectorcraft_format::base64_decode(&data).unwrap()).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + for bad in [json!({}), json!({"data": "not a document", "name": "x.vectorcraft"}), json!({"dataBase64": "!"})] { + assert!(s.execute("textStyle.loadAll", &bad).is_err(), "{bad}"); + } + let r = s.execute("textStyle.loadAll", &json!({"path": path.to_string_lossy()})).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + let one = |name: &str| json!({"added": [name], "existing": [], "skipped": []}); + assert_eq!(r, json!({"charStyles": one("Lead"), "paraStyles": one("Centered")})); + assert_eq!(listed(&mut s, "charStyle"), [NORMAL_CHAR, "Lead"]); + assert_eq!(listed(&mut s, "paraStyle"), [NORMAL_PARA, "Centered"]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Load All Styles"); + assert_eq!(s.doc().unwrap().doc, before); +} + +/// A loaded style brings the pattern it paints with, renamed when the document has another pattern +/// of that name, as Paste brings it; a global color whose name the document gives another color +/// is the document's. +#[test] +fn loaded_styles_bring_the_patterns_and_swatches_they_paint_with() { + // A pattern "Dots" made from a `size` square, a global "Ink", and type painted with each. + let painted = |size: f64, ink: &str| { + let (mut s, id) = session_with_text("Dots"); + let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": size, "height": size})).unwrap()["id"].clone(); + s.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap(); + s.execute("swatch.new", &json!({"name": "Ink", "color": ink, "global": true})).unwrap(); + (s, id) + }; + let (mut src, id) = painted(10.0, "#3366cc"); + src.execute("select.set", &json!({"ids": [id]})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [id]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Patterned", "id": id})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Ink", "ids": [id]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Inked", "id": id})).unwrap(); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false)); + let (mut s, id) = painted(30.0, "#ff0000"); + let r = s.execute("charStyle.load", &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + assert_eq!(r["added"], json!(["Patterned", "Inked"])); + let d = s.doc().unwrap().doc.clone(); + let fill = |name: &str| super::style_attrs(&d, false, name).unwrap()["fill"].to_string(); + assert!(d.pattern("Dots 2").is_some() && fill("Patterned").contains("\"Dots 2\""), "{}", fill("Patterned")); + assert!(fill("Inked").contains("\"Ink\"") && d.swatch("Ink 2").is_none(), "{}", fill("Inked")); + s.execute("charStyle.apply", &json!({"name": "Patterned", "id": id})).unwrap(); + assert!(matches!(&runs(&s, id)[0].style.fill, vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots 2")); +} + +/// A pattern whose type is set in a loaded style that was renamed on the way in ("Accent 2") is +/// the same pattern when the file is loaded again, and so is the style that paints with it, also +/// when "Accent" itself paints with another pattern. +#[test] +fn reloaded_patterns_follow_the_renamed_styles_their_type_uses() { + for patterned in [false, true] { + let (mut src, id) = session_with_text("Tile"); + if patterned { + let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [id]})).unwrap(); + } + src.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 4, "kerning": 40})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Accent", "id": id})).unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap(); + if patterned { + // A solid fill over the style's, so the tile's type itself paints with no pattern. + src.execute("paint.setFill", &json!({"color": "#000000", "ids": [id]})).unwrap(); + } + src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [id]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap(); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false)); + let (mut s, _) = session_with_text("X"); + s.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 10}})).unwrap(); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + s.execute("charStyle.load", &load).unwrap(); + let counts = |s: &Session| { + let d = &s.doc().unwrap().doc; + (d.patterns.len(), d.char_styles.iter().map(|c| c.name.clone()).collect::>()) + }; + let first = counts(&s); + assert!(first.1.contains(&"Accent 2".to_string()) && first.1.contains(&"Tiled".to_string()), "{patterned}: {first:?}"); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!([]), "{patterned}: {r}"); + assert_eq!(counts(&s), first, "{patterned}"); + } +} + +/// A loaded style that paints with a pattern whose type is set in that same style still paints +/// with the pattern when the pattern comes in under a free name ("Dots 2"). The commands refuse to +/// make that loop, so the source is a document edited directly, as a file may hold it. +#[test] +fn a_style_and_the_pattern_that_uses_it_load_together() { + let (mut src, id) = session_with_text("Tile"); + src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap(); + src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [id]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": other})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Painted", "id": other})).unwrap(); + let mut source = (*src.doc().unwrap().doc).clone(); + let painted = source.char_styles.iter().find(|s| s.name == "Painted").unwrap().attrs.clone(); + source.char_styles.retain(|s| s.name != "Painted"); + source.char_styles.iter_mut().find(|s| s.name == "Accent").unwrap().attrs = painted; + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&source, false)); + let fill = |s: &Session, name: &str| super::style_attrs(&s.doc().unwrap().doc, false, name).unwrap()["fill"].to_string(); + let source_fill = super::style_attrs(&source, false, "Accent").unwrap()["fill"].to_string(); + assert!(source_fill.contains("\"Dots\""), "{source_fill}"); + let (mut s, _) = session_with_text("X"); + let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); + s.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap(); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + s.execute("charStyle.load", &load).unwrap(); + assert!(s.doc().unwrap().doc.pattern("Dots 2").is_some()); + assert!(fill(&s, "Accent").contains("\"Dots 2\""), "{}", fill(&s, "Accent")); + // Loaded again, the two are found under those names, and nothing is added. + let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!([]), "{r}"); + assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), (doc, undo)); +} + +/// A style name from a saved document stays the name used by type in imported pattern art. +#[test] +fn loaded_style_keeps_the_name_used_by_pattern_type() { + let (mut src, id) = session_with_text("Tile"); + src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap(); + src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [id]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap(); + let mut source = (*src.doc().unwrap().doc).clone(); + source.char_styles.iter_mut().find(|s| s.name == "Accent").unwrap().name = " Accent ".into(); + let tile = source.patterns.iter_mut().find(|p| p.name == "Tiles").unwrap(); + let art = std::sync::Arc::make_mut(&mut tile.art[0]); + let NodeKind::Text(text) = &mut art.kind else { panic!("pattern art should be type") }; + text.runs[0].style.style_name = Some(" Accent ".into()); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&source, false)); + let (mut dst, _) = session_with_text("X"); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + dst.execute("charStyle.load", &load).unwrap(); + let doc = &dst.doc().unwrap().doc; + assert!(doc.char_styles.iter().any(|s| s.name == " Accent ")); + let NodeKind::Text(text) = &doc.pattern("Tiles").unwrap().art[0].kind else { panic!("pattern art should be type") }; + assert_eq!(text.runs[0].style.style_name.as_deref(), Some(" Accent ")); +} + +/// A pattern whose tile paints with itself is found under its renamed copy on a second load. +#[test] +fn self_referencing_pattern_is_not_added_again() { + let (mut src, id) = session_with_text("Patterned"); + let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap(); + let tile = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [tile]})).unwrap(); + let art = src.doc().unwrap().doc.node(NodeId(tile.as_u64().unwrap())).unwrap().clone(); + assert!(art.appearance.items.iter().any(|item| matches!(item.paint(), vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots"))); + let mut source = (*src.doc().unwrap().doc).clone(); + source.patterns.iter_mut().find(|p| p.name == "Dots").unwrap().art = vec![std::sync::Arc::new(art)]; + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [id]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Patterned", "id": id})).unwrap(); + source.char_styles = src.doc().unwrap().doc.char_styles.clone(); + assert!(super::style_attrs(&source, false, "Patterned").unwrap()["fill"].to_string().contains("\"Dots\"")); + let (mut dst, _) = session_with_text("X"); + let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); + dst.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [different]})).unwrap(); + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + super::super::clipboard::resources::import_text_styles(&source, target); + assert!(dst.doc().unwrap().doc.pattern("Dots 2").is_some()); + assert!( + dst.doc().unwrap().doc.pattern("Dots 2").unwrap().art[0] + .appearance + .items + .iter() + .any(|item| matches!(item.paint(), vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots 2")) + ); + let before = dst.doc().unwrap().doc.clone(); + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + let again = super::super::clipboard::resources::import_text_styles(&source, target); + assert!(again.iter().all(|s| !s.added)); + assert_eq!(dst.doc().unwrap().doc, before); +} + /// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no /// mojikumi) are still attributes a style can set, and a style captured with one applies to /// text at the default, is duplicated and comes back out of a library. diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index ffa6c9115..453662210 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1148,6 +1148,42 @@ fn styles(what: &str, data: &str) -> Result<(), TestCaseError> { }) } +/// A document with type and a character and a paragraph style, saved as pretty native text: what +/// Load All Styles reads. +fn saved_text_styles() -> String { + static TEXT: std::sync::OnceLock = std::sync::OnceLock::new(); + TEXT.get_or_init(|| { + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Styled"})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Lead", "attrs": {"size": 30, "tracking": 50, "underline": true, "leading": 36}})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center", "space_after": 6, "hyphenate": true}})).unwrap(); + let options = vectorcraft_format::SaveOptions { version: 2, pretty: true, ..vectorcraft_format::SaveOptions::default() }; + let text = String::from_utf8(vectorcraft_format::save_with(&s.doc().unwrap().doc, &options).unwrap()).unwrap(); + // Undamaged, it loads both styles. + let r = rich_session().execute("textStyle.loadAll", &json!({"data": text, "name": "fuzz.vectorcraft"})).unwrap(); + assert_eq!((&r["charStyles"]["added"], &r["paraStyles"]["added"]), (&json!(["Lead"]), &json!(["Centered"]))); + text + }) + .clone() +} + +/// Native file text `data`'s styles loaded into the rich document by Load All Styles, and each +/// style it added applied to all of its type. +fn text_styles(what: &str, data: &str) -> Result<(), TestCaseError> { + survive(what, || { + let mut s = rich_session(); + let r = no_panic(s.execute("textStyle.loadAll", &json!({"data": data, "name": "fuzz.vectorcraft"}))).ok()?; + let _ = no_panic(s.execute("select.all", &json!({}))); + for (list, kind) in [("charStyles", "charStyle"), ("paraStyles", "paraStyle")] { + for name in r[list]["added"].as_array().into_iter().flatten() { + let _ = no_panic(s.execute(&format!("{kind}.apply"), &json!({"name": name}))); + } + } + Some((*s.doc().ok()?.doc).clone()) + }) +} + /// Libraries panel library file `data` imported, then each kind's first item used on the rich /// document, moved into its first group and next to its kind's last item, a colour and the /// selection added into that group next to the first ones, the group moved and deleted and the @@ -1312,6 +1348,11 @@ proptest! { styles("mutated style library", &mutate_text(&text, cut, &edits))?; } + #[test] + fn mutated_text_style_files_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10)) { + text_styles("mutated text styles", &mutate_text(&saved_text_styles(), cut, &edits))?; + } + #[test] fn mutated_flattener_presets_never_panic(cut in 0usize..5_000, edits in prop::collection::vec(arb_edit(), 0..10)) { let text = saved("flattener.presets.export", json!({"names": ["high", "medium", "low"]})); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2046dcdd4..5d8917235 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1768,6 +1768,9 @@ font-technology Variable Променливи Live Paint Selection Tool Инструмент за селекция на динамично оцветяване Live Shape Properties Свойства на динамична фигура Load Color Profile… Зареждане на файлов цвят… + Load Character Styles… Зареждане на стилове на знаци… + Load Paragraph Styles… Зареждане на стилове на абзац… + Load All Styles… Зареждане на всички стилове… Remove Color Profile… Премахване на цветови профил… Load Image into Clipboard Зареждане на изображение в клипборда Load Metafile into Clipboard Зареждане на метафайл в клипборда diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 2a14e902d..77add87f3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3235,6 +3235,9 @@ theme Appearance Motiv Convert Document Color Mode Převést barevný režim dokumentu Gamut Warning Upozornění na gamut Load Color Profile… Načíst barevný profil… + Load Character Styles… Načíst styly znaků… + Load Paragraph Styles… Načíst styly odstavců… + Load All Styles… Načíst všechny styly… Remove Color Profile… Odebrat barevný profil… Plates Desky Spot Color Přímá barva diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 1a0c50b98..826beac28 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2979,6 +2979,9 @@ theme Appearance Udseende Convert Document Color Mode Konvertér dokumentets farvetilstand Gamut Warning Gamut-advarsel Load Color Profile… Indlæs farveprofil… + Load Character Styles… Indlæs tegnstile… + Load Paragraph Styles… Indlæs afsnitsstile… + Load All Styles… Indlæs alle stile… Plates Plader Spot Color Spotfarve Add to Swatches Føj til farveprøver diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3e90a9551..b0cca17b2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1830,6 +1830,9 @@ file-filter Templates Vorlagen Live Paint Selection Tool Interaktiv-malen-Auswahlwerkzeug Live Shape Properties Eigenschaften interaktiver Formen Load Color Profile… Farbprofil laden… + Load Character Styles… Zeichenformate laden… + Load Paragraph Styles… Absatzformate laden… + Load All Styles… Alle Formate laden… Remove Color Profile… Farbprofil entfernen… Load Image into Clipboard Bild in die Zwischenablage laden Load Metafile into Clipboard Metadatei in die Zwischenablage laden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 623ceedc1..2c26e7c69 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2646,6 +2646,9 @@ theme Appearance Εμφάνιση Live Paint Selection Tool Επιλογή δυναμικής βαφής (Live Paint Selection Tool) Live Shape Properties Ιδιότητες δυναμικού σχήματος Load Color Profile… Φόρτωση προφίλ χρώματος… + Load Character Styles… Φόρτωση στυλ χαρακτήρων… + Load Paragraph Styles… Φόρτωση στυλ παραγράφων… + Load All Styles… Φόρτωση όλων των στυλ… Remove Color Profile… Αφαίρεση προφίλ χρώματος… Load Image into Clipboard Φόρτωση εικόνας στο πρόχειρο Load Metafile into Clipboard Φόρτωση μετααρχείου στο πρόχειρο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 761499dea..cf85ae59f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1833,6 +1833,9 @@ font-technology Variable Variables Live Paint Selection Tool Herramienta Selección de pintura interactiva Live Shape Properties Propiedades de forma dinámica Load Color Profile… Cargar perfil de color… + Load Character Styles… Cargar estilos de carácter… + Load Paragraph Styles… Cargar estilos de párrafo… + Load All Styles… Cargar todos los estilos… Remove Color Profile… Quitar perfil de color… Load Image into Clipboard Cargar imagen en el portapapeles Load Metafile into Clipboard Cargar metarchivo en el portapapeles diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 994b6f7f9..9bf9d0c67 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3174,6 +3174,9 @@ theme Appearance Ulkoasu Convert Document Color Mode Muunna asiakirjan väritila Gamut Warning Väriavaruusvaroitus Load Color Profile… Lataa väriprofiili… + Load Character Styles… Lataa merkkityylit… + Load Paragraph Styles… Lataa kappaletyylit… + Load All Styles… Lataa kaikki tyylit… Remove Color Profile… Poista väriprofiili… Plates Erottelut Spot Color Spottiväri diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 042f05f8d..72e643ba9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1827,6 +1827,9 @@ font-technology Variable Variables Live Paint Selection Tool Outil Sélection de peinture dynamique Live Shape Properties Propriétés de la forme dynamique Load Color Profile… Charger un profil colorimétrique… + Load Character Styles… Charger les styles de caractère… + Load Paragraph Styles… Charger les styles de paragraphe… + Load All Styles… Charger tous les styles… Remove Color Profile… Supprimer un profil colorimétrique… Load Image into Clipboard Charger une image dans le presse-papiers Load Metafile into Clipboard Charger un métafichier dans le presse-papiers diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 702baf273..43b7fd772 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2310,6 +2310,9 @@ file-filter Templates Sablonok Live Paint Selection Tool Élőfestés-kijelölés Live Shape Properties Élő alakzat tulajdonságai Load Color Profile… Színprofil betöltése… + Load Character Styles… Karakterstílusok betöltése… + Load Paragraph Styles… Bekezdésstílusok betöltése… + Load All Styles… Az összes stílus betöltése… Remove Color Profile… Színprofil eltávolítása… Load Image into Clipboard Kép betöltése a vágólapra Load Metafile into Clipboard Metafájl betöltése a vágólapra diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 5b59c3363..21adbb444 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2979,6 +2979,9 @@ theme Appearance Tampilan Convert Document Color Mode Konversi mode warna dokumen Gamut Warning Peringatan gamut Load Color Profile… Muat profil warna… + Load Character Styles… Muat gaya karakter… + Load Paragraph Styles… Muat gaya paragraf… + Load All Styles… Muat semua gaya… Plates Pelat Spot Color Warna spot Add to Swatches Tambahkan ke Swatches diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a24aa3c0c..7a0cbaa72 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1862,6 +1862,9 @@ file-filter Templates Modelli Live Paint Selection Tool Strumento Selezione pittura dinamica Live Shape Properties Proprietà forma dinamica Load Color Profile… Carica profilo colore… + Load Character Styles… Carica stili carattere… + Load Paragraph Styles… Carica stili paragrafo… + Load All Styles… Carica tutti gli stili… Remove Color Profile… Rimuovi profilo colore… Load Image into Clipboard Carica immagine negli appunti Load Metafile into Clipboard Carica metafile negli appunti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 01c282353..827460f5d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2088,6 +2088,9 @@ digits Malayalam マラヤーラム数字 Live Paint Selection Tool ライブペイント選択ツール Live Shape Properties ライブシェイプのプロパティ Load Color Profile… カラープロファイルを読み込む… + Load Character Styles… 文字スタイルを読み込み… + Load Paragraph Styles… 段落スタイルを読み込み… + Load All Styles… すべてのスタイルを読み込み… Remove Color Profile… カラープロファイルを削除… Load Image into Clipboard 画像をクリップボードに読み込む Load Metafile into Clipboard メタファイルをクリップボードに読み込む diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 69b049eeb..009eb7601 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2798,6 +2798,9 @@ theme Appearance იერსახე Convert Document Color Mode დოკუმენტის ფერის რეჟიმის გარდაქმნა Gamut Warning ფერთა გამიდან გასვლის გაფრთხილება Load Color Profile… ფერის პროფილის ჩატვირთვა… + Load Character Styles… სიმბოლოს სტილების ჩატვირთვა… + Load Paragraph Styles… აბზაცის სტილების ჩატვირთვა… + Load All Styles… ყველა სტილის ჩატვირთვა… Plates ფირფიტები Spot Color სპოტ-ფერი Add to Swatches ფერის ნიმუშებში დამატება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b9a58b32a..d584bcf0d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2078,6 +2078,9 @@ digits Malayalam 말라얄람어 Live Paint Selection Tool 라이브 페인트 선택 도구 Live Shape Properties 라이브 모양 속성 Load Color Profile… 색상 프로파일 불러오기… + Load Character Styles… 문자 스타일 불러오기… + Load Paragraph Styles… 단락 스타일 불러오기… + Load All Styles… 모든 스타일 불러오기… Remove Color Profile… 색상 프로파일 제거… Load Image into Clipboard 이미지를 클립보드로 불러오기 Load Metafile into Clipboard 메타파일을 클립보드로 불러오기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 492db3213..12c84ce43 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2993,6 +2993,9 @@ theme Appearance Vormgeving Convert Document Color Mode Kleurmodus van document omzetten Gamut Warning Gamutwaarschuwing Load Color Profile… Kleurprofiel laden… + Load Character Styles… Tekenstijlen laden… + Load Paragraph Styles… Alineastijlen laden… + Load All Styles… Alle stijlen laden… Plates Platen Spot Color Steunkleur Add to Swatches Aan stalen toevoegen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ffb9811f7..94af2b1ac 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3114,6 +3114,9 @@ theme Appearance Wygląd Convert Document Color Mode Konwertuj tryb koloru dokumentu Gamut Warning Ostrzeżenie gamutu Load Color Profile… Wczytaj profil kolorów… + Load Character Styles… Wczytaj style znakowe… + Load Paragraph Styles… Wczytaj style akapitowe… + Load All Styles… Wczytaj wszystkie style… Remove Color Profile… Usuń profil kolorów… Plates Płyty Spot Color Kolor dodatkowy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 80fcaf5b0..a7ec8d313 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1557,6 +1557,9 @@ Live Paint Selection Tool Ferramenta Seleção de Pintura ao Vivo Live Shape Properties Propriedades de Forma ao Vivo Load Color Profile… Carregar Perfil de Cor… + Load Character Styles… Carregar Estilos de Caractere… + Load Paragraph Styles… Carregar Estilos de Parágrafo… + Load All Styles… Carregar Todos os Estilos… Remove Color Profile… Remover Perfil de Cor… Load Image into Clipboard Carregar Imagem na Área de Transferência Load Metafile into Clipboard Carregar Metarquivo na Área de Transferência diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9c48c4018..5af458942 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1859,6 +1859,9 @@ file-filter Templates Шаблоны Live Paint Selection Tool Инструмент «Выделение быстрой заливки» Live Shape Properties Свойства динамической фигуры Load Color Profile… Загрузить профиль цвета… + Load Character Styles… Загрузить стили символов… + Load Paragraph Styles… Загрузить стили абзацев… + Load All Styles… Загрузить все стили… Remove Color Profile… Удалить профиль цвета… Load Image into Clipboard Загрузить изображение в буфер обмена Load Metafile into Clipboard Загрузить метафайл в буфер обмена diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 00ffb45ea..83fcf6ac0 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1774,6 +1774,9 @@ font-technology Variable Variabla Live Paint Selection Tool Markeringsverktyg för direktmålning Live Shape Properties Egenskaper för direktform Load Color Profile… Läs in färgprofil… + Load Character Styles… Läs in teckenstilar… + Load Paragraph Styles… Läs in styckestilar… + Load All Styles… Läs in alla stilar… Remove Color Profile… Ta bort färgprofil… Load Image into Clipboard Läs in bild till urklipp Load Metafile into Clipboard Läs in metafil till urklipp diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index bb1e6b161..264c7020e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1669,6 +1669,9 @@ font-technology Variable Değişken Live Paint Selection Tool Canlı Boyama Seçim Aracı Live Shape Properties Canlı Şekil Özellikleri Load Color Profile… Renk Profilini Yükle… + Load Character Styles… Karakter Stillerini Yükle… + Load Paragraph Styles… Paragraf Stillerini Yükle… + Load All Styles… Tüm Stilleri Yükle… Load Image into Clipboard Resmi Panoya Yükle Load Metafile into Clipboard Meta Dosyasını Panoya Yükle Load PDF into Clipboard PDF'yi Panoya Yükle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 91d7ffb61..0e1195938 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1856,6 +1856,9 @@ file-filter Templates Шаблони Live Paint Selection Tool Інструмент «Виділення швидкої заливки» Live Shape Properties Властивості динамічної фігури Load Color Profile… Завантажити профіль кольору… + Load Character Styles… Завантажити стилі символів… + Load Paragraph Styles… Завантажити стилі абзаців… + Load All Styles… Завантажити всі стилі… Remove Color Profile… Видалити профіль кольору… Load Image into Clipboard Завантажити зображення в буфер обміну Load Metafile into Clipboard Завантажити метафайл у буфер обміну diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ff36f9e77..72ebde638 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2136,6 +2136,9 @@ digits Hindi Hindi Live Paint Selection Tool Công cụ Chọn Tô trực tiếp Live Shape Properties Thuộc tính hình dạng trực tiếp Load Color Profile… Tải hồ sơ màu… + Load Character Styles… Tải kiểu ký tự… + Load Paragraph Styles… Tải kiểu đoạn văn… + Load All Styles… Tải tất cả kiểu… Remove Color Profile… Xóa hồ sơ màu… Load Image into Clipboard Tải ảnh vào bảng tạm Load Metafile into Clipboard Tải metafile vào bảng tạm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 30502fde9..d4e927749 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1744,6 +1744,9 @@ file-filter Templates 模板 Live Paint Selection Tool 实时上色选择工具 Live Shape Properties 实时形状属性 Load Color Profile… 载入颜色配置文件… + Load Character Styles… 载入字符样式… + Load Paragraph Styles… 载入段落样式… + Load All Styles… 载入所有样式… Remove Color Profile… 移除颜色配置文件… Load Image into Clipboard 将图像载入剪贴板 Load Metafile into Clipboard 将图元文件载入剪贴板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7162a7663..e052bbc5d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2708,6 +2708,9 @@ file-filter Templates 範本 Live Paint Selection Tool 即時上色選取工具 Live Shape Properties 即時形狀內容 Load Color Profile… 載入色彩描述檔… + Load Character Styles… 載入字元樣式… + Load Paragraph Styles… 載入段落樣式… + Load All Styles… 載入所有樣式… Remove Color Profile… 移除色彩描述檔… Load Image into Clipboard 將影像載入剪貼簿 Load Metafile into Clipboard 將中繼檔載入剪貼簿 diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index a9e6608a5..104b20459 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -756,6 +756,9 @@ fn run_ui_part9(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::text_styles::load(app, "charStyle.load", s("path")), + "ui.loadParaStyles" => crate::panels::text_styles::load(app, "paraStyle.load", s("path")), + "ui.loadAllStyles" => crate::panels::text_styles::load(app, "textStyle.loadAll", s("path")), id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => match crate::panels::graphic_styles::user_library(app, id) { Some(l) => crate::panels::graphic_styles::open_library(app, &json!({ "library": l.id })), None => Err("no such user library".into()), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index aeaa15577..7eb828313 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -963,6 +963,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{names?: [the styles selected in the Graphic Styles panel]} open Save Graphic Style Library (dialog `saveGraphicStyleLibrary`: name, user: save to the user library folder, selectedOnly); OK runs graphicStyle.saveLibrary", ), + ( + "ui.loadCharStyles", + "Load Character Styles…", + "", + "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's character styles to the active one (engine: charStyle.load), the Character Styles panel menu's Load Character Styles…", + ), + ( + "ui.loadParaStyles", + "Load Paragraph Styles…", + "", + "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's paragraph styles to the active one (engine: paraStyle.load), the Paragraph Styles panel menu's Load Paragraph Styles…", + ), + ( + "ui.loadAllStyles", + "Load All Styles…", + "", + "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's character and paragraph styles to the active one (engine: textStyle.loadAll), Load All Styles… in either panel's menu", + ), ( "window.userGraphicStyleLibrary1", "User Graphic Style Library 1", diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index 8aaff6849..eb2908400 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -10,7 +10,7 @@ use std::cell::RefCell; use egui::{Color32, Sense, Ui, vec2}; use serde_json::{Map, Value, json}; use vectorcraft_doc::Document; -use vectorcraft_engine::cmd::textstyles; +use vectorcraft_engine::cmd::{fileio, textstyles}; use super::character::{end_typing, text_editing, text_style}; use super::memo::Memo; @@ -339,7 +339,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { /// The panel menu. Select All Unused highlights the styles no text uses (`charStyle.unused`, /// `paraStyle.unused`), Duplicate copies every highlighted style and highlights the copies, and -/// Delete deletes every highlighted style but the Normal style. +/// Delete deletes every highlighted style but the Normal style. Load Character Styles… (Load +/// Paragraph Styles…) and Load All Styles… add the styles of a document picked in an open dialog +/// ([`load`]); they are off without an open document or an open dialog. pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let all = styles(app, kind); let listed: Vec<&str> = all.iter().map(|s| s.0.as_str()).collect(); @@ -394,6 +396,37 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { app.run("ui.paramDialog", json!({ "command": format!("{p}.rename"), "label": label, "params": { "name": n, "to": n } })).ok(); } } + ui.separator(); + // As commands, so a file dialog shown off the UI thread loads the styles when it answers. + let can_load = app.session.active().is_some() && app.services.pick_open.is_some(); + let one = if kind == Kind::Char { + (tl!("Load Character Styles…"), "ui.loadCharStyles") + } else { + (tl!("Load Paragraph Styles…"), "ui.loadParaStyles") + }; + for (label, cmd) in [one, (tl!("Load All Styles…"), "ui.loadAllStyles")] { + if menu_item(ui, label, can_load, false) + && let Err(e) = app.run(cmd, json!({})) + { + app.status(e); + } + } +} + +/// Load Character Styles…, Load Paragraph Styles… and Load All Styles… (`ui.loadCharStyles`, +/// `ui.loadParaStyles`, `ui.loadAllStyles`): the engine command `load` (`charStyle.load`, +/// `paraStyle.load`, `textStyle.loadAll`) on the document at `path`, else on one picked in an open +/// dialog. A cancelled dialog loads nothing. +pub(crate) fn load(app: &mut VectorcraftApp, load: &str, path: Option) -> Result { + let path = match path { + Some(path) => path, + None => { + let pick = crate::FilePick { filters: crate::FilePick::filters(fileio::open_filters()), ..Default::default() }; + let Some(path) = crate::picks::open(app, &pick) else { return Ok(Value::Null) }; + path + } + }; + app.run(load, json!({ "path": path })) } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/text_styles/tests.rs b/crates/ui-egui/src/panels/text_styles/tests.rs index 144e2d525..f3218bc35 100644 --- a/crates/ui-egui/src/panels/text_styles/tests.rs +++ b/crates/ui-egui/src/panels/text_styles/tests.rs @@ -766,6 +766,44 @@ fn dragging_a_highlighted_style_carries_every_highlighted_style() { } } +/// The panel menu's Load Character Styles… (Load Paragraph Styles…) adds that kind of style from +/// the document picked in the open dialog, and Load All Styles… adds both kinds, each in one undo +/// step. Without an open dialog the items are off. +#[test] +fn panel_menus_load_the_styles_of_a_picked_document() { + let mut src = vectorcraft_engine::Session::new(); + src.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Lead", "attrs": {"size": 30.0}})).unwrap(); + src.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center"}})).unwrap(); + let dir = std::env::temp_dir().join(format!("vc-panel-load-styles-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Styles.vectorcraft"); + std::fs::write(&path, vectorcraft_format::save(&src.doc().unwrap().doc, false)).unwrap(); + let names = |app: &mut VectorcraftApp, kind| styles(app, kind).into_iter().skip(1).map(|s| s.0).collect::>(); + for kind in [Kind::Char, Kind::Para] { + let (mut app, _) = several_app(); + let ctx = egui::Context::default(); + let items = |app: &mut VectorcraftApp, ui: &mut egui::Ui| menu(app, ui, kind); + let one = format!(" Load {} Styles…", kind.what()); + let doc = app.session.doc().unwrap().doc.clone(); + click_text_held(&ctx, &mut app, &one, egui::Modifiers::NONE, items); + assert_eq!(app.session.doc().unwrap().doc, doc, "{} Styles: off without an open dialog", kind.what()); + let picked = path.to_string_lossy().to_string(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); + let undo = app.session.doc().unwrap().history.undo.len(); + click_text_held(&ctx, &mut app, &one, egui::Modifiers::NONE, items); + let (added, other) = if kind == Kind::Char { ("Lead", Kind::Para) } else { ("Centered", Kind::Char) }; + assert_eq!(names(&mut app, kind), ["A", "B", "C", "D", added], "{} Styles", kind.what()); + assert_eq!(names(&mut app, other), ["A", "B", "C", "D"], "{} Styles: the other kind", kind.what()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + click_text_held(&ctx, &mut app, " Load All Styles…", egui::Modifiers::NONE, items); + assert_eq!(names(&mut app, Kind::Char), ["A", "B", "C", "D", "Lead"], "{} Styles: Load All Styles", kind.what()); + assert_eq!(names(&mut app, Kind::Para), ["A", "B", "C", "D", "Centered"], "{} Styles: Load All Styles", kind.what()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2); + } + std::fs::remove_dir_all(&dir).ok(); +} + /// Feed real text/shortcut events through the canvas Type routing. No focus reset or typing /// settlement here: the capture dispatch itself must close the live typing interaction. fn capture_input(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) { diff --git a/docs/mcp.md b/docs/mcp.md index 889cfedd1..45b7708d5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5930,7 +5930,16 @@ paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes. `charStyle.unused` and `paraStyle.unused` (Select All Unused in the Character Styles and Paragraph Styles panel menus) list the styles with no uses, in panel order and never the Normal style, and the panel highlights them; `charStyle.delete` and -`paraStyle.delete` take `{names}` to delete several styles in one undo step. A Return the Type tool types (or `text.editRange` +`paraStyle.delete` take `{names}` to delete several styles in one undo step. `charStyle.load` and `paraStyle.load` +(Load Character Styles… and Load Paragraph Styles… in the panel menus, where `ui.loadCharStyles` and +`ui.loadParaStyles` pick the file) copy the styles of that kind from another document VectorCraft opens (`path`, `data` +or `dataBase64`), and `textStyle.loadAll` (Load All Styles…, `ui.loadAllStyles`) copies both kinds, each in one undo +step. The swatches and patterns the styles paint with come along as Paste brings those of pasted type; a global or spot +color whose name the document gives another color is the document's. A loaded style that is the same as the style of +its name, or of its name with a number (`Body 2`), is not added again; any other is added under a free name. The +document's styles and the type that uses them keep their attributes, and the file's Normal styles are not loaded. The +reply lists the names `added`, `existing` and `skipped` (a style whose attributes do not read, or whose name is +empty); `textStyle.loadAll` replies `{charStyles, paraStyles}`. A Return the Type tool types (or `text.editRange` inserting `\n`) continues the style of the paragraph it splits; deleting a break keeps the first paragraph's style; `text.setText` gives every paragraph the first one's attributes. Threaded text keeps each paragraph's attributes as the story re-flows (a paragraph split between frames has them in both). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9cd5bd543..123ecf09e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1186,6 +1186,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one. - **Several text styles at once:** in the Character Styles and Paragraph Styles panels, Shift-click highlights a range of styles and Ctrl/Cmd-click adds or removes one, without applying them; the panel menu's Select All Unused (`charStyle.unused`, `paraStyle.unused`) highlights the styles no type uses (in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes), never the Normal style; Delete, the button or the menu item, deletes every highlighted style but Normal in one undo step (`charStyle.delete {names}`, `paraStyle.delete {names}`). The menu's Duplicate copies every highlighted style in one undo step and highlights the copies. A highlighted row dragged onto Create New Style or Delete carries every highlighted style: dropped on Create New Style it copies them all and highlights the copies, and dropped on Delete it deletes them all but Normal and clears the highlight; a row from outside the highlight carries only its own style. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). + - **Load text styles from another document:** the Character Styles and Paragraph Styles panel menus have Load Character Styles… or Load Paragraph Styles… and Load All Styles…, which copy the styles of a document picked in an open dialog, with the swatches and patterns they paint with, in one undo step (`charStyle.load`, `paraStyle.load`, `textStyle.loadAll`); a style the document already has, under its name or a numbered one, is not added again, any other whose name is taken gets a number, and the file's Normal styles are not loaded. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. The Libraries panel holds gradients and patterns: its + button adds the selection's fill or stroke gradient or pattern, a gradient or pattern swatch dragged from the Swatches panel is added, and a click paints the selection through the active Fill/Stroke proxy, a pattern bringing its definition and swatch into the document. A spot colour stays a spot colour: the library keeps its ink name and tint, and using it in any document links the paint to that document's spot swatch of that name, adding it when missing (#5812). From c168302fec36e4dcf922b22f0e7fcbaa423a814e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 06:18:43 -0800 Subject: [PATCH 02/10] Text styles: match each loop of patterns that use each other on its own when loading styles Loading styles placed every definition in a loop of uses, and everything that used one, in one group and tried the product of all their candidate names under one cap of 256. Separate loops multiplied: nine self-referencing patterns, each in the document under its name and as a numbered copy from an earlier load, gave 512 combinations, none were tried, and a repeat load added more numbered copies. One loop with no candidate stopped every other loop from matching. The loops are now the strongly connected components of the uses among those definitions. Each loop is matched with its own search and cap, and a definition that only uses a loop is placed after it, with the loop's names known. --- crates/engine/src/cmd/clipboard/resources.rs | 126 +++++++++++++++---- crates/engine/src/cmd/textstyles/tests.rs | 46 +++++++ 2 files changed, 149 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index bc934ecf0..cf7edc981 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -701,19 +701,24 @@ impl<'c> Def<'c> { } } - /// Does it use one of `defs`, including itself? - fn uses_any(&self, defs: &[Def<'_>]) -> bool { - let mut found = false; - let mut f = |r: Ref<'_>| { + /// Call `f` with each resource it refers to by name. + fn each_use(&self, f: &mut dyn FnMut(Res, &str)) { + let mut g = |r: Ref<'_>| { if let Ref::Name(res, n) = r { - found |= defs.iter().any(|o| o.key() == (res, n)); + f(res, n); } }; match self { - Def::Style(_, s) => attrs_refs(&s.attrs, &mut f), - Def::Symbol(s, _) => node_refs(&s.art, &mut f), - Def::Pattern(p) => p.art.iter().for_each(|a| node_refs(a, &mut f)), + Def::Style(_, s) => attrs_refs(&s.attrs, &mut g), + Def::Symbol(s, _) => node_refs(&s.art, &mut g), + Def::Pattern(p) => p.art.iter().for_each(|a| node_refs(a, &mut g)), } + } + + /// Does it use one of `defs`, including itself? + fn uses_any(&self, defs: &[Def<'_>]) -> bool { + let mut found = false; + self.each_use(&mut |res, n| found |= defs.iter().any(|o| o.key() == (res, n))); found } @@ -768,9 +773,77 @@ fn use_order(mut left: Vec>) -> (Vec>, Vec>) { (order, left) } -/// The names in `d` that `looped` (definitions that use each other, [`use_order`]) all have at -/// once, each the same once relinked by `r` with those names: each one's name or a numbered copy -/// of it, at most 256 combinations tried. `None` when no combination fits. +/// `rest` ([`use_order`]'s definitions that use each other in a loop or use one that does) split +/// into its loops and the definitions outside a loop, each group after the groups it uses: +/// (positions in `rest`, in their order; true for a loop, definitions that use each other or one +/// that uses itself). +fn loop_groups(rest: &[Def<'_>]) -> Vec<(Vec, bool)> { + let mut at: BTreeMap<(Res, &str), Vec> = BTreeMap::new(); + for (i, def) in rest.iter().enumerate() { + at.entry(def.key()).or_default().push(i); + } + let uses: Vec> = rest + .iter() + .map(|def| { + let mut to = vec![]; + def.each_use(&mut |res, n| to.extend(at.get(&(res, n)).into_iter().flatten())); + to.sort_unstable(); + to.dedup(); + to + }) + .collect(); + // Tarjan's strongly connected components: each one comes out after those it reaches. + const NONE: usize = usize::MAX; + let (mut index, mut low, mut on) = (vec![NONE; rest.len()], vec![0; rest.len()], vec![false; rest.len()]); + let (mut stack, mut next, mut groups) = (vec![], 0, vec![]); + for root in 0..rest.len() { + if index[root] != NONE { + continue; + } + let mut calls: Vec<(usize, usize)> = vec![(root, 0)]; + (index[root], low[root], on[root]) = (next, next, true); + next += 1; + stack.push(root); + while let Some(&(v, k)) = calls.last() { + if let Some(&w) = uses[v].get(k) { + if let Some(top) = calls.last_mut() { + top.1 += 1; + } + if index[w] == NONE { + (index[w], low[w], on[w]) = (next, next, true); + next += 1; + stack.push(w); + calls.push((w, 0)); + } else if on[w] { + low[v] = low[v].min(index[w]); + } + continue; + } + calls.pop(); + if let Some(&(u, _)) = calls.last() { + low[u] = low[u].min(low[v]); + } + if low[v] == index[v] { + let mut group = vec![]; + while let Some(w) = stack.pop() { + on[w] = false; + group.push(w); + if w == v { + break; + } + } + group.sort_unstable(); + let looped = group.len() > 1 || uses[v].contains(&v); + groups.push((group, looped)); + } + } + } + groups +} + +/// The names in `d` that `looped` (a loop of definitions that use each other, [`loop_groups`]) +/// all have at once, each the same once relinked by `r` with those names: each one's name or a +/// numbered copy of it, at most 256 combinations tried. `None` when no combination fits. fn names_of_loop<'d>(d: &'d Document, looped: &[Def<'_>], r: &Relink) -> Option> { let names: Vec> = looped.iter().map(|def| def.names_in(d)).collect(); let count = names.iter().try_fold(1usize, |n, l| n.checked_mul(l.len()).filter(|n| *n <= 256))?; @@ -896,7 +969,8 @@ impl Clipboard { /// [`Self::import_into`] for the resources `used` names. The character and paragraph styles /// come before the symbols and patterns, whose art may set type in them; with `in_use_order`, /// each style, symbol and pattern comes after the styles, symbols and patterns it uses - /// ([`use_order`]), so that it is compared and relinked with their names here. + /// ([`use_order`]), so that it is compared and relinked with their names here, and each loop + /// of definitions that use each other is matched on its own ([`loop_groups`]). fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported { let mut out = Imported::default(); self.import_images(d, used, same_doc, &mut out); @@ -910,24 +984,30 @@ impl Clipboard { } defs.extend(self.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)).map(|s| Def::Symbol(s, self.symbol_options.get(&s.name)))); defs.extend(self.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)).map(Def::Pattern)); - let mut looped = vec![]; - if in_use_order { - (defs, looped) = use_order(defs); - } - let first_looped = defs.len(); + // Groups to place in order; true for a loop of definitions that use each other. + let groups: Vec<(Vec>, bool)> = if in_use_order { + let (order, rest) = use_order(defs); + let loops = loop_groups(&rest).into_iter().map(|(at, looped)| (at.into_iter().filter_map(|i| rest.get(i).copied()).collect(), looped)); + std::iter::once((order, false)).chain(loops).collect() + } else { + vec![(defs, false)] + }; // Styles (their colors relinked first), symbols and patterns (compared once relinked, // symbols with their options too). The symbols and patterns added are relinked at the end, // when every new name and graphic style id is known; with `in_use_order`, so are the // styles added, which a loop of uses may place before a pattern they paint with. let (mut added_symbols, mut added_patterns, mut added_styles) = (HashSet::new(), HashSet::new(), vec![]); - for (i, def) in defs.into_iter().chain(looped.iter().copied()).enumerate() { - // Definitions in a loop of uses that the document has all at once are known under - // those names before they are compared. - if i == first_looped - && let Some(names) = names_of_loop(d, &looped, &out.relink) + // Each definition, with the loop it starts. + let steps = + groups.iter().flat_map(|(group, looped)| group.iter().enumerate().map(move |(i, def)| (*def, (*looped && i == 0).then_some(group)))); + for (def, starts) in steps { + // A loop of uses that the document has all at once is known under those names before + // its definitions are compared. + if let Some(group) = starts + && let Some(names) = names_of_loop(d, group, &out.relink) { let names: Vec = names.into_iter().map(str::to_string).collect(); - for (def, name) in looped.iter().zip(names) { + for (def, name) in group.iter().zip(names) { let (res, own) = def.key(); if own != name { out.relink.renames.entry(res).or_default().insert(own.to_string(), name); diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index fe06c6aa3..9998bcafb 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -987,6 +987,52 @@ fn self_referencing_pattern_is_not_added_again() { assert_eq!(dst.doc().unwrap().doc, before); } +/// Patterns whose tiles each paint with themselves are matched one loop at a time: nine of them, +/// each in the document under its name with other art and as the copy an earlier load added, +/// are found under those copies, and a second load adds nothing. +#[test] +fn separate_self_referencing_patterns_are_not_added_again() { + let (mut src, id) = session_with_text("Patterned"); + let mut tiles = vec![]; + for k in 0..9 { + let name = format!("Dots{k}"); + let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("object.pattern.make", &json!({"name": name, "edit": false, "ids": [square]})).unwrap(); + let tile = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("paint.setFill", &json!({"swatch": name, "ids": [tile]})).unwrap(); + tiles.push((name.clone(), src.doc().unwrap().doc.node(NodeId(tile.as_u64().unwrap())).unwrap().clone())); + src.execute("paint.setFill", &json!({"swatch": name, "ids": [id]})).unwrap(); + src.execute("charStyle.new", &json!({"name": format!("Patterned{k}"), "id": id})).unwrap(); + } + let mut source = (*src.doc().unwrap().doc).clone(); + for (name, art) in tiles { + source.patterns.iter_mut().find(|p| p.name == name).unwrap().art = vec![std::sync::Arc::new(art)]; + } + let (mut dst, _) = session_with_text("X"); + for k in 0..9 { + let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); + dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap(); + } + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + super::super::clipboard::resources::import_text_styles(&source, target); + for k in 0..9 { + assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}"); + } + let before = dst.doc().unwrap().doc.clone(); + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + let again = super::super::clipboard::resources::import_text_styles(&source, target); + assert!(again.iter().all(|s| !s.added)); + assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len()); + assert_eq!(dst.doc().unwrap().doc, before); + // With one loop's pattern gone from the document, the others are still found. + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + target.patterns.retain(|p| !p.name.starts_with("Dots8")); + let left = target.patterns.len(); + let third = super::super::clipboard::resources::import_text_styles(&source, target); + assert_eq!(dst.doc().unwrap().doc.patterns.len(), left + 1); + assert!(third.iter().filter(|s| s.name != "Patterned8" && !s.name.starts_with("Patterned8 ")).all(|s| !s.added)); +} + /// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no /// mojikumi) are still attributes a style can set, and a style captured with one applies to /// text at the default, is duplicated and comes back out of a library. From dfb507b7b75fc104675d1e98b1141d7b9b99d2e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 06:52:46 -0800 Subject: [PATCH 03/10] Text styles: a loaded style's pattern brings the styles of either kind its type uses Load Character Styles and Load Paragraph Styles left out every style of the other kind, so a pattern that a loaded style paints with kept a reference to a paragraph or character style the document did not get. The styles of the kind not loaded now come along when such a pattern or symbol sets type in them, renamed like any other. The order in which styles, symbols and patterns are placed is worked out from one graph of their uses instead of scanning every definition left in each round. --- crates/engine/src/cmd/clipboard/resources.rs | 96 +++++++++++--------- crates/engine/src/cmd/textstyles.rs | 16 ++-- crates/engine/src/cmd/textstyles/tests.rs | 38 +++++++- docs/mcp.md | 5 +- 4 files changed, 98 insertions(+), 57 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index cf7edc981..155433bd1 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -245,14 +245,15 @@ impl Used { used } - /// Every character and paragraph style of `defs`, and the resources they use. - fn of_text_styles(defs: &Defs<'_>) -> Self { + /// Every character style of `defs` (with `chars`) and every paragraph style (with `paras`), and + /// the resources they use, styles of the other kind included. + fn of_text_styles(defs: &Defs<'_>, chars: bool, paras: bool) -> Self { let mut used = Used::default(); let mut todo = vec![]; - for s in defs.char_styles { + for s in defs.char_styles.iter().filter(|_| chars) { used.note(defs, Ref::Name(Res::CharStyle, &s.name), &mut todo); } - for s in defs.para_styles { + for s in defs.para_styles.iter().filter(|_| paras) { used.note(defs, Ref::Name(Res::ParaStyle, &s.name), &mut todo); } used.follow(defs, todo); @@ -715,13 +716,6 @@ impl<'c> Def<'c> { } } - /// Does it use one of `defs`, including itself? - fn uses_any(&self, defs: &[Def<'_>]) -> bool { - let mut found = false; - self.each_use(&mut |res, n| found |= defs.iter().any(|o| o.key() == (res, n))); - found - } - /// Is it the definition `d` has under `name`, once relinked by `r`? fn same_in(&self, d: &Document, name: &str, r: &Relink) -> bool { match self { @@ -758,32 +752,31 @@ impl<'c> Def<'c> { /// `defs` in rounds: each round takes those that use none of the others left, in their order. /// → (those, the rest, which use each other in a loop or use one that does, in their order) -fn use_order(mut left: Vec>) -> (Vec>, Vec>) { - let mut order = Vec::with_capacity(left.len()); - while !left.is_empty() { - let ready: Vec = left.iter().map(|d| !d.uses_any(&left)).collect(); - if !ready.contains(&true) { - break; +fn use_order(defs: Vec>) -> (Vec>, Vec>) { + let uses = uses_of(&defs); + // The round of each definition; `None` for one in a loop or one that uses such a definition. + let mut round: Vec> = vec![None; defs.len()]; + // A group outside a loop holds one definition, and comes after the groups it uses. + for (group, looped) in components(&uses) { + for v in group.into_iter().filter(|_| !looped) { + let r = uses[v].iter().try_fold(0, |r, &w| round.get(w).copied().flatten().map(|k| r.max(k + 1))); + round[v] = r; } - let mut flags = ready.into_iter(); - let (now, later): (Vec<_>, Vec<_>) = left.into_iter().partition(|_| flags.next().unwrap_or(false)); - order.extend(now); - left = later; } - (order, left) + let mut ready: Vec<(usize, usize)> = round.iter().enumerate().filter_map(|(i, r)| r.map(|r| (r, i))).collect(); + ready.sort_unstable(); + let order = ready.into_iter().filter_map(|(_, i)| defs.get(i).copied()).collect(); + let rest = defs.iter().zip(&round).filter(|(_, r)| r.is_none()).map(|(def, _)| *def).collect(); + (order, rest) } -/// `rest` ([`use_order`]'s definitions that use each other in a loop or use one that does) split -/// into its loops and the definitions outside a loop, each group after the groups it uses: -/// (positions in `rest`, in their order; true for a loop, definitions that use each other or one -/// that uses itself). -fn loop_groups(rest: &[Def<'_>]) -> Vec<(Vec, bool)> { +/// The positions in `defs` of the definitions each one uses, itself included. +fn uses_of(defs: &[Def<'_>]) -> Vec> { let mut at: BTreeMap<(Res, &str), Vec> = BTreeMap::new(); - for (i, def) in rest.iter().enumerate() { + for (i, def) in defs.iter().enumerate() { at.entry(def.key()).or_default().push(i); } - let uses: Vec> = rest - .iter() + defs.iter() .map(|def| { let mut to = vec![]; def.each_use(&mut |res, n| to.extend(at.get(&(res, n)).into_iter().flatten())); @@ -791,12 +784,26 @@ fn loop_groups(rest: &[Def<'_>]) -> Vec<(Vec, bool)> { to.dedup(); to }) - .collect(); - // Tarjan's strongly connected components: each one comes out after those it reaches. + .collect() +} + +/// `rest` ([`use_order`]'s definitions that use each other in a loop or use one that does) split +/// into its loops and the definitions outside a loop, each group after the groups it uses: +/// (positions in `rest`, in their order; true for a loop, definitions that use each other or one +/// that uses itself). +fn loop_groups(rest: &[Def<'_>]) -> Vec<(Vec, bool)> { + components(&uses_of(rest)) +} + +/// The strongly connected components of the graph `uses` (each node's successors), each after +/// those it reaches: (nodes, in their order; true for a loop, two or more nodes or one that uses +/// itself). +fn components(uses: &[Vec]) -> Vec<(Vec, bool)> { + // Tarjan's algorithm, with an explicit stack of calls. const NONE: usize = usize::MAX; - let (mut index, mut low, mut on) = (vec![NONE; rest.len()], vec![0; rest.len()], vec![false; rest.len()]); + let (mut index, mut low, mut on) = (vec![NONE; uses.len()], vec![0; uses.len()], vec![false; uses.len()]); let (mut stack, mut next, mut groups) = (vec![], 0, vec![]); - for root in 0..rest.len() { + for root in 0..uses.len() { if index[root] != NONE { continue; } @@ -1195,17 +1202,18 @@ pub(crate) struct LoadedStyle { pub added: bool, } -/// Add every character and paragraph style of `src` to `d`, with the swatches, patterns and -/// what their art uses that the styles' fill and stroke lead to, as Paste adds those of pasted -/// type ([`Clipboard::import_into`]; a global or spot color whose name `d` gives another color -/// is `d`'s). Each style, symbol and pattern is placed after those it uses, so that a style -/// follows a pattern added under a free name and a pattern follows a style renamed on the way -/// in. A style `d` has under its name, or a numbered copy of it ("Body 2"), that is the -/// same once relinked is used; any other is added under a free name. → each style of `src`, in -/// order, character styles first. -pub(crate) fn import_text_styles(src: &Document, d: &mut Document) -> Vec { +/// Add every character style of `src` (with `chars`) and every paragraph style (with `paras`) to +/// `d`, with the swatches, patterns and what their art uses (styles of the other kind included) +/// that the styles' fill and stroke lead to, as Paste adds those of pasted type +/// ([`Clipboard::import_into`]; a global or spot color whose name `d` gives another color is +/// `d`'s). Each style, symbol and pattern is placed after those it uses, so that a style follows +/// a pattern added under a free name and a pattern follows a style renamed on the way in. A style +/// `d` has under its name, or a numbered copy of it ("Body 2"), that is the same once relinked is +/// used; any other is added under a free name. → each style of `src` placed, in order, character +/// styles first. +pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Vec { let library = OnceCell::new(); - let used = Used::of_text_styles(&Defs::of(src, &library)); + let used = Used::of_text_styles(&Defs::of(src, &library), chars, paras); let clip = Clipboard::with_used(src, &used, &library, vec![]); let before = [&d.char_styles, &d.para_styles].map(|l| l.iter().map(|s| s.name.clone()).collect::>()); let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true); diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 62d94a89a..47677e78b 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -125,7 +125,7 @@ macro_rules! style_cmds { } /// The params of `charStyle.load`, `paraStyle.load` and `textStyle.loadAll`. -const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}})"; +const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with and the styles of either kind that type in those patterns is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}})"; pub fn specs() -> Vec { let mut v = style_cmds!( @@ -955,20 +955,24 @@ fn load(s: &mut Session, p: &Value, kinds: &[Kind]) -> Result { if src.preview_only { return Err(bad(c, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning")); } - // The source with only the styles that load. + // The source with only the styles that load. Those of a kind not loaded still come along when + // a pattern or symbol that a loaded style paints with sets type in them. let mut src = src.doc; let mut skipped = [vec![], vec![]]; for kind in [Kind::Char, Kind::Para] { - let (defs, names) = if kinds.contains(&kind) { loadable(&src, kind) } else { (vec![], vec![]) }; + let (defs, names) = loadable(&src, kind); *kind.defs_mut(&mut src) = defs; - skipped[usize::from(kind == Kind::Para)] = names; + if kinds.contains(&kind) { + skipped[usize::from(kind == Kind::Para)] = names; + } } + let (chars, paras) = (kinds.contains(&Kind::Char), kinds.contains(&Kind::Para)); let current = s.doc()?.doc.clone(); let mut trial = (*current).clone(); - let mut placed = import_text_styles(&src, &mut trial); + let mut placed = import_text_styles(&src, &mut trial, chars, paras); // An undo step only when the document changes. if trial != *current { - placed = s.edit(label, |d, _| Ok(import_text_styles(&src, d)))?; + placed = s.edit(label, |d, _| Ok(import_text_styles(&src, d, chars, paras)))?; } let reply = |para: bool| { let names = |added: bool| placed.iter().filter(|p| p.para == para && p.added == added).map(|p| p.name.as_str()).collect::>(); diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 9998bcafb..8d68fedc8 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -951,6 +951,34 @@ fn loaded_style_keeps_the_name_used_by_pattern_type() { assert_eq!(text.runs[0].style.style_name.as_deref(), Some(" Accent ")); } +/// Load Character Styles brings the paragraph style that type in a loaded style's pattern is set +/// in, renamed when the document has another style of that name, without listing it in the reply. +#[test] +fn loaded_pattern_type_keeps_its_paragraph_style() { + let (mut src, id) = session_with_text("Tile"); + src.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center"}})).unwrap(); + src.execute("paraStyle.apply", &json!({"name": "Centered", "id": id})).unwrap(); + src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [id]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap(); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false)); + let (mut s, _) = session_with_text("X"); + s.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Right"}})).unwrap(); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r, json!({"added": ["Tiled"], "existing": [], "skipped": []})); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(listed(&mut s, "paraStyle"), [NORMAL_PARA, "Centered", "Centered 2"]); + assert_eq!(super::style_attrs(&d, true, "Centered 2").map(Value::Object), Some(json!({"justify": "Center"}))); + let NodeKind::Text(text) = &d.pattern("Tiles").unwrap().art[0].kind else { panic!("pattern art should be type") }; + assert_eq!(text.para_styles().filter_map(|p| p.style_name.as_deref()).collect::>(), ["Centered 2"]); + // Loaded again, nothing is added. + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!([]), "{r}"); + assert_eq!(s.doc().unwrap().doc, d); +} + /// A pattern whose tile paints with itself is found under its renamed copy on a second load. #[test] fn self_referencing_pattern_is_not_added_again() { @@ -971,7 +999,7 @@ fn self_referencing_pattern_is_not_added_again() { let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); dst.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [different]})).unwrap(); let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - super::super::clipboard::resources::import_text_styles(&source, target); + super::super::clipboard::resources::import_text_styles(&source, target, true, true); assert!(dst.doc().unwrap().doc.pattern("Dots 2").is_some()); assert!( dst.doc().unwrap().doc.pattern("Dots 2").unwrap().art[0] @@ -982,7 +1010,7 @@ fn self_referencing_pattern_is_not_added_again() { ); let before = dst.doc().unwrap().doc.clone(); let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - let again = super::super::clipboard::resources::import_text_styles(&source, target); + let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true); assert!(again.iter().all(|s| !s.added)); assert_eq!(dst.doc().unwrap().doc, before); } @@ -1014,13 +1042,13 @@ fn separate_self_referencing_patterns_are_not_added_again() { dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap(); } let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - super::super::clipboard::resources::import_text_styles(&source, target); + super::super::clipboard::resources::import_text_styles(&source, target, true, true); for k in 0..9 { assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}"); } let before = dst.doc().unwrap().doc.clone(); let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); - let again = super::super::clipboard::resources::import_text_styles(&source, target); + let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true); assert!(again.iter().all(|s| !s.added)); assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len()); assert_eq!(dst.doc().unwrap().doc, before); @@ -1028,7 +1056,7 @@ fn separate_self_referencing_patterns_are_not_added_again() { let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); target.patterns.retain(|p| !p.name.starts_with("Dots8")); let left = target.patterns.len(); - let third = super::super::clipboard::resources::import_text_styles(&source, target); + let third = super::super::clipboard::resources::import_text_styles(&source, target, true, true); assert_eq!(dst.doc().unwrap().doc.patterns.len(), left + 1); assert!(third.iter().filter(|s| s.name != "Patterned8" && !s.name.starts_with("Patterned8 ")).all(|s| !s.added)); } diff --git a/docs/mcp.md b/docs/mcp.md index 45b7708d5..495bdae3f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5934,8 +5934,9 @@ uses, in panel order and never the Normal style, and the panel highlights them; (Load Character Styles… and Load Paragraph Styles… in the panel menus, where `ui.loadCharStyles` and `ui.loadParaStyles` pick the file) copy the styles of that kind from another document VectorCraft opens (`path`, `data` or `dataBase64`), and `textStyle.loadAll` (Load All Styles…, `ui.loadAllStyles`) copies both kinds, each in one undo -step. The swatches and patterns the styles paint with come along as Paste brings those of pasted type; a global or spot -color whose name the document gives another color is the document's. A loaded style that is the same as the style of +step. The swatches and patterns the styles paint with, and the styles of either kind that type in those patterns is set +in, come along as Paste brings those of pasted type; a global or spot color whose name the document gives another color +is the document's. A loaded style that is the same as the style of its name, or of its name with a number (`Body 2`), is not added again; any other is added under a free name. The document's styles and the type that uses them keep their attributes, and the file's Normal styles are not loaded. The reply lists the names `added`, `existing` and `skipped` (a style whose attributes do not read, or whose name is From 8f14e2f0ec8ff5d4d94c260ce2aff27c3e6ef940 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 07:07:10 -0800 Subject: [PATCH 04/10] Text styles: loading skips a style whose sizes or scales are out of range A loaded character style kept a size, scale, leading, shift or stroke width that charStyle.new refuses. Such a style is now skipped and listed in the reply's skipped names. --- crates/engine/src/cmd/textstyles.rs | 13 +++++++++---- crates/engine/src/cmd/textstyles/tests.rs | 18 ++++++++++++++++++ docs/mcp.md | 4 ++-- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 47677e78b..ffc75acc9 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -125,7 +125,7 @@ macro_rules! style_cmds { } /// The params of `charStyle.load`, `paraStyle.load` and `textStyle.loadAll`. -const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with and the styles of either kind that type in those patterns is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}})"; +const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with and the styles of either kind that type in those patterns is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}})"; pub fn specs() -> Vec { let mut v = style_cmds!( @@ -913,16 +913,21 @@ fn unused(s: &mut Session, kind: Kind) -> Result { } /// The styles of `kind` in `src` that load, with their source names so that art in the file keeps -/// referring to them: not the Normal style, and not one whose attributes don't read or whose name is empty, -/// which are skipped. → (the styles, the names in `src` of those skipped) +/// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes, +/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, or whose name +/// is empty, which are skipped. → (the styles, the names in `src` of those skipped) fn loadable(src: &Document, kind: Kind) -> (Vec, Vec) { + const RANGED: [&str; 8] = ["size", "h_scale", "v_scale", "leading", "baseline_shift", "tcyUpDown", "tcyLeftRight", "stroke_width"]; let (mut defs, mut skipped) = (vec![], vec![]); for def in kind.defs(src) { let name = def.name.as_str(); if name.trim() == kind.normal() { continue; } - match attrs_param(src, &json!({ "attrs": def.attrs }), kind, "load", Some(&def.attrs)).ok().flatten().filter(|_| !name.trim().is_empty()) { + // Paints, features and language are taken as the file stores them; the ranged values are checked. + let mut kept = def.attrs.clone(); + kept.retain(|k, _| !RANGED.contains(&k.as_str())); + match attrs_param(src, &json!({ "attrs": def.attrs }), kind, "load", Some(&kept)).ok().flatten().filter(|_| !name.trim().is_empty()) { Some(attrs) => defs.push(TextStyleDef { name: name.to_string(), attrs }), None => skipped.push(def.name.clone()), } diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 8d68fedc8..25ae42b10 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -819,6 +819,24 @@ fn load_all_styles_loads_both_kinds_in_one_step() { assert_eq!(s.doc().unwrap().doc, before); } +/// A loaded character style whose size, scale, leading, shift or stroke width is out of the range +/// `charStyle.new` takes is skipped. +#[test] +fn loaded_styles_out_of_range_are_skipped() { + let (mut s, _) = session_with_text("Title"); + let file = [ + ("Huge", json!({"size": 2000.0})), + ("Flat", json!({"h_scale": 0.5})), + ("Shifted", json!({"baseline_shift": 5000.0})), + ("Thick", json!({"stroke_width": -2.0})), + ("Fine", json!({"size": 12.0, "h_scale": 50.0})), + ]; + let data = styles_file(&file, &[]); + let r = s.execute("charStyle.load", &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + assert_eq!(r, json!({"added": ["Fine"], "existing": [], "skipped": ["Huge", "Flat", "Shifted", "Thick"]})); + assert_eq!(listed(&mut s, "charStyle"), [NORMAL_CHAR, "Fine"]); +} + /// A loaded style brings the pattern it paints with, renamed when the document has another pattern /// of that name, as Paste brings it; a global color whose name the document gives another color /// is the document's. diff --git a/docs/mcp.md b/docs/mcp.md index 495bdae3f..020712101 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5939,8 +5939,8 @@ in, come along as Paste brings those of pasted type; a global or spot color whos is the document's. A loaded style that is the same as the style of its name, or of its name with a number (`Body 2`), is not added again; any other is added under a free name. The document's styles and the type that uses them keep their attributes, and the file's Normal styles are not loaded. The -reply lists the names `added`, `existing` and `skipped` (a style whose attributes do not read, or whose name is -empty); `textStyle.loadAll` replies `{charStyles, paraStyles}`. A Return the Type tool types (or `text.editRange` +reply lists the names `added`, `existing` and `skipped` (a style whose attributes do not read or are out of the range +`charStyle.new` takes, or whose name is empty); `textStyle.loadAll` replies `{charStyles, paraStyles}`. A Return the Type tool types (or `text.editRange` inserting `\n`) continues the style of the paragraph it splits; deleting a break keeps the first paragraph's style; `text.setText` gives every paragraph the first one's attributes. Threaded text keeps each paragraph's attributes as the story re-flows (a paragraph split between frames has them in both). From 4abf9f40815284a2b834b20d6b212bb9a7117431 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 07:24:59 -0800 Subject: [PATCH 05/10] Text styles: loading matches a long loop of patterns that use each other The names a loop of styles and patterns has in the document were searched by trying every combination, up to 256, so a loop of nine with a numbered copy each was not matched and a repeat load added it again. The names are now picked one definition at a time, comparing each definition as soon as the names of those it uses are picked. --- crates/engine/src/cmd/clipboard/resources.rs | 55 ++++++++++++++------ crates/engine/src/cmd/textstyles/tests.rs | 40 ++++++++++++++ 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 155433bd1..72b4ef833 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -850,26 +850,47 @@ fn components(uses: &[Vec]) -> Vec<(Vec, bool)> { /// The names in `d` that `looped` (a loop of definitions that use each other, [`loop_groups`]) /// all have at once, each the same once relinked by `r` with those names: each one's name or a -/// numbered copy of it, at most 256 combinations tried. `None` when no combination fits. +/// numbered copy of it. The names are picked one definition at a time, in order, and each +/// definition is compared as soon as the names of those it uses are picked, so a pick that does +/// not fit is dropped with every combination that follows from it; at most 65,536 comparisons are +/// made. `None` when no combination fits, or none was found within that many comparisons. fn names_of_loop<'d>(d: &'d Document, looped: &[Def<'_>], r: &Relink) -> Option> { let names: Vec> = looped.iter().map(|def| def.names_in(d)).collect(); - let count = names.iter().try_fold(1usize, |n, l| n.checked_mul(l.len()).filter(|n| *n <= 256))?; - (0..count).find_map(|mut i| { - let pick: Vec<&str> = names - .iter() - .map(|l| { - let n = l.get(i % l.len()).copied().unwrap_or_default(); - i /= l.len(); - n - }) - .collect(); - let mut guess = r.clone(); - for (def, name) in looped.iter().zip(&pick) { - let (res, own) = def.key(); - guess.renames.entry(res).or_default().insert(own.to_string(), name.to_string()); + if names.is_empty() || names.iter().any(Vec::is_empty) { + return None; + } // The definitions compared once the name at each position is picked. + let mut compare_at: Vec> = vec![vec![]; looped.len()]; + for (i, uses) in uses_of(looped).iter().enumerate() { + compare_at[uses.iter().copied().fold(i, usize::max)].push(i); + } + let (mut pick, mut guess, mut budget, mut k) = (vec![0usize; looped.len()], r.clone(), 1usize << 16, 0); + loop { + let (res, own) = looped[k].key(); + guess.renames.entry(res).or_default().insert(own.to_string(), names[k][pick[k]].to_string()); + let fits = compare_at[k].iter().all(|&i| { + budget = budget.saturating_sub(1); + looped[i].same_in(d, names[i][pick[i]], &guess) + }); + if fits && k + 1 == looped.len() { + return Some(names.iter().zip(&pick).map(|(l, &p)| l[p]).collect()); } - looped.iter().zip(&pick).all(|(def, name)| def.same_in(d, name, &guess)).then_some(pick) - }) + if budget == 0 { + return None; + } + if fits { + k += 1; + pick[k] = 0; + continue; + } + // The next name at this position, or back to the position before. + loop { + pick[k] += 1; + if pick[k] < names[k].len() { + break; + } + k = k.checked_sub(1)?; + } + } } /// What pasting did to the document's resources (part of the paste result). diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 25ae42b10..91c0a3621 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -1079,6 +1079,46 @@ fn separate_self_referencing_patterns_are_not_added_again() { assert!(third.iter().filter(|s| s.name != "Patterned8" && !s.name.starts_with("Patterned8 ")).all(|s| !s.added)); } +/// Nine patterns whose tiles each paint with the next one, the last with the first, in the +/// document under their names with other art and as the copies an earlier load added, are found +/// under those copies, and a second load adds nothing. +#[test] +fn a_long_loop_of_patterns_is_not_added_again() { + let (mut src, id) = session_with_text("Patterned"); + for k in 0..9 { + let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [square]})).unwrap(); + } + let mut tiles = vec![]; + for k in 0..9 { + let tile = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("paint.setFill", &json!({"swatch": format!("Dots{}", (k + 1) % 9), "ids": [tile]})).unwrap(); + tiles.push(src.doc().unwrap().doc.node(NodeId(tile.as_u64().unwrap())).unwrap().clone()); + } + src.execute("paint.setFill", &json!({"swatch": "Dots0", "ids": [id]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Patterned", "id": id})).unwrap(); + let mut source = (*src.doc().unwrap().doc).clone(); + for (k, art) in tiles.into_iter().enumerate() { + source.patterns.iter_mut().find(|p| p.name == format!("Dots{k}")).unwrap().art = vec![std::sync::Arc::new(art)]; + } + let (mut dst, _) = session_with_text("X"); + for k in 0..9 { + let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); + dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap(); + } + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + super::super::clipboard::resources::import_text_styles(&source, target, true, true); + for k in 0..9 { + assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}"); + } + let before = dst.doc().unwrap().doc.clone(); + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + assert!(again.iter().all(|s| !s.added)); + assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len()); + assert_eq!(dst.doc().unwrap().doc, before); +} + /// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no /// mojikumi) are still attributes a style can set, and a style captured with one applies to /// text at the default, is duplicated and comes back out of a library. From b31ad2c537ca0c7638cdb6ad096480b6054e8d5d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 07:41:00 -0800 Subject: [PATCH 06/10] Text styles: loading adds every style and pattern of a loop the document lacks A style that paints with a pattern whose type is set in that style could be matched to the document's style of the same name and attributes while the pattern came in under a free name, so the loaded style painted with the document's other pattern. When the document does not have such a loop under one set of names, each of its styles, symbols and patterns is now added. --- crates/engine/src/cmd/clipboard/resources.rs | 43 ++++++++++++-------- crates/engine/src/cmd/textstyles/tests.rs | 36 ++++++++++++++++ 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 72b4ef833..a88953def 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -858,7 +858,8 @@ fn names_of_loop<'d>(d: &'d Document, looped: &[Def<'_>], r: &Relink) -> Option< let names: Vec> = looped.iter().map(|def| def.names_in(d)).collect(); if names.is_empty() || names.iter().any(Vec::is_empty) { return None; - } // The definitions compared once the name at each position is picked. + } + // The definitions compared once the name at each position is picked. let mut compare_at: Vec> = vec![vec![]; looped.len()]; for (i, uses) in uses_of(looped).iter().enumerate() { compare_at[uses.iter().copied().fold(i, usize::max)].push(i); @@ -1025,21 +1026,30 @@ impl Clipboard { // when every new name and graphic style id is known; with `in_use_order`, so are the // styles added, which a loop of uses may place before a pattern they paint with. let (mut added_symbols, mut added_patterns, mut added_styles) = (HashSet::new(), HashSet::new(), vec![]); - // Each definition, with the loop it starts. - let steps = - groups.iter().flat_map(|(group, looped)| group.iter().enumerate().map(move |(i, def)| (*def, (*looped && i == 0).then_some(group)))); - for (def, starts) in steps { + // Each definition, whether it is in a loop, and the loop it starts. + let steps = groups + .iter() + .flat_map(|(group, looped)| group.iter().enumerate().map(move |(i, def)| (*def, *looped, (*looped && i == 0).then_some(group)))); + // In a loop the document does not have all at once, every definition uses one that is + // added under a free name, so each one is added too. + let mut fresh = false; + for (def, looped, starts) in steps { + fresh &= looped; // A loop of uses that the document has all at once is known under those names before // its definitions are compared. - if let Some(group) = starts - && let Some(names) = names_of_loop(d, group, &out.relink) - { - let names: Vec = names.into_iter().map(str::to_string).collect(); - for (def, name) in group.iter().zip(names) { - let (res, own) = def.key(); - if own != name { - out.relink.renames.entry(res).or_default().insert(own.to_string(), name); + if let Some(group) = starts { + match names_of_loop(d, group, &out.relink) { + Some(names) => { + let names: Vec = names.into_iter().map(str::to_string).collect(); + for (def, name) in group.iter().zip(names) { + let (res, own) = def.key(); + if own != name { + out.relink.renames.entry(res).or_default().insert(own.to_string(), name); + } + } + fresh = false; } + None => fresh = true, } } match def { @@ -1047,7 +1057,7 @@ impl Clipboard { let mut s = def.clone(); out.relink.attrs(&mut s.attrs); let theirs = if r == Res::CharStyle { &mut d.char_styles } else { &mut d.para_styles }; - let p = place(theirs, &s, same_doc, PartialEq::eq); + let p = place(theirs, &s, same_doc, |a, b| !fresh && a == b); if in_use_order && let Some(name) = p.new_name(&def.name) { added_styles.push((r, name, def)); } @@ -1065,7 +1075,8 @@ impl Clipboard { }; let r = &out.relink; let p = place(&mut d.symbols, def, same_doc, |a, b| { - options.is_none_or(|o| theirs.get(&a.name) == Some(o)) + !fresh + && 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) { @@ -1079,7 +1090,7 @@ impl Clipboard { Def::Pattern(def) => { // Tile options alike, and the art once relinked. let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; - let same = |n: &str| d.pattern(n).is_some_and(|x| bare(x) == bare(def) && same_art(&out.relink, &x.art, &def.art)); + let same = |n: &str| !fresh && d.pattern(n).is_some_and(|x| bare(x) == bare(def) && same_art(&out.relink, &x.art, &def.art)); let p = existing(&def.name, d.patterns.iter().map(|p| p.name.as_str()), same_doc, same).unwrap_or_else(|| { // Its swatch comes along under the same name. let name = unique_name(&def.name, |n| d.pattern(n).is_some() || d.swatch_name_taken(n)); diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 91c0a3621..579de23d4 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -943,6 +943,42 @@ fn a_style_and_the_pattern_that_uses_it_load_together() { assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), (doc, undo)); } +/// A style and the pattern it paints with, whose type is set in that style, are both added when +/// the document has a style of that name and attributes but another pattern of that name: the +/// copy of the style paints with the copy of the pattern, and a second load adds nothing. +#[test] +fn a_style_in_a_loop_is_not_matched_to_a_style_that_paints_with_another_pattern() { + let (mut src, id) = session_with_text("Tile"); + src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap(); + src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [id]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": other})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Painted", "id": other})).unwrap(); + let mut source = (*src.doc().unwrap().doc).clone(); + let painted = source.char_styles.iter().find(|s| s.name == "Painted").unwrap().attrs.clone(); + source.char_styles.retain(|s| s.name != "Painted"); + source.char_styles.iter_mut().find(|s| s.name == "Accent").unwrap().attrs = painted.clone(); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&source, false)); + let (mut s, _) = session_with_text("X"); + let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); + s.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap(); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + doc.char_styles.push(TextStyleDef { name: "Accent".into(), attrs: painted }); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!(["Accent 2"]), "{r}"); + let d = s.doc().unwrap().doc.clone(); + let fill = |name: &str| super::style_attrs(&d, false, name).unwrap()["fill"].to_string(); + assert!(fill("Accent").contains("\"Dots\"") && fill("Accent 2").contains("\"Dots 2\""), "{} {}", fill("Accent"), fill("Accent 2")); + let NodeKind::Text(text) = &d.pattern("Dots 2").unwrap().art[0].kind else { panic!("pattern art should be type") }; + assert_eq!(text.runs[0].style.style_name.as_deref(), Some("Accent 2")); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!([]), "{r}"); + assert_eq!(s.doc().unwrap().doc, d); +} + /// A style name from a saved document stays the name used by type in imported pattern art. #[test] fn loaded_style_keeps_the_name_used_by_pattern_type() { From 63e8e765ac911d79cbdc1ec29cdefecb24fc2b1a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 07:59:41 -0800 Subject: [PATCH 07/10] Text styles: loading places brushes in use order with styles and patterns A brush whose art sets type in a style renamed on the way in was compared before the rename was known, so loading the file again added the brush and what uses it again. Loading now places brushes with the styles, symbols and patterns, each after what it uses. Paste keeps its order. --- crates/engine/src/cmd/clipboard/resources.rs | 58 ++++++++++++++------ crates/engine/src/cmd/textstyles/tests.rs | 37 +++++++++++++ 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index a88953def..3a87fec20 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -684,13 +684,15 @@ impl SymbolOptions { } } -/// A character or paragraph style, symbol (with the Symbol Options copied with it) or pattern to -/// place ([`Clipboard::import_used`]). +/// A character or paragraph style, symbol (with the Symbol Options copied with it), pattern or +/// brush (placed with the others only when they are placed in use order) to place +/// ([`Clipboard::import_used`]). #[derive(Clone, Copy)] enum Def<'c> { Style(Res, &'c TextStyleDef), Symbol(&'c Symbol, Option<&'c SymbolOptions>), Pattern(&'c PatternDef), + Brush(&'c Brush), } impl<'c> Def<'c> { @@ -699,6 +701,7 @@ impl<'c> Def<'c> { Def::Style(r, s) => (*r, &s.name), Def::Symbol(s, _) => (Res::Symbol, &s.name), Def::Pattern(p) => (Res::Pattern, &p.name), + Def::Brush(b) => (Res::Brush, &b.name), } } @@ -713,6 +716,7 @@ impl<'c> Def<'c> { Def::Style(_, s) => attrs_refs(&s.attrs, &mut g), Def::Symbol(s, _) => node_refs(&s.art, &mut g), Def::Pattern(p) => p.art.iter().for_each(|a| node_refs(a, &mut g)), + Def::Brush(b) => b.art().into_iter().for_each(|a| node_refs(a, &mut g)), } } @@ -734,17 +738,19 @@ impl<'c> Def<'c> { let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; d.pattern(name).is_some_and(|x| bare(x) == bare(p) && same_art(r, &x.art, &p.art)) } + Def::Brush(b) => vectorcraft_brush::library(d).iter().any(|x| x.name == name && same_brush(r, x, b)), } } /// The names of its kind in `d` that are its name or a numbered copy of it. - fn names_in<'d>(&self, d: &'d Document) -> Vec<&'d str> { + fn names_in(&self, d: &Document) -> Vec { let (res, name) = self.key(); - let names: Vec<&str> = match res { - Res::CharStyle => d.char_styles.iter().map(|s| s.name.as_str()).collect(), - Res::ParaStyle => d.para_styles.iter().map(|s| s.name.as_str()).collect(), - Res::Symbol => d.symbols.iter().map(|s| s.name.as_str()).collect(), - _ => d.patterns.iter().map(|p| p.name.as_str()).collect(), + let names: Vec = match res { + Res::CharStyle => d.char_styles.iter().map(|s| s.name.clone()).collect(), + Res::ParaStyle => d.para_styles.iter().map(|s| s.name.clone()).collect(), + Res::Symbol => d.symbols.iter().map(|s| s.name.clone()).collect(), + Res::Brush => vectorcraft_brush::library(d).into_iter().map(|b| b.name).collect(), + _ => d.patterns.iter().map(|p| p.name.clone()).collect(), }; names.into_iter().filter(|n| copy_of(name, n)).collect() } @@ -854,8 +860,8 @@ fn components(uses: &[Vec]) -> Vec<(Vec, bool)> { /// definition is compared as soon as the names of those it uses are picked, so a pick that does /// not fit is dropped with every combination that follows from it; at most 65,536 comparisons are /// made. `None` when no combination fits, or none was found within that many comparisons. -fn names_of_loop<'d>(d: &'d Document, looped: &[Def<'_>], r: &Relink) -> Option> { - let names: Vec> = looped.iter().map(|def| def.names_in(d)).collect(); +fn names_of_loop(d: &Document, looped: &[Def<'_>], r: &Relink) -> Option> { + let names: Vec> = looped.iter().map(|def| def.names_in(d)).collect(); if names.is_empty() || names.iter().any(Vec::is_empty) { return None; } @@ -867,13 +873,13 @@ fn names_of_loop<'d>(d: &'d Document, looped: &[Def<'_>], r: &Relink) -> Option< let (mut pick, mut guess, mut budget, mut k) = (vec![0usize; looped.len()], r.clone(), 1usize << 16, 0); loop { let (res, own) = looped[k].key(); - guess.renames.entry(res).or_default().insert(own.to_string(), names[k][pick[k]].to_string()); + guess.renames.entry(res).or_default().insert(own.to_string(), names[k][pick[k]].clone()); let fits = compare_at[k].iter().all(|&i| { budget = budget.saturating_sub(1); - looped[i].same_in(d, names[i][pick[i]], &guess) + looped[i].same_in(d, &names[i][pick[i]], &guess) }); if fits && k + 1 == looped.len() { - return Some(names.iter().zip(&pick).map(|(l, &p)| l[p]).collect()); + return Some(names.iter().zip(&pick).map(|(l, &p)| l[p].clone()).collect()); } if budget == 0 { return None; @@ -997,7 +1003,7 @@ impl Clipboard { /// [`Self::import_into`] for the resources `used` names. The character and paragraph styles /// come before the symbols and patterns, whose art may set type in them; with `in_use_order`, - /// each style, symbol and pattern comes after the styles, symbols and patterns it uses + /// each style, symbol, pattern and brush comes after the styles, symbols, patterns and brushes it uses /// ([`use_order`]), so that it is compared and relinked with their names here, and each loop /// of definitions that use each other is matched on its own ([`loop_groups`]). fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported { @@ -1005,14 +1011,19 @@ impl Clipboard { self.import_images(d, used, same_doc, &mut out); self.import_swatches(d, used, choices, same_doc, &mut out); // Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art - // they may brush; the others once those are in. - let mut added_brushes: HashSet = self.import_brushes(d, used, same_doc, false, &mut out).into_iter().collect(); + // they may brush; the others once those are in. With `in_use_order`, the brushes are + // placed in use order with the others. + let mut added_brushes: HashSet = + if in_use_order { HashSet::new() } else { self.import_brushes(d, used, same_doc, false, &mut out).into_iter().collect() }; let mut defs: Vec> = vec![]; for (r, list) in [(Res::CharStyle, &self.char_styles), (Res::ParaStyle, &self.para_styles)] { defs.extend(list.iter().filter(|s| used.has(r, &s.name)).map(|s| Def::Style(r, s))); } defs.extend(self.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)).map(|s| Def::Symbol(s, self.symbol_options.get(&s.name)))); defs.extend(self.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)).map(Def::Pattern)); + if in_use_order { + defs.extend(self.brushes.iter().filter(|b| used.has(Res::Brush, &b.name)).map(Def::Brush)); + } // Groups to place in order; true for a loop of definitions that use each other. let groups: Vec<(Vec>, bool)> = if in_use_order { let (order, rest) = use_order(defs); @@ -1040,7 +1051,6 @@ impl Clipboard { if let Some(group) = starts { match names_of_loop(d, group, &out.relink) { Some(names) => { - let names: Vec = names.into_iter().map(str::to_string).collect(); for (def, name) in group.iter().zip(names) { let (res, own) = def.key(); if own != name { @@ -1100,9 +1110,21 @@ impl Clipboard { added_patterns.extend(p.new_name(&def.name)); out.placed(Res::Pattern, &def.name, &p); } + Def::Brush(b) => { + let mut lib = vectorcraft_brush::library(d); + let r = &out.relink; + let p = place(&mut lib, b, same_doc, |ours, theirs| !fresh && same_brush(r, ours, theirs)); + if let Some(name) = p.new_name(&b.name) { + vectorcraft_brush::store(d, &lib); + added_brushes.insert(name); + } + out.placed(Res::Brush, &b.name, &p); + } } } - added_brushes.extend(self.import_brushes(d, used, same_doc, true, &mut out)); + if !in_use_order { + added_brushes.extend(self.import_brushes(d, used, same_doc, true, &mut out)); + } self.import_graphic_styles(d, used, same_doc, &mut out); for (r, name, def) in added_styles { let theirs = if r == Res::CharStyle { &mut d.char_styles } else { &mut d.para_styles }; diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 579de23d4..180e1c480 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -979,6 +979,43 @@ fn a_style_in_a_loop_is_not_matched_to_a_style_that_paints_with_another_pattern( assert_eq!(s.doc().unwrap().doc, d); } +/// A brush in a loaded style's pattern whose art sets type in a style renamed on the way in +/// ("Accent 2") is the same brush when the file is loaded again. +#[test] +fn reloaded_brushes_follow_the_renamed_styles_their_art_uses() { + let (mut src, id) = session_with_text("Ink"); + src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap(); + src.execute("brush.new", &json!({"type": "art", "name": "Lettering", "ids": [id]})).unwrap(); + let path = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 40, "height": 10})).unwrap()["id"].clone(); + src.execute("brush.apply", &json!({"name": "Lettering", "ids": [path]})).unwrap(); + src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [path]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap(); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false)); + let (mut s, _) = session_with_text("X"); + s.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 10}})).unwrap(); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + let r = s.execute("charStyle.load", &load).unwrap(); + assert!(r["added"].as_array().unwrap().contains(&json!("Accent 2")), "{r}"); + let d = s.doc().unwrap().doc.clone(); + let lib = vectorcraft_brush::library(&d); + let brush = lib.iter().find(|b| b.name == "Lettering").unwrap(); + let mut styles = vec![]; + for a in brush.art() { + a.walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + styles.extend(t.runs.iter().filter_map(|r| r.style.style_name.clone())); + } + }); + } + assert_eq!(styles, ["Accent 2"]); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!([]), "{r}"); + assert_eq!(s.doc().unwrap().doc, d); +} + /// A style name from a saved document stays the name used by type in imported pattern art. #[test] fn loaded_style_keeps_the_name_used_by_pattern_type() { From 127251f297fd65ccb51177ddaaf8490a44a419c0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 08:22:29 -0800 Subject: [PATCH 08/10] Keyboard Shortcuts: the text style loaders are listed only with a key of their own charStyle.load, paraStyle.load and textStyle.loadAll need a file, which their panel menu items ask for, so a key press without one reports a missing param. They join the library loaders that Edit > Keyboard Shortcuts lists only when a key was saved for them. --- crates/ui-egui/src/shortcut_editor.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index ce94ff803..d5ed1ade2 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -453,8 +453,8 @@ fn listed(overrides: &Overrides, e: &Entry) -> bool { /// Commands whose params are all optional that do nothing, or fail, when a key press runs them /// with none, and that no item of the menu bar runs: Transparency and Opacity Mask Options need /// a value to set, Align and Distribute a direction, Delete Swatch and Move Swatch a swatch name, -/// and the library and data set loaders a file, which their panel menu items ask for. -const NEEDS_A_PARAM: [&str; 16] = [ +/// and the library, data set and text style loaders a file, which their panel menu items ask for. +const NEEDS_A_PARAM: [&str; 19] = [ "transparency.set", "transparency.setOpacityMask", "stroke.set", @@ -471,6 +471,9 @@ const NEEDS_A_PARAM: [&str; 16] = [ "brush.loadLibrary", "dataset.import", "variable.loadLibrary", + "charStyle.load", + "paraStyle.load", + "textStyle.loadAll", ]; /// The (label, params) of each menu bar item, by command id. From ea3fe168961006dc530131354983b70b4b0c201f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 08:47:51 -0800 Subject: [PATCH 09/10] Text styles: loading uses a loop the document has under the names found for it When the document has a loop of styles and patterns under one set of names, its styles were still matched one by one, so a style could be matched to another copy with the same attributes and the pattern then compared with the wrong style name. Each definition of such a loop is now the document's under the name found for it. --- crates/engine/src/cmd/clipboard/resources.rs | 16 +++++++++++++++- crates/engine/src/cmd/textstyles/tests.rs | 17 ++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 3a87fec20..91e9bbb3e 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -1044,24 +1044,38 @@ impl Clipboard { // In a loop the document does not have all at once, every definition uses one that is // added under a free name, so each one is added too. let mut fresh = false; + // The names in the document of the loop being placed, when it has them all at once. + let mut matched: BTreeMap<(Res, String), String> = BTreeMap::new(); for (def, looped, starts) in steps { fresh &= looped; + if !looped { + matched.clear(); + } // A loop of uses that the document has all at once is known under those names before // its definitions are compared. if let Some(group) = starts { + matched.clear(); match names_of_loop(d, group, &out.relink) { Some(names) => { for (def, name) in group.iter().zip(names) { let (res, own) = def.key(); if own != name { - out.relink.renames.entry(res).or_default().insert(own.to_string(), name); + out.relink.renames.entry(res).or_default().insert(own.to_string(), name.clone()); } + matched.insert((res, own.to_string()), name); } fresh = false; } None => fresh = true, } } + // A definition of such a loop is the document's under the name found for it. + let (res, own) = def.key(); + if let Some(name) = matched.get(&(res, own.to_string())) { + let p = if name == own { Placed::Existing } else { Placed::Reused(name.clone()) }; + out.placed(res, own, &p); + continue; + } match def { Def::Style(r, def) => { let mut s = def.clone(); diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 180e1c480..73c938dc1 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -945,7 +945,8 @@ fn a_style_and_the_pattern_that_uses_it_load_together() { /// A style and the pattern it paints with, whose type is set in that style, are both added when /// the document has a style of that name and attributes but another pattern of that name: the -/// copy of the style paints with the copy of the pattern, and a second load adds nothing. +/// copy of the style paints with the copy of the pattern, and a second load adds nothing. A loop the +/// document has is used under the names it has together. #[test] fn a_style_in_a_loop_is_not_matched_to_a_style_that_paints_with_another_pattern() { let (mut src, id) = session_with_text("Tile"); @@ -977,6 +978,20 @@ fn a_style_in_a_loop_is_not_matched_to_a_style_that_paints_with_another_pattern( let r = s.execute("charStyle.load", &load).unwrap(); assert_eq!(r["added"], json!([]), "{r}"); assert_eq!(s.doc().unwrap().doc, d); + // With the loop's style renamed "Accent 3" and an "Accent 2" listed before it that also paints + // with "Dots 2", the loop is still found as "Accent 3" and "Dots 2", the names it has together. + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let at = doc.char_styles.iter().position(|c| c.name == "Accent 2").unwrap(); + doc.char_styles[at].name = "Accent 3".into(); + let copy = TextStyleDef { name: "Accent 2".into(), attrs: doc.char_styles[at].attrs.clone() }; + doc.char_styles.insert(at, copy); + let tile = doc.patterns.iter_mut().find(|p| p.name == "Dots 2").unwrap(); + let NodeKind::Text(text) = &mut std::sync::Arc::make_mut(&mut tile.art[0]).kind else { panic!("pattern art should be type") }; + text.runs[0].style.style_name = Some("Accent 3".into()); + let d = s.doc().unwrap().doc.clone(); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r, json!({"added": [], "existing": ["Accent 3"], "skipped": []})); + assert_eq!(s.doc().unwrap().doc, d); } /// A brush in a loaded style's pattern whose art sets type in a style renamed on the way in From cbe374df628168d49bab1938bf81549664b5719f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 19:34:38 -0800 Subject: [PATCH 10/10] Text styles: loading imports once and reads the brush library once charStyle.load, paraStyle.load and textStyle.loadAll imported the styles into a copy of the document to see whether anything changed, then ran the same import again inside the edit. The edit now takes the copy the first import made. While a typing session is open the import still runs inside the edit, because the edit ends the session first and that can change the document. Matching a loop of definitions parsed the document's brush library again for every comparison, and placing brushes in use order parsed it again for every brush. Each now reads it once. The params text and docs/mcp.md now say that the symbols and brushes the patterns use come along, that `skipped` lists names in the file, and that charStyle.load and paraStyle.load list only the styles of their own kind. --- crates/engine/src/cmd/clipboard/resources.rs | 32 ++++++++++++-------- crates/engine/src/cmd/textstyles.rs | 19 ++++++++---- docs/mcp.md | 9 +++--- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 91e9bbb3e..4488b4746 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -720,8 +720,9 @@ impl<'c> Def<'c> { } } - /// Is it the definition `d` has under `name`, once relinked by `r`? - fn same_in(&self, d: &Document, name: &str, r: &Relink) -> bool { + /// Is it the definition `d` has under `name`, once relinked by `r`? (`brushes`: the brush + /// library of `d`.) + fn same_in(&self, d: &Document, brushes: &Brushes<'_>, name: &str, r: &Relink) -> bool { match self { Def::Style(res, s) => { let theirs = if *res == Res::CharStyle { &d.char_styles } else { &d.para_styles }; @@ -738,18 +739,19 @@ impl<'c> Def<'c> { let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; d.pattern(name).is_some_and(|x| bare(x) == bare(p) && same_art(r, &x.art, &p.art)) } - Def::Brush(b) => vectorcraft_brush::library(d).iter().any(|x| x.name == name && same_brush(r, x, b)), + Def::Brush(b) => brushes.get().iter().any(|x| x.name == name && same_brush(r, x, b)), } } - /// The names of its kind in `d` that are its name or a numbered copy of it. - fn names_in(&self, d: &Document) -> Vec { + /// The names of its kind in `d` (`brushes`: its brush library) that are its name or a numbered + /// copy of it. + fn names_in(&self, d: &Document, brushes: &Brushes<'_>) -> Vec { let (res, name) = self.key(); let names: Vec = match res { Res::CharStyle => d.char_styles.iter().map(|s| s.name.clone()).collect(), Res::ParaStyle => d.para_styles.iter().map(|s| s.name.clone()).collect(), Res::Symbol => d.symbols.iter().map(|s| s.name.clone()).collect(), - Res::Brush => vectorcraft_brush::library(d).into_iter().map(|b| b.name).collect(), + Res::Brush => brushes.get().iter().map(|b| b.name.clone()).collect(), _ => d.patterns.iter().map(|p| p.name.clone()).collect(), }; names.into_iter().filter(|n| copy_of(name, n)).collect() @@ -859,9 +861,12 @@ fn components(uses: &[Vec]) -> Vec<(Vec, bool)> { /// numbered copy of it. The names are picked one definition at a time, in order, and each /// definition is compared as soon as the names of those it uses are picked, so a pick that does /// not fit is dropped with every combination that follows from it; at most 65,536 comparisons are -/// made. `None` when no combination fits, or none was found within that many comparisons. +/// made. `None` when no combination fits, or none was found within that many comparisons. The brush +/// library of `d` is read once, the first time a brush is compared. fn names_of_loop(d: &Document, looped: &[Def<'_>], r: &Relink) -> Option> { - let names: Vec> = looped.iter().map(|def| def.names_in(d)).collect(); + let library = OnceCell::new(); + let brushes = Brushes::Lazy(d, &library); + let names: Vec> = looped.iter().map(|def| def.names_in(d, &brushes)).collect(); if names.is_empty() || names.iter().any(Vec::is_empty) { return None; } @@ -876,7 +881,7 @@ fn names_of_loop(d: &Document, looped: &[Def<'_>], r: &Relink) -> Option = BTreeMap::new(); + // The document's brush library, read the first time a brush is placed in use order and + // stored again after each brush added; nothing else in the loop changes it. + let mut library: Option> = None; for (def, looped, starts) in steps { fresh &= looped; if !looped { @@ -1125,11 +1133,11 @@ impl Clipboard { out.placed(Res::Pattern, &def.name, &p); } Def::Brush(b) => { - let mut lib = vectorcraft_brush::library(d); + let lib = library.get_or_insert_with(|| vectorcraft_brush::library(d)); let r = &out.relink; - let p = place(&mut lib, b, same_doc, |ours, theirs| !fresh && same_brush(r, ours, theirs)); + let p = place(lib, b, same_doc, |ours, theirs| !fresh && same_brush(r, ours, theirs)); if let Some(name) = p.new_name(&b.name) { - vectorcraft_brush::store(d, &lib); + vectorcraft_brush::store(d, lib); added_brushes.insert(name); } out.placed(Res::Brush, &b.name, &p); diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index ffc75acc9..f8ce2d691 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -125,7 +125,7 @@ macro_rules! style_cmds { } /// The params of `charStyle.load`, `paraStyle.load` and `textStyle.loadAll`. -const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with and the styles of either kind that type in those patterns is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}})"; +const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind"; pub fn specs() -> Vec { let mut v = style_cmds!( @@ -972,12 +972,19 @@ fn load(s: &mut Session, p: &Value, kinds: &[Kind]) -> Result { } } let (chars, paras) = (kinds.contains(&Kind::Char), kinds.contains(&Kind::Para)); - let current = s.doc()?.doc.clone(); - let mut trial = (*current).clone(); + let mut trial = (*s.doc()?.doc).clone(); let mut placed = import_text_styles(&src, &mut trial, chars, paras); - // An undo step only when the document changes. - if trial != *current { - placed = s.edit(label, |d, _| Ok(import_text_styles(&src, d, chars, paras)))?; + // An undo step only when the document changes. The step takes the document the import made, + // unless the edit first ends a typing session, which can change the document it starts from. + if trial != *s.doc()?.doc { + if super::typing_in_progress(s) { + placed = s.edit(label, |d, _| Ok(import_text_styles(&src, d, chars, paras)))?; + } else { + s.edit(label, |d, _| { + *d = trial; + Ok(()) + })?; + } } let reply = |para: bool| { let names = |added: bool| placed.iter().filter(|p| p.para == para && p.added == added).map(|p| p.name.as_str()).collect::>(); diff --git a/docs/mcp.md b/docs/mcp.md index 020712101..7fdd4fc4b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5934,13 +5934,14 @@ uses, in panel order and never the Normal style, and the panel highlights them; (Load Character Styles… and Load Paragraph Styles… in the panel menus, where `ui.loadCharStyles` and `ui.loadParaStyles` pick the file) copy the styles of that kind from another document VectorCraft opens (`path`, `data` or `dataBase64`), and `textStyle.loadAll` (Load All Styles…, `ui.loadAllStyles`) copies both kinds, each in one undo -step. The swatches and patterns the styles paint with, and the styles of either kind that type in those patterns is set -in, come along as Paste brings those of pasted type; a global or spot color whose name the document gives another color +step. The swatches and patterns the styles paint with, the symbols and brushes those patterns use, and the styles of +either kind that type in those patterns and symbols is set in, come along as Paste brings those of pasted type; a global or spot color whose name the document gives another color is the document's. A loaded style that is the same as the style of its name, or of its name with a number (`Body 2`), is not added again; any other is added under a free name. The document's styles and the type that uses them keep their attributes, and the file's Normal styles are not loaded. The -reply lists the names `added`, `existing` and `skipped` (a style whose attributes do not read or are out of the range -`charStyle.new` takes, or whose name is empty); `textStyle.loadAll` replies `{charStyles, paraStyles}`. A Return the Type tool types (or `text.editRange` +reply lists the names `added` and `existing` in the document and the names `skipped` in the file (a style whose +attributes do not read or are out of the range `charStyle.new` takes, or whose name is empty); `textStyle.loadAll` +replies `{charStyles, paraStyles}`, and `charStyle.load` and `paraStyle.load` list only the styles of their own kind. A Return the Type tool types (or `text.editRange` inserting `\n`) continues the style of the paragraph it splits; deleting a break keeps the first paragraph's style; `text.setText` gives every paragraph the first one's attributes. Threaded text keeps each paragraph's attributes as the story re-flows (a paragraph split between frames has them in both).