diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index cfb13dc53..c7fe4eaf9 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1254,6 +1254,8 @@ impl Effects { ds.size = len("dropShadow.size", ds.size)?; ds.spread = pct("dropShadow.spread", ds.spread)?; self.feather = len("feather", self.feather)?; + // As IDML import reads it, so a round trip keeps it. + self.feather_options.choke = pct("featherOptions.choke", self.feather_options.choke)?; let e = &mut self.inner_shadow; e.opacity = opacity("innerShadow.opacity", e.opacity)?; e.angle = finite("innerShadow.angle", e.angle)?; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 76e878277..7bcdcbb1d 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -377,7 +377,7 @@ pub fn specs() -> Vec { "Clear Object Style Overrides", [], None, - "{ids?} — each item takes its object style's fill, stroke, effects and text frame options again → {changed}", + "{ids?} — each item takes its object style's fill, stroke, effects and text frame options again, and a text frame's story its paragraph style → {changed}", super::has_selection, clear_object_overrides ), @@ -1430,18 +1430,33 @@ fn apply_object(s: &mut Session, p: &Value) -> Result { fn clear_object_overrides(s: &mut Session, p: &Value) -> Result { let ids = super::targets(s, p)?; - // Each target with its style's attributes applied, kept only where that changes the item. + // Each target with its style's attributes applied, kept only where that changes the item; + // and a text frame's story back in the style's paragraph style, as applying the style does. let d = &s.doc()?.doc; - let changed: Vec = ids - .iter() - .filter_map(|id| { - let it = d.item(*id)?; - let mut next = it.clone(); - d.styles.object_style(&it.object_style)?.apply_to(&mut next); - (next != *it).then_some(next) - }) - .collect(); - if changed.is_empty() { + let mut changed: Vec = Vec::new(); + let mut restyled: Vec<(designcraft_doc::StoryId, String)> = Vec::new(); + let mut n = 0; + for id in &ids { + let Some(it) = d.item(*id) else { continue }; + let Some(os) = d.styles.object_style(&it.object_style) else { continue }; + let mut next = it.clone(); + os.apply_to(&mut next); + let item_changed = next != *it; + if item_changed { + changed.push(next); + } + let mut story_changed = false; + if let (Some(ps), Some(tf)) = (&os.paragraph_style, it.text_frame()) + && d.story(tf.story).is_some_and(|st| st.paras.iter().any(|f| f.style != *ps)) + { + story_changed = true; + if !restyled.iter().any(|(sid, _)| *sid == tf.story) { + restyled.push((tf.story, ps.clone())); + } + } + n += usize::from(item_changed || story_changed); + } + if n == 0 { return Ok(json!({"changed": 0})); } s.edit(|d, _| { @@ -1450,7 +1465,13 @@ fn clear_object_overrides(s: &mut Session, p: &Value) -> Result { *it = next.clone(); } } - Ok(json!({"changed": changed.len()})) + for (sid, ps) in &restyled { + if let Some(st) = d.story_mut(*sid) { + let len = st.len(); + st.format_paras(0..len, |f| f.style = ps.clone()); + } + } + Ok(json!({"changed": n})) }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4770fb16a..6471eb131 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1710,3 +1710,32 @@ fn a_line_fits_when_its_baseline_does() { assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame"); assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); } + +/// Clear Overrides puts a text frame's story back in its object style's paragraph style, as +/// applying the style does; and a style's feather choke is kept to 0–100 %, as IDML reads it. +#[test] +fn object_style_clear_overrides_restores_the_paragraph_style_and_choke_is_clamped() { + let mut s = session(); + s.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap(); + s.execute("style.object.create", &json!({"name": "Captioned", "fromSelection": false, "paragraphStyle": "Caption"})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "One\nTwo"})).unwrap(); + let (id, sid) = (f["id"].as_u64().unwrap(), designcraft_doc::StoryId(f["story"].as_u64().unwrap())); + s.execute("style.object.apply", &json!({"name": "Captioned", "ids": [id]})).unwrap(); + let styles = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().paras.iter().map(|p| p.style.clone()).collect::>(); + assert_eq!(styles(&s), ["Caption", "Caption"]); + { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let st = std::sync::Arc::make_mut(doc.stories.get_mut(&sid).unwrap()); + st.paras[1].style = designcraft_doc::BASIC_PARAGRAPH.into(); + } + let r = s.execute("style.object.clearOverrides", &json!({"ids": [id]})).unwrap(); + assert_eq!(r["changed"], 1); + assert_eq!(styles(&s), ["Caption", "Caption"]); + let r = s.execute("style.object.clearOverrides", &json!({"ids": [id]})).unwrap(); + assert_eq!(r["changed"], 0, "nothing left to clear"); + + s.execute("style.object.create", &json!({"name": "Soft", "fromSelection": false, "effects": {"feather": 4, "featherOptions": {"choke": 150}}})) + .unwrap(); + let fx = s.doc().unwrap().doc.styles.object_style("Soft").unwrap().effects.clone().unwrap(); + assert_eq!(fx.feather_options.choke, 100.0); +}