From 6eb2cf2f8394026561cb00c3c2ef17dfe6786350 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 12:26:53 -0400 Subject: [PATCH] M4: object styles (create from selection, apply fill/stroke/text frame options/paragraph style) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/style.rs | 60 ++++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 16 +++++++++ 2 files changed, 76 insertions(+) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 21b12d2af..928bcbac3 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -40,6 +40,8 @@ pub fn specs() -> Vec { "object": st.doc.styles.object.iter().map(|p| &p.name).collect::>(), })) }), + cmd!("style.object.apply", "Apply Object Style", [], None, "{name, ids?}", super::has_selection, apply_object), + cmd!("style.object.create", "New Object Style…", [], None, "{name, fromSelection?: true, fill?, paragraphStyle?}", has_doc, create_object), cmd!( "swatch.create", "New Color Swatch…", @@ -276,3 +278,61 @@ pub fn parse_color(v: &Value) -> Option { _ => None, } } + +fn apply_object(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.object.apply", "missing name"))?.to_string(); + let os = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad("style.object.apply", format!("no object style `{name}`")))?; + let ids = super::targets(s, p)?; + s.edit(|d, _| { + let mut stories = Vec::new(); + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + it.object_style = name.clone(); + if let Some(f) = &os.fill { + it.fill = f.clone(); + } + if let Some(st) = &os.stroke { + it.stroke = st.clone(); + } + if let Some(tf) = it.text_frame_mut() { + if let Some(o) = &os.text_frame { + tf.options = o.clone(); + } + stories.push(tf.story); + } + } + if let Some(ps) = &os.paragraph_style { + for sid in stories { + if let Some(st) = d.story_mut(sid) { + let len = st.len(); + st.format_paras(0..len, |f| f.style = ps.clone()); + } + } + } + Ok(Value::Null) + }) +} + +fn create_object(s: &mut Session, p: &Value) -> Result { + let base = str_param(p, "name").unwrap_or("Object Style 1").to_string(); + let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true); + let src = if from_sel { s.doc()?.selection.items.first().and_then(|i| s.doc().ok()?.doc.item(*i).cloned()) } else { None }; + let mut os = designcraft_doc::ObjectStyle::default(); + if let Some(it) = &src { + os.fill = Some(it.fill.clone()); + os.stroke = Some(it.stroke.clone()); + os.text_frame = it.text_frame().map(|t| t.options.clone()); + } + if let Some(sw) = str_param(p, "fill") { + os.fill = Some(designcraft_doc::Fill::swatch(sw)); + } + if let Some(ps) = str_param(p, "paragraphStyle") { + os.paragraph_style = Some(ps.to_string()); + } + s.edit(|d, _| { + let name = Styles::unique_name(|n| d.styles.object_style(n).is_some(), &base); + os.name = name.clone(); + d.styles_mut().object.push(os.clone()); + Ok(json!({"name": name})) + }) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3599e0fb1..8b658cc4c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -271,6 +271,22 @@ fn snippets_roundtrip_between_documents() { st.doc.check().unwrap(); } +#[test] +fn object_styles_create_and_apply() { + let mut s = session(); + let a = s.execute("frame.create", &json!({"rect": [36, 36, 136, 136], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); + s.execute("object.stroke", &json!({"weight": 4.0})).unwrap(); + s.execute("style.object.create", &json!({"name": "Magenta Box"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 36, 300, 136], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("style.object.apply", &json!({"name": "Magenta Box"})).unwrap(); + let d = &s.doc().unwrap().doc; + let (ia, ib) = (d.item(designcraft_doc::ItemId(a)).unwrap(), d.item(designcraft_doc::ItemId(b)).unwrap()); + assert_eq!(ia.fill, ib.fill); + assert_eq!(ib.stroke.weight, 4.0); + assert_eq!(ib.object_style, "Magenta Box"); +} + /// Unicode text per page, through hayro's interpreter (ToUnicode / ActualText). fn pdf_text(bytes: &[u8]) -> Vec { use hayro_interpret::font::Glyph;