Object styles: corner options, and editing a style's fill, stroke and corners

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 <noreply@anthropic.com>
This commit is contained in:
paaspaas00andClaude Opus 5.5 committed 2026-10-21 09:24:56 +02:00
1 parent 9f7504931d
commit 0e57712bba
7 files changed
+236 -23

No files matched your search

+1
View File
@@ -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.
+8 -1
View File
@@ -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<Effects>,
/// Corner Options (Stroke & Corner Options): each corner's shape and size, in path order.
#[serde(skip_serializing_if = "Option::is_none")]
pub corners: Option<CornerOptions>,
}
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();
}
+161 -21
View File
@@ -359,7 +359,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<Value> {
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<Value> {
})
}
/// `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<T: serde::Serialize + serde::de::DeserializeOwned>(cmd: &str, p: &Value, key: &str, base: &T) -> Result<Option<Option<T>>> {
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<Option<Option<designcraft_doc::Fill>>> {
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<Option<Option<designcraft_geom::corners::CornerOptions>>> {
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::<designcraft_geom::corners::CornerOptions>(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<Value> {
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<Value> {
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}))
})
}
+49
View File
@@ -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;
+7
View File
@@ -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));
+5 -1
View File
@@ -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),
+5
View File
@@ -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);