From 0e57712bba7d405002804049c97980fc37081bf5 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:24:56 +0200 Subject: [PATCH] Object styles: corner options, and editing a style's fill, stroke and corners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An object style now carries Corner Options (`corners`, in a rectangle's path order). New styles take them from the selection, applying the style sets them, and IDML reads and writes them as InDesign's Stroke & Corner Options (EnableStrokeAndCornerOptions, TopLeftCornerOption…). style.object.edit changes more than effects: `fill` (a swatch or {swatch, tint}), `stroke` (fields merged over the style's), `corners` ({shape, size} or four corners), null to stop setting one, and `rename` (every use follows; built-in names and names in use are refused). Items using the style follow each changed attribute while they have the style's value; overridden ones keep theirs. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/styles.rs | 9 +- crates/engine/src/cmd/style.rs | 182 +++++++++++++++++++++++++++++---- crates/engine/src/tests.rs | 49 +++++++++ crates/idml/src/export.rs | 7 ++ crates/idml/src/import.rs | 6 +- crates/idml/src/tests.rs | 5 + 7 files changed, 236 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..b1199ed8f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Object styles carry Corner Options (`corners`), taken from the selection by `style.object.create` and written to IDML (`EnableStrokeAndCornerOptions`, `TopLeftCornerOption`…). `style.object.edit` also changes a style's fill, stroke and corners and renames it (`rename`); the items using the style that haven't overridden an attribute follow each change, as in InDesign. Found in the Learn Skills Daily course, chapter 15. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 5c747fdbf..380f0fd3a 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -1,5 +1,6 @@ //! Paragraph, character and object styles with based-on inheritance. +use designcraft_geom::corners::CornerOptions; use serde::{Deserialize, Serialize}; use crate::attrs::{CharAttrs, CharProps, ParaAttrs, ParaProps}; @@ -59,11 +60,17 @@ pub struct ObjectStyle { /// effects differ from the style's (a local override). #[serde(skip_serializing_if = "Option::is_none")] pub effects: Option, + /// Corner Options (Stroke & Corner Options): each corner's shape and size, in path order. + #[serde(skip_serializing_if = "Option::is_none")] + pub corners: Option, } impl ObjectStyle { - /// Gives `it` the fill, stroke, effects and text frame options this style sets. + /// Gives `it` the fill, stroke, corners, effects and text frame options this style sets. pub fn apply_to(&self, it: &mut Item) { + if let Some(c) = self.corners { + it.corners = c; + } if let Some(f) = &self.fill { it.fill = f.clone(); } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d1bd06ad0..e0eee77b9 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -359,7 +359,7 @@ pub fn specs() -> Vec { "New Object Style…", [], None, - "{name, fromSelection?: true (fill, stroke, effects and text frame options of the first selected item), fill?, paragraphStyle?, effects?: {dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?}, dropShadow?: {on?, color?, opacity? (0–1), angle?, globalLight?, distance?, size?, spread?}, feather?: width, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} → {name} (made unique)", + "{name, fromSelection?: true (fill, stroke, corners, effects and text frame options of the first selected item), fill?: swatch | {swatch?, tint?}, stroke?: {swatch?, weight?, tint?, type?, …}, corners?: {shape?, size?} | {corners: [{shape, size}] ×4}, paragraphStyle?, effects?: {dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?}, dropShadow?: {on?, color?, opacity? (0–1), angle?, globalLight?, distance?, size?, spread?}, feather?: width, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} → {name} (made unique)", has_doc, create_object ), @@ -368,7 +368,7 @@ pub fn specs() -> Vec { "Object Style Options…", [], None, - "{name, effects?: {…} | null (the style no longer sets effects), dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} — effect objects merge field by field into the style's effects (fields as in `document.inspect` item effects); values that aren't finite numbers are refused, the others clamped (opacities 0–1, sizes and distances ≥ 0); items using the style whose effects match the style's (no effects, when the style set none) take the new effects, items with their own effects keep them → {name, updated}", + "{name, rename?: new name (every use follows), fill?: swatch | {swatch?, tint?} | null, stroke?: {swatch?, weight?, tint?, type?, …} | null, corners?: {shape?: none|rounded|inverseRounded|inset|bevel|fancy, size?} | {corners: [{shape, size}] ×4} | null (null: the style no longer sets it; items keep theirs), effects?: {…} | null (the style no longer sets effects), dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} — effect objects merge field by field into the style's effects (fields as in `document.inspect` item effects); values that aren't finite numbers are refused, the others clamped (opacities 0–1, sizes and distances ≥ 0); items using the style whose effects (fill, stroke, corners) match the style's (an item's defaults, when the style set none) take the new ones, items with their own keep them → {name, updated}", has_doc, edit_object ), @@ -1507,11 +1507,19 @@ fn create_object(s: &mut Session, p: &Value) -> Result { if let Some(it) = &src { os.fill = Some(it.fill.clone()); os.stroke = Some(it.stroke.clone()); + os.corners = Some(it.corners); os.text_frame = it.text_frame().map(|t| t.options.clone()); os.effects = Some(it.effects.clone()); } - if let Some(sw) = str_param(p, "fill") { - os.fill = Some(designcraft_doc::Fill::swatch(sw)); + const CREATE: &str = "style.object.create"; + if let Some(f) = fill_param(CREATE, p, os.fill.as_ref())? { + os.fill = f; + } + if let Some(st) = group_param(CREATE, p, "stroke", os.stroke.as_ref().unwrap_or(&designcraft_doc::Stroke::default()))? { + os.stroke = st; + } + if let Some(c) = corners_param(CREATE, p, os.corners.unwrap_or_default())? { + os.corners = c; } if let Some(ps) = str_param(p, "paragraphStyle") { os.paragraph_style = Some(ps.to_string()); @@ -1527,6 +1535,68 @@ fn create_object(s: &mut Session, p: &Value) -> Result { }) } +/// `p[key]`, an attribute group of a style, merged field by field over `base`: `None` when not +/// given, `Some(None)` for null (the style no longer sets it). +fn group_param(cmd: &str, p: &Value, key: &str, base: &T) -> Result>> { + match p.get(key) { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(v @ Value::Object(_)) => { + let mut cur = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?; + merge_json(&mut cur, v.clone(), 0); + serde_json::from_value(cur).map(|t| Some(Some(t))).map_err(|e| bad(cmd, format!("{key}: {e}"))) + } + Some(_) => Err(bad(cmd, format!("`{key}` must be an object or null"))), + } +} + +/// `fill`: a swatch name, `{swatch?, tint?}` over `base`, or null. +fn fill_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Fill>) -> Result>> { + if let Some(sw) = p.get("fill").and_then(Value::as_str) { + return Ok(Some(Some(designcraft_doc::Fill::swatch(sw)))); + } + let fill = group_param(cmd, p, "fill", base.unwrap_or(&designcraft_doc::Fill::default()))?; + if let Some(Some(f)) = &fill + && !(f.tint.is_finite() && (0.0..=1.0).contains(&f.tint)) + { + return Err(bad(cmd, "fill tint must be 0–1")); + } + Ok(fill) +} + +/// `corners`: `{shape?, size?}` for all four, `{corners: [{shape, size}] ×4}`, or null. +fn corners_param( + cmd: &str, + p: &Value, + base: designcraft_geom::corners::CornerOptions, +) -> Result>> { + let Some(v) = p.get("corners") else { return Ok(None) }; + if v.is_null() { + return Ok(Some(None)); + } + let o = v.as_object().ok_or_else(|| bad(cmd, "`corners` must be an object or null"))?; + let mut c = if o.contains_key("corners") { + serde_json::from_value::(v.clone()).map_err(|e| bad(cmd, format!("corners: {e}")))? + } else { + base + }; + if let Some(sh) = o.get("shape") { + let shape = serde_json::from_value(sh.clone()).map_err(|e| bad(cmd, format!("corner shape: {e}")))?; + c.corners.iter_mut().for_each(|k| k.shape = shape); + } + if let Some(sz) = o.get("size") { + let size = sz.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "corner size must be a number"))?; + c.corners.iter_mut().for_each(|k| k.size = size); + } + for k in &mut c.corners { + if !k.size.is_finite() { + return Err(bad(cmd, "corner sizes must be finite")); + } + k.size = k.size.clamp(0.0, 10_000.0); + } + Ok(Some(Some(c))) +} + fn edit_object(s: &mut Session, p: &Value) -> Result { const CMD: &str = "style.object.edit"; let name = str_param(p, "name").ok_or_else(|| bad(CMD, "missing name"))?.to_string(); @@ -1534,34 +1604,104 @@ fn edit_object(s: &mut Session, p: &Value) -> Result { return Err(bad(CMD, format!("`{name}` can't be edited"))); } let old = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad(CMD, format!("no object style `{name}`")))?; - let effects = if p.get("effects").is_some_and(Value::is_null) { + let mut new = old.clone(); + if p.get("effects").is_some_and(Value::is_null) { if EFFECT_KEYS.iter().any(|k| p.get(k).is_some()) { return Err(bad(CMD, "`effects: null` can't be combined with effect settings")); } - None - } else { - match effects_param(CMD, p, old.effects.as_ref().unwrap_or(&Effects::default()))? { - Some(e) => Some(e), - None => return Ok(json!({"name": name, "updated": 0})), + new.effects = None; + } else if let Some(e) = effects_param(CMD, p, old.effects.as_ref().unwrap_or(&Effects::default()))? { + new.effects = Some(e); + } + if let Some(f) = fill_param(CMD, p, old.fill.as_ref())? { + new.fill = f; + } + if let Some(st) = group_param(CMD, p, "stroke", old.stroke.as_ref().unwrap_or(&designcraft_doc::Stroke::default()))? { + if let Some(st) = &st + && !(st.weight.is_finite() && st.weight >= 0.0 && st.tint.is_finite()) + { + return Err(bad(CMD, "stroke weight must be a number ≥ 0")); } - }; - if effects == old.effects { + new.stroke = st; + } + if let Some(c) = corners_param(CMD, p, old.corners.unwrap_or_default())? { + new.corners = c; + } + let rename = str_param(p, "rename").map(str::trim).filter(|r| *r != name).map(str::to_string); + if let Some(r) = &rename { + let builtin = name.starts_with('[') && name.ends_with(']'); + if builtin || r.is_empty() || (r.starts_with('[') && r.ends_with(']')) { + return Err(bad(CMD, format!("`{name}` can't be renamed to `{r}`"))); + } + if s.doc()?.doc.styles.object_style(r).is_some() { + return Err(bad(CMD, format!("an object style is already named `{r}`"))); + } + new.name = r.clone(); + } + if new == old { return Ok(json!({"name": name, "updated": 0})); } s.edit(|d, _| { let Some(os) = d.styles_mut().object.iter_mut().find(|o| o.name == name) else { return Err(bad(CMD, format!("no object style `{name}`"))); }; - os.effects = effects.clone(); - // An item follows the style while its effects are the style's. When the style didn't set - // effects before, only items without effects of their own follow (the default styles are - // on most frames). - let follows = old.effects.clone().unwrap_or_default(); - let updated = match &effects { - Some(new) => d.update_items(|it| it.object_style == name && it.effects == follows, |it| it.effects = new.clone()), - None => 0, + *os = new.clone(); + if let Some(r) = &rename { + let st = d.styles_mut(); + for o in &mut st.object { + if o.based_on.as_deref() == Some(name.as_str()) { + o.based_on = Some(r.clone()); + } + } + for n in [&mut st.default_text_frame, &mut st.default_graphic_frame] { + if *n == name { + *n = r.clone(); + } + } + d.update_items(|it| it.object_style == name, |it| it.object_style = r.clone()); + } + // An item follows each attribute the style sets while it has the style's value. When the + // style didn't set it before, items with the default follow (the default styles are on + // most frames). An attribute the style no longer sets stays as it is on the items. + let effects = (new.effects != old.effects).then_some(new.effects.clone()).flatten().map(|e| (old.effects.clone().unwrap_or_default(), e)); + let fill = (new.fill != old.fill).then_some(new.fill.clone()).flatten().map(|f| (old.fill.clone().unwrap_or_default(), f)); + let stroke = (new.stroke != old.stroke) + .then_some(new.stroke.clone()) + .flatten() + .map(|f| (old.stroke.clone().unwrap_or_else(designcraft_doc::Stroke::none), f)); + let corners = (new.corners != old.corners).then_some(new.corners).flatten().map(|c| (old.corners.unwrap_or_default(), c)); + let follows = |it: &Item| { + effects.as_ref().is_some_and(|(o, _)| it.effects == *o) + || fill.as_ref().is_some_and(|(o, _)| it.fill == *o) + || stroke.as_ref().is_some_and(|(o, _)| it.stroke == *o) + || corners.as_ref().is_some_and(|(o, _)| it.corners == *o) }; - Ok(json!({"name": name, "updated": updated})) + let updated = d.update_items( + |it| it.object_style == new.name && follows(it), + |it| { + if let Some((o, n)) = &effects + && it.effects == *o + { + it.effects = n.clone(); + } + if let Some((o, n)) = &fill + && it.fill == *o + { + it.fill = n.clone(); + } + if let Some((o, n)) = &stroke + && it.stroke == *o + { + it.stroke = n.clone(); + } + if let Some((o, n)) = &corners + && it.corners == *o + { + it.corners = *n; + } + }, + ); + Ok(json!({"name": new.name, "updated": updated})) }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aacf469d..47bfcb523 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -685,6 +685,55 @@ fn object_style_effects_follow_the_style_until_overridden() { assert_eq!(s.doc().unwrap().revision, rev); } +/// The course's "Rounded Corner Image, White Stroke" style: made from a frame with rounded corners +/// and a paper stroke, applied to another frame, then edited (a thicker stroke, bigger corners): +/// the frames that follow the style change, one with its own stroke keeps it; a rename follows. +#[test] +fn object_styles_carry_corners_and_their_stroke_and_corners_can_be_edited() { + use designcraft_doc::ItemId; + use designcraft_geom::corners::CornerShape; + let mut s = session(); + let frame = |s: &mut Session, x: f64| { + s.execute("frame.create", &json!({"rect": [x, 36, x + 100.0, 136], "content": "graphic"})).unwrap()["id"].as_u64().unwrap() + }; + let item = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().clone(); + let a = frame(&mut s, 36.0); + s.execute("object.cornerOptions", &json!({"shape": "rounded", "size": 18, "ids": [a]})).unwrap(); + s.execute("object.stroke", &json!({"swatch": "[Paper]", "weight": 2.5, "ids": [a]})).unwrap(); + s.execute("selection.set", &json!({"ids": [a]})).unwrap(); + let name = + s.execute("style.object.create", &json!({"name": "Rounded Corner Image, White Stroke"})).unwrap()["name"].as_str().unwrap().to_string(); + let os = s.doc().unwrap().doc.styles.object_style(&name).cloned().unwrap(); + assert_eq!(os.corners.unwrap().corners[0].shape, CornerShape::Rounded); + let (b, c) = (frame(&mut s, 200.0), frame(&mut s, 360.0)); + s.execute("style.object.apply", &json!({"name": name, "ids": [a, b, c]})).unwrap(); + assert_eq!((item(&s, b).corners, item(&s, b).stroke.weight, item(&s, b).stroke.swatch.as_str()), (item(&s, a).corners, 2.5, "[Paper]")); + s.execute("object.stroke", &json!({"weight": 1, "ids": [c]})).unwrap(); + + let r = s.execute("style.object.edit", &json!({"name": name, "stroke": {"weight": 6}, "corners": {"size": 30}})).unwrap(); + assert_eq!(r["updated"], 3); + assert_eq!((item(&s, a).stroke.weight, item(&s, b).stroke.weight, item(&s, c).stroke.weight), (6.0, 6.0, 1.0)); + assert_eq!(item(&s, b).stroke.swatch, "[Paper]"); + assert!(item(&s, c).corners.corners.iter().all(|k| k.shape == CornerShape::Rounded && k.size == 30.0), "corners follow apart from the stroke"); + s.execute("style.object.edit", &json!({"name": name, "fill": "[Black]"})).unwrap(); + assert_eq!(item(&s, b).fill.swatch, "[Black]"); + + // Renamed, the items keep the style; a name in use or a built-in one is refused. + s.execute("style.object.edit", &json!({"name": name, "rename": "Framed"})).unwrap(); + assert_eq!(item(&s, a).object_style, "Framed"); + assert!(s.doc().unwrap().doc.styles.object_style(&name).is_none()); + assert!(s.execute("style.object.edit", &json!({"name": "Framed", "rename": "[Basic Text Frame]"})).is_err()); + assert!(s.execute("style.object.edit", &json!({"name": "[Basic Graphics Frame]", "rename": "Mine"})).is_err()); + assert!(s.execute("style.object.edit", &json!({"name": "Framed", "corners": {"size": "big"}})).is_err()); + // null: the style stops setting corners, the items keep theirs. + s.execute("style.object.edit", &json!({"name": "Framed", "corners": null})).unwrap(); + assert!(s.doc().unwrap().doc.styles.object_style("Framed").unwrap().corners.is_none()); + assert_eq!(item(&s, a).corners.corners[0].size, 30.0); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(item(&s, a).object_style, name); +} + #[test] fn object_style_effects_reach_grouped_and_anchored_items() { use designcraft_doc::ItemId; diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 86fef8e46..e29a6838e 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1058,6 +1058,13 @@ impl<'a> Ex<'a> { el.set("EnableStroke", bool_s(os.stroke.is_some())); el.set("EnableParagraphStyle", bool_s(os.paragraph_style.is_some())); el.set("EnableTextFrameGeneralOptions", bool_s(os.text_frame.is_some())); + if let Some(c) = &os.corners { + el.set("EnableStrokeAndCornerOptions", "true"); + for (n, c) in ["TopLeft", "TopRight", "BottomRight", "BottomLeft"].iter().zip(c.corners) { + el.set(&format!("{n}CornerOption"), names::corner_out(c.shape)); + el.set(&format!("{n}CornerRadius"), num(c.size)); + } + } if let Some(f) = &os.fill { el.set("FillColor", self.sw(&f.swatch)); el.set("FillTint", pct(f.tint as f64)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d770aef79..6160bb599 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1271,7 +1271,11 @@ impl<'r> Importer<'r> { None }; let effects = self.style_effects(e); - let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, text_frame, effects }; + // Corner options, when the style sets them (Stroke & Corner Options): the named corners + // in a rectangle's path order. + let corners = + (e.get("EnableStrokeAndCornerOptions") != Some("false")).then(|| corners(e, "")).flatten().map(|c| CornerOptions { corners: c }); + let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, text_frame, effects, corners }; match self.styles.object.iter_mut().find(|o| o.name == name) { Some(slot) => *slot = s, None => self.styles.object.push(s), diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 035333da4..19232c54c 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -3553,6 +3553,8 @@ fn object_style_effects_round_trip() { }; d.styles_mut().object.push(ObjectStyle { name: "Photo".into(), effects: Some(fx.clone()), ..Default::default() }); d.styles_mut().object.push(ObjectStyle { name: "Plain".into(), fill: Some(Fill::swatch("Black")), ..Default::default() }); + let rounded = designcraft_geom::corners::CornerOptions::uniform(designcraft_geom::corners::CornerShape::Rounded, 9.0); + d.styles_mut().object.push(ObjectStyle { name: "Rounded".into(), corners: Some(rounded), ..Default::default() }); let lid = d.default_layer(); let id = designcraft_doc::ItemId(d.alloc()); let mut it = designcraft_doc::Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0))); @@ -3583,6 +3585,9 @@ fn object_style_effects_round_trip() { assert!((ds.distance - 5.0).abs() < 1e-6 && (ds.angle - DropShadow::default().angle).abs() < 1e-6, "{ds:?}"); assert_eq!(got.feather, 3.0); assert!(style("Plain").effects.is_none()); + // Corner options (Stroke & Corner Options). + assert_eq!(style("Rounded").corners, Some(rounded)); + assert_eq!(style("Plain").corners, None); // The frame's effects are the style's, so it follows later edits of the style. let frames: Vec<_> = back.spreads[0].items.iter().filter(|i| i.object_style == "Photo").collect(); assert_eq!(frames[0].effects, got);