diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 14bec3776..b12a6f10b 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -64,7 +64,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, } @@ -73,7 +75,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 412d97a14..5bd6c8944 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1527,7 +1527,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()); } @@ -1761,7 +1761,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)) || frame.as_ref().is_some_and(|(o, _)| it.text_frame().is_some_and(|t| t.options == *o)) }; let updated = d.update_items( @@ -1783,9 +1783,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); } if let Some((o, n)) = &frame && let Some(tf) = it.text_frame_mut() 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 59e2b2aaa..4374ac09a 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -3021,28 +3021,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 4b8ec56d9..a8630010e 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 apply_next_style = paragraph_style.is_some() && e.get("ApplyNextParagraphStyle") == Some("true"); let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, apply_next_style, text_frame, effects, corners }; match self.styles.object.iter_mut().find(|o| o.name == name) { diff --git a/crates/idml/src/tests/object_styles.rs b/crates/idml/src/tests/object_styles.rs index f445c7f41..41624f855 100644 --- a/crates/idml/src/tests/object_styles.rs +++ b/crates/idml/src/tests/object_styles.rs @@ -255,3 +255,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]); +} diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs index 88a971771..fc1fbbd1a 100644 --- a/crates/ui-egui/src/object_style_dialog.rs +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -56,7 +56,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, "paragraph": from, "textFrame": frame.is_some()},