Merge branch 'object-styles-panel' into object-style-paragraph-styles

# Conflicts:
#	ROADMAP.md
#	crates/engine/src/cmd/style.rs
#	crates/idml/src/import.rs
This commit is contained in:
moppeeer committed 2026-10-22 10:29:28 +02:00
commit 38443c0b86
7 files changed
+129 -36

No files matched your search

+4 -2
View File
@@ -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<Effects>,
/// 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<CornerOptions>,
}
@@ -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();
+4 -4
View File
@@ -1527,7 +1527,7 @@ 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.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<Value> {
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<Value> {
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()
+67
View File
@@ -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));
+1 -22
View File
@@ -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 {
+30 -7
View File
@@ -471,11 +471,18 @@ fn parse_point(s: Option<&str>) -> Option<Point> {
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) {
+21
View File
@@ -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 Self="ObjectStyle/Child" Name="Child" TopRightCornerOption="BevelCorner" TopRightCornerRadius="4">
<Properties><BasedOn type="object">ObjectStyle/Round</BasedOn></Properties>
</ObjectStyle>"#;
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]);
}
+2 -1
View File
@@ -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()},