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.
This commit is contained in:
dotCooCoo
2026-11-08 12:59:33 +07:00
committed by bflatastic
parent 089d6c3e74
commit d8187b4967
35 changed files with 825 additions and 57 deletions
+269 -53
View File
@@ -241,9 +241,28 @@ pub(super) struct Used {
impl Used {
fn of<'a>(defs: &Defs<'a>, nodes: impl IntoIterator<Item = &'a Node>) -> Self {
let mut used = Used::default();
let mut todo: Vec<Source<'a>> = 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<Source<'a>>) {
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<Res, BTreeMap<String, String>>,
/// 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<Def<'_>>) -> (Vec<Def<'_>>, Vec<Def<'_>>) {
let mut order = Vec::with_capacity(left.len());
while !left.is_empty() {
let ready: Vec<bool> = 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<Vec<&'d str>> {
let names: Vec<Vec<&str>> = 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<Node>) -> 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<Vec<Brush>>, nodes: Vec<Node>) -> Self {
let pick = |r: Res, list: &[TextStyleDef]| list.iter().filter(|s| used.has(r, &s.name)).cloned().collect::<Vec<_>>();
// 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<String> = 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<String> = self.import_brushes(d, used, same_doc, false, &mut out).into_iter().collect();
let mut defs: Vec<Def<'_>> = 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<String> = 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<String, SymbolOptions> = 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<String, SymbolOptions> = 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<LoadedStyle> {
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::<BTreeSet<_>>());
let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true);
let mut placed: Vec<LoadedStyle> = 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 {
@@ -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}");
}
+81
View File
@@ -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<CommandSpec> {
let mut v = style_cmds!(
Kind::Char,
@@ -132,6 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<TextStyleDef>, Vec<String>) {
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<Value> {
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::<Vec<_>>();
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;
+254
View File
@@ -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::<Vec<_>>()
};
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::<Vec<_>>())
};
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.
+41
View File
@@ -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<String> = 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"]}));
+3
View File
@@ -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 Зареждане на метафайл в клипборда
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -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 Φόρτωση μετααρχείου στο πρόχειρο
Can't render this file because it contains an unexpected character in line 163 and column 5.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2246 and column 5.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -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 メタファイルをクリップボードに読み込む
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -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 ფერის ნიმუშებში დამატება
Can't render this file because it is too large.
+3
View File
@@ -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 메타파일을 클립보드로 불러오기
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -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 Загрузить метафайл в буфер обмена
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+3
View File
@@ -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 Завантажити метафайл у буфер обміну
Can't render this file because it contains an unexpected character in line 177 and column 5.
+3
View File
@@ -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
Can't render this file because it contains an unexpected character in line 199 and column 5.
+3
View File
@@ -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 将图元文件载入剪贴板
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -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 將中繼檔載入剪貼簿
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -756,6 +756,9 @@ fn run_ui_part9(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
crate::dialogs::save_style_library::open(app, names.unwrap_or_default())
}
"ui.loadCharStyles" => 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()),
+18
View File
@@ -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",
+35 -2
View File
@@ -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<String>) -> Result<Value, String> {
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)]
@@ -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::<Vec<_>>();
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<egui::Event>) {
+10 -1
View File
@@ -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).
+1
View File
@@ -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).