Merge main into feat/revolve-graphics

This commit is contained in:
dotCooCoo
2026-11-13 07:49:32 -08:00
51 changed files with 1404 additions and 98 deletions
+5 -1
View File
@@ -167,9 +167,13 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({ "ids": [], "before": 0, "after": 0 }));
}
let ids: Vec<NodeId> = plan.iter().map(|(id, _)| *id).collect();
s.edit("Smooth", |d, _| {
s.edit("Smooth", |d, sel| {
for (id, np) in plan {
*path_mut(d, id)? = np;
// The smoothed path has new anchors, so the old anchor and segment indices are
// dropped and the path stays selected whole.
sel.anchors.remove(&id);
sel.segments.remove(&id);
}
Ok(())
})?;
+158 -2
View File
@@ -346,6 +346,36 @@ impl Library {
}
}
/// Name item `r` `name`, in its place in its kind's list; an item keyed by its name keeps its
/// group under the new name (a graphic is keyed by its id, which stays).
fn rename_item(&mut self, r: &ItemRef, name: &str) {
fn set<'a>(mut names: impl Iterator<Item = &'a mut String>, from: &str, to: &str) {
if let Some(n) = names.find(|n| n.as_str() == from) {
*n = to.to_string();
}
}
let from = r.item.as_str();
match r.kind {
ItemKind::Color => set(self.colors.iter_mut().map(|c| &mut c.name), from, name),
ItemKind::Gradient => set(self.gradients.iter_mut().map(|g| &mut g.name), from, name),
ItemKind::Pattern => set(self.patterns.iter_mut().map(|p| &mut p.name), from, name),
ItemKind::CharStyle => set(self.char_styles.iter_mut().map(|t| &mut t.name), from, name),
ItemKind::ParaStyle => set(self.para_styles.iter_mut().map(|t| &mut t.name), from, name),
ItemKind::Graphic => {
if let Some(g) = self.graphics.iter_mut().find(|g| g.id == from) {
g.name = name.to_string();
}
return;
}
ItemKind::Unknown => return,
}
for x in self.groups.iter_mut().flat_map(|g| g.items.iter_mut()) {
if x == r {
x.item = name.to_string();
}
}
}
/// Move item `r` next to `to`, an item of its kind: just before it, or just after it when
/// `after`, in their kind's list (the order the panel shows them in).
fn place_next_to(&mut self, r: &ItemRef, to: &ItemRef, after: bool) {
@@ -683,7 +713,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Use Library Item",
[],
None,
"{library?, kind, item: name (graphic: id or name), center?: [x, y] (graphic; default: the first artboard's centre), to?: fill|stroke (colors, gradients, patterns; default: the kind's), recorded?: the item as it was when used (recorded in the journal so a replay matches)} graphic: place a copy centred on `center`, its resources pasted along, selected, one undo step; colors and gradients: paint the selection (and the defaults) as paint.setFill/paint.setStroke, a gradient fitting each object; a spot color links to the document's spot swatch of its ink's name, added when the document has none of that colour (a name taken by another colour gets a number), one undo step; patterns: add the pattern and its swatch to the document (a name the document gives another pattern gets a number; the same pattern is reused) and paint the selection (and the defaults) with it, one undo step; charStyle/paraStyle: add the style to the document (a name taken by different attributes gets a number) and apply it to the selected text, one undo step → {kind, ids?, style?}",
"{library?, kind, item: name (graphic: id or name), center?: [x, y] (graphic; default: the first artboard's centre), to?: fill|stroke (colors, gradients, patterns; default: the kind's), recorded?: the item as it was when used (recorded in the journal so a replay matches)} graphic: place a copy centred on `center`, its resources pasted along, selected, one undo step; colors and gradients: paint the selection (and the defaults) as paint.setFill/paint.setStroke, a gradient fitting each object; a spot color links to the document's spot swatch of its ink's name, added when the document has none of that colour (a name taken by another colour gets a number), one undo step; patterns: add the pattern and its swatch to the document (a name the document gives another pattern gets a number; the same pattern is reused) and paint the selection (and the defaults) with it, one undo step; charStyle/paraStyle: add the style to the document (a name taken by different attributes gets a number; a character style's color linked to a swatch that isn't a global or spot swatch of the document keeps the color it shows, unlinked) and apply it to the selected text, one undo step → {kind, ids?, style?}",
has_doc,
use_item
),
@@ -696,6 +726,24 @@ pub fn specs() -> Vec<CommandSpec> {
always,
remove_item
),
cmd!(
"library.renameItem",
"Rename Library Item",
[],
None,
"{library?, kind, item: name (graphic: id or name), name: string (trimmed; 1 to 256 characters)} rename an item; it keeps its place and its group; another item of its kind with that name is an error, and its own name changes nothing; not undoable → {library, kind, item: its key now (graphic: its id), name}",
always,
rename_item
),
cmd!(
"library.addToCharacterStyles",
"Add to Character Styles",
[],
None,
"{library?, item: a character style's name, recorded?: the style as it was when added (recorded in the journal so a replay matches)} add a library character style to the document's character styles without applying it (no text needs to be selected): a document style of that name with the same attributes is reused, a name taken by different attributes gets a number; a color linked to a swatch that isn't a global or spot swatch of the document keeps the color it shows, unlinked; a new style is one undo step, a reused one none → {style: its name in the document, existing: bool}",
has_doc,
add_to_char_styles
),
cmd!(
"library.createGroup",
"New Library Group",
@@ -1231,8 +1279,13 @@ fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
Kind::CharStyle | Kind::ParaStyle => {
let para = kind == Kind::ParaStyle;
let name = data.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Style").to_string();
let attrs = data.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
let mut attrs = data.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
let doc = &s.doc()?.doc;
if !para {
unlink_missing_swatches(doc, &mut attrs);
}
// Compared in the form the document stores them in.
let attrs = super::textstyles::stored_style_attrs(doc, para, &attrs).unwrap_or(attrs);
// The document's style of that name when it is the same; else a new one.
let same = super::textstyles::style_attrs(doc, para, &name).is_some_and(|a| a == attrs);
let style = if same { name } else { unique(&name, |n| super::textstyles::style_attrs(doc, para, n).is_some()) };
@@ -1361,6 +1414,109 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn rename_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.renameItem";
let (id, kind, item) = item_param(s, p, C)?;
let name = match p.get("name") {
None | Some(Value::Null) => return Err(bad(C, "missing `name`")),
Some(Value::String(n)) => n.trim().to_string(),
Some(_) => return Err(bad(C, "`name` must be a string")),
};
if name.is_empty() {
return Err(bad(C, "missing `name`"));
}
if name.chars().count() > MAX_NAME {
let (label, max) = ("`name`", MAX_NAME);
return Err(bad(C, format!("{label} must be at most {max} characters")));
}
let lib = s.libraries.get(&id).ok_or_else(|| bad(C, format!("no library `{id}`")))?;
let r = lib.item_ref(kind.item_kind(), item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?;
// The item's name now, and whether another item of its kind has the new one.
let mut same = false;
let mut taken = false;
for (_, key, shown) in lib.items().filter(|(k, ..)| *k == r.kind) {
if key == r.item {
same = shown == name;
} else if shown == name {
taken = true;
}
}
let key = if r.kind == ItemKind::Graphic { r.item.clone() } else { name.clone() };
let out = json!({ "library": id, "kind": str_param(p, "kind"), "item": key, "name": name });
if same {
return Ok(out);
}
if taken {
return Err(bad(C, format!("the library has another item of that kind named `{name}`")));
}
s.libraries.change(&id, |lib| {
lib.rename_item(&r, &name);
Ok(())
})?;
Ok(out)
}
fn add_to_char_styles(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.addToCharacterStyles";
// A replay (the journal) carries what the style was, so it doesn't depend on the library.
let data = match p.get("recorded").cloned() {
Some(r) => r,
None => {
let id = library_id(s, p, C)?;
let item = str_param(p, "item").ok_or_else(|| bad(C, "missing `item`"))?;
let lib = s.libraries.get(&id).ok_or_else(|| bad(C, format!("no library `{id}`")))?;
let t = lib.char_styles.iter().find(|t| t.name == item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?;
let data = json!({ "name": t.name, "attrs": t.attrs });
s.note_journal("recorded", data.clone());
data
}
};
let name = data.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Style").to_string();
let mut attrs = data.get("attrs").and_then(Value::as_object).cloned().ok_or_else(|| bad(C, "the library item is damaged"))?;
let doc = &s.doc()?.doc;
unlink_missing_swatches(doc, &mut attrs);
// Compared in the form the document stores them in.
let attrs = super::textstyles::stored_style_attrs(doc, false, &attrs).unwrap_or(attrs);
// The document's style of that name when it is the same; else a new one, which isn't applied.
if super::textstyles::style_attrs(doc, false, &name).is_some_and(|a| a == attrs) {
return Ok(json!({ "style": name, "existing": true }));
}
let style = unique(&name, |n| super::textstyles::style_attrs(doc, false, n).is_some());
s.execute("charStyle.new", &json!({ "name": style, "attrs": attrs }))?;
Ok(json!({ "style": style, "existing": false }))
}
/// Unlink a library character style's `fill` and `stroke` colors (solid, and each gradient stop)
/// from swatches that are not global or spot swatches of `doc`: an unlinked color keeps the color
/// it shows at full tint, as [`clean_gradient`] keeps a library gradient's stops. A link to a
/// global or spot swatch `doc` has stays, and takes that swatch's color.
fn unlink_missing_swatches(doc: &vectorcraft_doc::Document, attrs: &mut Map<String, Value>) {
let keep = |name: &str| doc.global_color(name).is_some();
for key in ["fill", "stroke"] {
let Some(mut paint) = attrs.get(key).and_then(|v| serde_json::from_value::<Paint>(v.clone()).ok()) else { continue };
let mut changed = false;
let mut unlink = |swatch: &mut Option<String>, tint: &mut f32| {
if swatch.as_deref().is_some_and(|n| !keep(n)) {
*swatch = None;
*tint = 1.0;
changed = true;
}
};
match &mut paint {
Paint::Solid { swatch, tint, .. } => unlink(swatch, tint),
Paint::Gradient(g) => {
for st in &mut g.gradient.stops {
unlink(&mut st.swatch, &mut st.tint);
}
}
_ => {}
}
if changed && let Ok(v) = serde_json::to_value(&paint) {
attrs.insert(key.to_string(), v);
}
}
}
/// `p`'s `name`, trimmed and clipped, else `default`.
fn name_param(p: &Value, default: &str) -> String {
let mut name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or(default).to_string();
+7
View File
@@ -506,6 +506,13 @@ pub(crate) fn style_attrs(d: &Document, para: bool, name: &str) -> Option<Map<St
if para { Kind::Para } else { Kind::Char }.attrs(d, name)
}
/// `attrs` in the form `charStyle.new` (`para`: `paraStyle.new`) stores them in `d`, or `None`
/// when they don't pass its checks.
pub(crate) fn stored_style_attrs(d: &Document, para: bool, attrs: &Map<String, Value>) -> Option<Map<String, Value>> {
let kind = if para { Kind::Para } else { Kind::Char };
attrs_param(d, &json!({ "attrs": attrs }), kind, "new", None).ok().flatten()
}
/// The name `charStyle.new` (`para`: `paraStyle.new`) gives a style when it is given none: the
/// first "Character Style N" ("Paragraph Style N") that no character style (paragraph style) of the
/// document has.