From c22c48dc8e2636b2c481299e3cce16bd1932ee94 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 10:24:19 +0200 Subject: [PATCH 1/2] Object styles hold named corners; IDML styles inherit the corners they don't set An object style's corners are top left, top right, bottom right and bottom left, as InDesign's and as IDML import and export already read them. `corners::named_slots` finds which anchor of a path is at which corner, and `CornerOptions::named_to_path` / `path_to_named` convert: applying a style puts each corner on the anchor at that corner, whatever order the path runs in; a style made from a selection, and redefining one, convert the item's corners. IDML export's `corner_names` uses the same mapping. An IDML object style that sets some corners takes the others from its Based On chain, so applying a child that changes one corner keeps the parent's other corners. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/styles.rs | 6 ++- crates/engine/src/cmd/style.rs | 8 +-- crates/geom/src/corners.rs | 67 ++++++++++++++++++++++++++ crates/idml/src/export.rs | 23 +-------- crates/idml/src/import.rs | 37 +++++++++++--- crates/idml/src/tests/object_styles.rs | 21 ++++++++ 6 files changed, 127 insertions(+), 35 deletions(-) diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 380f0fd3a..ca7b13665 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -60,7 +60,9 @@ 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. + /// Corner Options (Stroke & Corner Options): each corner's shape and size, by name (top left, + /// top right, bottom right, bottom left), as InDesign's; an item takes them at the anchors at + /// those corners ([`CornerOptions::named_to_path`]). #[serde(skip_serializing_if = "Option::is_none")] pub corners: Option, } @@ -69,7 +71,7 @@ impl ObjectStyle { /// 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; + it.corners = c.named_to_path(&it.path); } 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 7bcdcbb1d..86ad2ea87 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1528,7 +1528,7 @@ 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.corners = Some(it.corners.path_to_named(&it.path)); os.text_frame = it.text_frame().map(|t| t.options.clone()); os.effects = Some(it.effects.clone()); } @@ -1705,7 +1705,7 @@ fn edit_object(s: &mut Session, p: &Value) -> Result { 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) + || corners.as_ref().is_some_and(|(o, _)| it.corners == o.named_to_path(&it.path)) }; let updated = d.update_items( |it| it.object_style == new.name && follows(it), @@ -1726,9 +1726,9 @@ fn edit_object(s: &mut Session, p: &Value) -> Result { it.stroke = n.clone(); } if let Some((o, n)) = &corners - && it.corners == *o + && it.corners == o.named_to_path(&it.path) { - it.corners = *n; + it.corners = n.named_to_path(&it.path); } }, ); diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 5f0e2cfda..9c1bfbb00 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -55,10 +55,53 @@ pub struct CornerOptions { pub corners: [Corner; 4], } +/// The named corner (0 top left, 1 top right, 2 bottom right, 3 bottom left) each of a path's +/// four anchors is, by its place on the path's bounds. A path that isn't one subpath of four +/// anchors at four different corners keeps a rectangle's order. +pub fn named_slots(path: &PathData) -> [usize; 4] { + let rect_order = [0, 1, 2, 3]; + let Some(sp) = path.subpaths.first() else { return rect_order }; + let Some(b) = path.bounds() else { return rect_order }; + if sp.anchors.len() != 4 { + return rect_order; + } + let c = b.center(); + let mut out = rect_order; + let mut seen = [false; 4]; + for (i, a) in sp.anchors.iter().enumerate() { + let k = match (a.p.y < c.y, a.p.x < c.x) { + (true, true) => 0, + (true, false) => 1, + (false, false) => 2, + (false, true) => 3, + }; + if std::mem::replace(&mut seen[k], true) { + return rect_order; + } + out[i] = k; + } + out +} + impl CornerOptions { pub fn uniform(shape: CornerShape, size: f64) -> Self { Self { corners: [Corner { shape, size }; 4] } } + + /// Named corners (top left, top right, bottom right, bottom left), as an object style holds + /// them, in `path`'s order. + pub fn named_to_path(&self, path: &PathData) -> Self { + Self { corners: named_slots(path).map(|k| self.corners[k]) } + } + + /// `path`-order corners as named corners (top left, top right, bottom right, bottom left). + pub fn path_to_named(&self, path: &PathData) -> Self { + let mut corners = self.corners; + for (i, k) in named_slots(path).into_iter().enumerate() { + corners[k] = self.corners[i]; + } + Self { corners } + } pub fn is_none(&self) -> bool { self.corners.iter().all(Corner::is_none) } @@ -252,6 +295,30 @@ mod tests { use crate::shapes::rectangle; use kurbo::{Rect, Shape}; + /// Named corners go to the anchors at those corners, whatever order the path runs in, and + /// come back the same. + #[test] + fn named_corners_map_to_any_path_order() { + use kurbo::Point; + let named = CornerOptions { + corners: [CornerShape::Rounded, CornerShape::Bevel, CornerShape::Inset, CornerShape::Fancy].map(|shape| Corner { shape, size: 6.0 }), + }; + // Top left, bottom left, bottom right, top right (as InDesign writes a rectangle). + let down_first = PathData::single(SubPath::polyline( + &[Point::new(0.0, 0.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0), Point::new(100.0, 0.0)], + true, + )); + assert_eq!(named_slots(&down_first), [0, 3, 2, 1]); + let on_path = named.named_to_path(&down_first); + let shapes = on_path.corners.map(|c| c.shape); + assert_eq!(shapes, [CornerShape::Rounded, CornerShape::Fancy, CornerShape::Inset, CornerShape::Bevel]); + assert_eq!(on_path.path_to_named(&down_first), named); + // A rectangle in its own order, and a triangle, keep the order. + assert_eq!(named.named_to_path(&rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))), named); + let tri = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(5.0, 9.0)], true)); + assert_eq!(named.named_to_path(&tri), named); + } + #[test] fn none_is_identity() { let r = rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index a4330f4e2..1fb0f202b 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -3020,28 +3020,7 @@ fn path_geometry(path: &PathData) -> El { /// IDML corner names for anchors 0..4 of a path (by position on its bounding box). pub(crate) fn corner_names(path: &PathData) -> [&'static str; 4] { - let default = ["TopLeft", "TopRight", "BottomRight", "BottomLeft"]; - let Some(sp) = path.subpaths.first() else { return default }; - if sp.anchors.len() != 4 { - return default; - } - let Some(b) = path.bounds() else { return default }; - let c = b.center(); - let mut out = default; - let mut seen = HashSet::new(); - for (i, a) in sp.anchors.iter().enumerate() { - let n = match (a.p.y < c.y, a.p.x < c.x) { - (true, true) => "TopLeft", - (true, false) => "TopRight", - (false, false) => "BottomRight", - (false, true) => "BottomLeft", - }; - if !seen.insert(n) { - return default; - } - out[i] = n; - } - out + designcraft_geom::corners::named_slots(path).map(|k| ["TopLeft", "TopRight", "BottomRight", "BottomLeft"][k]) } fn format_name(mime: &str) -> &'static str { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index db1439053..46c604e8f 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -471,11 +471,18 @@ fn parse_point(s: Option<&str>) -> Option { const INDESIGN_CORNER_SIZE: f64 = 12.0; /// The four corners of a paragraph border or shading (`prefix` `ParagraphBorder` / -/// `ParagraphShading`): top left, top right, bottom right, bottom left. Unset when none is given. +/// `ParagraphShading`), or an object style (no prefix): top left, top right, bottom right, +/// bottom left. Unset when none is given. fn corners(e: &El, prefix: &str) -> Option<[Corner; 4]> { + corners_in(&[e], prefix) +} + +/// [`corners`] over `levels` (an object style, then the styles it is based on): each corner's +/// shape and size from the first level that gives it. +fn corners_in(levels: &[&El], prefix: &str) -> Option<[Corner; 4]> { let keys = ["TopLeft", "TopRight", "BottomRight", "BottomLeft"]; - let shapes = keys.map(|n| e.prop(&format!("{prefix}{n}CornerOption"))); - let sizes = keys.map(|n| e.num(&format!("{prefix}{n}CornerRadius"))); + let shapes = keys.map(|n| levels.iter().find_map(|e| e.prop(&format!("{prefix}{n}CornerOption")))); + let sizes = keys.map(|n| levels.iter().find_map(|e| e.num(&format!("{prefix}{n}CornerRadius")))); if shapes.iter().all(Option::is_none) && sizes.iter().all(Option::is_none) { return None; } @@ -1247,6 +1254,7 @@ impl<'r> Importer<'r> { None => self.styles.table.push(ts), } } + let objects_by_self: HashMap<&str, &El> = objects.iter().filter_map(|(_, e)| Some((e.get("Self")?, e))).collect(); for (_, e) in &objects { let name = names::style_name_in(OBJECT_BUILTINS, e.get("Name").unwrap_or("")); let based_on = e.prop("BasedOn").map(|b| { @@ -1274,10 +1282,25 @@ impl<'r> Importer<'r> { None }; let effects = self.style_effects(e); - // 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 }); + // Corner options, when the style sets them (Stroke & Corner Options), by name. A style + // that sets some corners takes the others from the styles it is based on, as the style + // holds all four. + let corners = (e.get("EnableStrokeAndCornerOptions") != Some("false") && corners(e, "").is_some()) + .then(|| { + let mut levels = vec![e]; + let mut next = e.prop("BasedOn"); + while let Some(b) = next.take() + && levels.len() < 16 + && let Some(s) = objects_by_self.get(b.trim()).copied() + && !levels.iter().any(|l| std::ptr::eq(*l, s)) + { + levels.push(s); + next = s.prop("BasedOn"); + } + corners_in(&levels, "") + }) + .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, diff --git a/crates/idml/src/tests/object_styles.rs b/crates/idml/src/tests/object_styles.rs index 4e7f5ef5d..10b0d940b 100644 --- a/crates/idml/src/tests/object_styles.rs +++ b/crates/idml/src/tests/object_styles.rs @@ -227,3 +227,24 @@ fn feathers_come_from_the_object_style_unless_its_category_turns_them_off() { assert!(fx[2].gradient_feather.on); assert!(!fx[2].directional_feather.on, "the item's own setting wins"); } + +/// A style that sets one corner takes the others from the style it is based on, so applying it +/// keeps the based-on style's rounded corners; and a style's named corners go to the anchors at +/// those corners of a frame whose path runs top left, bottom left, bottom right, top right. +#[test] +fn a_style_setting_one_corner_inherits_the_others_and_names_them() { + let child = r#" + ObjectStyle/Round + "#; + let d = fixture(&format!("{ROUND}{child}"), &[(r#"AppliedObjectStyle="ObjectStyle/Child""#, "")]); + let style = d.styles.object.iter().find(|s| s.name == "Child").unwrap(); + let c = style.corners.expect("the child sets corners").corners; + assert_eq!(c.map(|c| c.shape), [CornerShape::Rounded, CornerShape::Bevel, CornerShape::Rounded, CornerShape::Rounded], "{c:?}"); + assert_eq!(c.map(|c| c.size), [17.0, 4.0, 17.0, 17.0]); + // Applied to the fixture's frame (anchors top left, bottom left, bottom right, top right): + // the bevel goes to the top right anchor, the last. + let mut it = (*d.spreads[0].items[0]).clone(); + it.corners = Default::default(); + style.apply_to(&mut it); + assert_eq!(it.corners.corners.map(|c| c.shape), [CornerShape::Rounded, CornerShape::Rounded, CornerShape::Rounded, CornerShape::Bevel]); +} From 496b2fe4dc2adf77831533bb3cf5afd0b647d2cb Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 10:28:47 +0200 Subject: [PATCH 2/2] New Object Style: the selected object's corners as named corners With #1455's named style corners merged, the dialog seeds the style's corners from the selected item through `path_to_named`, so a frame whose path runs in another order gives each corner its own options. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/object_style_dialog.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs index 9dc905b3c..1678a2dee 100644 --- a/crates/ui-egui/src/object_style_dialog.rs +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -29,7 +29,8 @@ pub fn open_new(app: &mut DesignApp) { "basedOn": designcraft_doc::NO_OBJECT_STYLE, "fill": it.as_ref().map(|i| i.fill.clone()).unwrap_or_default(), "stroke": it.as_ref().map(|i| i.stroke.clone()).unwrap_or_default(), - "corners": it.as_ref().map(|i| i.corners).unwrap_or_default(), + // A style holds named corners (top left, top right, …), the item its path's. + "corners": it.as_ref().map(|i| i.corners.path_to_named(&i.path)).unwrap_or_default(), "fx": crate::effects_dialog::effects_json(&it.map(|i| i.effects).unwrap_or_default()), "hadEffects": from, "inc": {"fill": from, "stroke": from, "corners": from},