From 98ff430022d17758262f2235899e9c8087cd46b6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 00:40:59 +0000 Subject: [PATCH 1/2] feat(styles): object styles carry effects (#653) --- ROADMAP.md | 3 +- crates/doc/src/edit.rs | 44 +++++++++ crates/doc/src/styles.rs | 6 +- crates/engine/src/cmd/style.rs | 166 ++++++++++++++++++++++++++++++--- crates/engine/src/tests.rs | 55 +++++++++++ crates/idml/src/export.rs | 63 ++++++++++--- crates/idml/src/import.rs | 73 ++++++++++----- crates/idml/src/tests.rs | 31 ++++++ docs/target-app-parity.md | 3 +- 9 files changed, 389 insertions(+), 55 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 91a951616..e1a6d9f07 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (object styles carry effects) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Object styles carry effects (drop shadow, feather, inner and outer shadows and glows, bevel, satin, gradient and directional feather). New styles from a selection keep its effects, `style.object.edit` changes a style's effects and every item that follows them, `style.object.clearOverrides` returns items to their style, and IDML ObjectStyles write and read the drop shadow and feather (#653). No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 3bb827135..90bf45012 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -368,6 +368,50 @@ impl Document { v } + /// Calls `f` on every item `hit` selects: on document and parent spreads, inside groups and + /// among anchored objects. Only the branches holding a hit are copied (copy-on-write). Returns + /// how many items `f` saw. + pub fn update_items(&mut self, hit: impl Fn(&Item) -> bool, mut f: impl FnMut(&mut Item)) -> usize { + const MAX_GROUP_DEPTH: usize = 64; + fn any(it: &Item, hit: &impl Fn(&Item) -> bool) -> bool { + let mut found = false; + it.walk(&mut |i| found |= hit(i)); + found + } + fn update(it: &mut Item, hit: &impl Fn(&Item) -> bool, f: &mut impl FnMut(&mut Item), n: &mut usize, depth: usize) { + if hit(it) { + f(it); + *n += 1; + } + if depth >= MAX_GROUP_DEPTH { + return; + } + if let Some(children) = it.children_mut() { + for c in children.iter_mut().filter(|c| any(c, hit)) { + update(Arc::make_mut(c), hit, f, n, depth + 1); + } + } + } + let mut n = 0; + for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) { + if !sp.items.iter().any(|i| any(i, &hit)) { + continue; + } + for it in Arc::make_mut(sp).items.iter_mut().filter(|i| any(i, &hit)) { + update(Arc::make_mut(it), &hit, &mut f, &mut n, 0); + } + } + for st in self.stories.values_mut() { + if !st.objects.iter().any(|o| any(&o.item, &hit)) { + continue; + } + for o in Arc::make_mut(st).objects.iter_mut().filter(|o| any(&o.item, &hit)) { + update(&mut Arc::make_mut(o).item, &hit, &mut f, &mut n, 0); + } + } + n + } + /// Plain story text with frame/story stats (for agents). pub fn story_summary(&self, sid: StoryId) -> Option { let st = self.story(sid)?; diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index e348ff295..de76c7f11 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::attrs::{CharAttrs, CharProps, ParaAttrs, ParaProps}; -use crate::item::{Fill, Stroke, TextFrameOptions}; +use crate::item::{Effects, Fill, Stroke, TextFrameOptions}; use crate::story::{BASIC_PARAGRAPH, CharFormat, NO_CHAR_STYLE, ParaFormat}; pub const NO_PARA_STYLE: &str = "[No Paragraph Style]"; @@ -55,6 +55,10 @@ pub struct ObjectStyle { pub paragraph_style: Option, #[serde(skip_serializing_if = "Option::is_none")] pub text_frame: Option, + /// Effects (drop shadow, feather, glows…). An item using the style follows them until its own + /// effects differ from the style's (a local override). + #[serde(skip_serializing_if = "Option::is_none")] + pub effects: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4bf69ec41..44eec0ba5 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1,7 +1,7 @@ //! Paragraph / character styles and swatches. use designcraft_color::{Swatch, SwatchValue}; -use designcraft_doc::{CharAttrs, CharacterStyle, ParaAttrs, ParagraphStyle, Styles}; +use designcraft_doc::{CharAttrs, CharacterStyle, Effects, Item, ObjectStyle, ParaAttrs, ParagraphStyle, Styles}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, ok, str_param}; @@ -128,8 +128,42 @@ pub fn specs() -> Vec { "table": st.doc.styles.table.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!( + "style.object.apply", + "Apply Object Style", + [], + None, + "{name, ids?} — sets the style's fill, stroke, effects and text frame options on the items", + super::has_selection, + apply_object + ), + cmd!( + "style.object.create", + "New Object Style…", + [], + None, + "{name, fromSelection?: true (fill, stroke, effects and text frame options of the first selected item), fill?, paragraphStyle?, effects?: {dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?}, dropShadow?: {on?, color?, opacity? (0–1), angle?, globalLight?, distance?, size?, spread?}, feather?: width, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} → {name} (made unique)", + has_doc, + create_object + ), + cmd!( + "style.object.edit", + "Object Style Options…", + [], + None, + "{name, effects?: {…} | null (the style no longer sets effects), dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} — effect objects merge field by field into the style's effects (fields as in `document.inspect` item effects); items using the style whose effects match the style's take the new effects, items with their own effects keep them → {name, updated}", + has_doc, + edit_object + ), + cmd!( + "style.object.clearOverrides", + "Clear Object Style Overrides", + [], + None, + "{ids?} — each item takes its object style's fill, stroke, effects and text frame options again", + super::has_selection, + clear_object_overrides + ), cmd!( "swatch.create", "New Color Swatch…", @@ -746,16 +780,8 @@ fn apply_object(s: &mut Session, p: &Value) -> Result { 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(); - } + set_object_attrs(it, &os); + if let Some(tf) = it.text_frame() { stories.push(tf.story); } } @@ -771,15 +797,89 @@ fn apply_object(s: &mut Session, p: &Value) -> Result { }) } +/// Gives `it` the attributes the object style `os` sets. +fn set_object_attrs(it: &mut Item, os: &ObjectStyle) { + if let Some(f) = &os.fill { + it.fill = f.clone(); + } + if let Some(st) = &os.stroke { + it.stroke = st.clone(); + } + if let Some(e) = &os.effects { + it.effects = e.clone(); + } + if let (Some(tf), Some(o)) = (it.text_frame_mut(), &os.text_frame) { + tf.options = o.clone(); + } +} + +fn clear_object_overrides(s: &mut Session, p: &Value) -> Result { + let ids = super::targets(s, p)?; + s.edit(|d, _| { + for id in &ids { + let Some(os) = d.item(*id).and_then(|it| d.styles.object_style(&it.object_style)).cloned() else { continue }; + if let Some(it) = d.item_mut(*id) { + set_object_attrs(it, &os); + } + } + Ok(Value::Null) + }) +} + +/// Effect keys accepted at the top level of `style.object.create` / `edit` as shorthand for +/// `effects.`. +const EFFECT_KEYS: [&str; 9] = + ["dropShadow", "feather", "innerShadow", "outerGlow", "innerGlow", "bevel", "satin", "gradientFeather", "directionalFeather"]; + +/// The effects `p` gives (`effects`, then the top-level effect keys) merged field by field over +/// `base`, or `None` when `p` names no effect. +fn effects_param(cmd: &str, p: &Value, base: &Effects) -> Result> { + let mut patch = serde_json::Map::new(); + match p.get("effects") { + None | Some(Value::Null) => {} + Some(Value::Object(o)) => patch.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))), + Some(_) => return Err(bad(cmd, "`effects` must be an object or null")), + } + for k in EFFECT_KEYS { + if let Some(v) = p.get(k) { + patch.insert(k.to_string(), v.clone()); + } + } + if patch.is_empty() { + return Ok(None); + } + let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?; + merge_json(&mut v, Value::Object(patch), 0); + serde_json::from_value(v).map(Some).map_err(|e| bad(cmd, format!("effects: {e}"))) +} + +/// Merges `top` into `base`: objects key by key, anything else replaced. +fn merge_json(base: &mut Value, top: Value, depth: usize) { + match (base, top) { + (Value::Object(b), Value::Object(t)) if depth < 8 => { + for (k, v) in t { + match b.get_mut(&k) { + Some(slot) => merge_json(slot, v, depth + 1), + None => { + b.insert(k, v); + } + } + } + } + (b, t) => *b = t, + } +} + 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(); + let mut os = 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()); + os.effects = Some(it.effects.clone()); } if let Some(sw) = str_param(p, "fill") { os.fill = Some(designcraft_doc::Fill::swatch(sw)); @@ -787,6 +887,9 @@ fn create_object(s: &mut Session, p: &Value) -> Result { if let Some(ps) = str_param(p, "paragraphStyle") { os.paragraph_style = Some(ps.to_string()); } + if let Some(e) = effects_param("style.object.create", p, os.effects.as_ref().unwrap_or(&Effects::default()))? { + os.effects = Some(e); + } s.edit(|d, _| { let name = Styles::unique_name(|n| d.styles.object_style(n).is_some(), &base); os.name = name.clone(); @@ -795,6 +898,41 @@ fn create_object(s: &mut Session, p: &Value) -> Result { }) } +fn edit_object(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "style.object.edit"; + let name = str_param(p, "name").ok_or_else(|| bad(CMD, "missing name"))?.to_string(); + if name == designcraft_doc::NO_OBJECT_STYLE { + return Err(bad(CMD, format!("`{name}` can't be edited"))); + } + let old = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad(CMD, format!("no object style `{name}`")))?; + let effects = if p.get("effects").is_some_and(Value::is_null) { + if EFFECT_KEYS.iter().any(|k| p.get(k).is_some()) { + return Err(bad(CMD, "`effects: null` can't be combined with effect settings")); + } + None + } else { + match effects_param(CMD, p, old.effects.as_ref().unwrap_or(&Effects::default()))? { + Some(e) => Some(e), + None => return Ok(json!({"name": name, "updated": 0})), + } + }; + s.edit(|d, _| { + let Some(os) = d.styles_mut().object.iter_mut().find(|o| o.name == name) else { + return Err(bad(CMD, format!("no object style `{name}`"))); + }; + os.effects = effects.clone(); + // An item follows the style while its effects are the style's; when the style didn't set + // effects before, every item using it takes them. + let updated = match &effects { + Some(new) => { + d.update_items(|it| it.object_style == name && old.effects.as_ref().is_none_or(|e| it.effects == *e), |it| it.effects = new.clone()) + } + None => 0, + }; + Ok(json!({"name": name, "updated": updated})) + }) +} + /// The swatch name of a colour value ("C=… M=… Y=… K=…" / "R=… G=… B=…"). fn value_name(color: designcraft_color::Color) -> String { match color { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 17d1ed098..a3b1e9897 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -438,6 +438,61 @@ fn object_styles_create_and_apply() { assert_eq!(ib.object_style, "Magenta Box"); } +#[test] +fn object_style_effects_follow_the_style_until_overridden() { + use designcraft_doc::{BASIC_GRAPHICS_FRAME, ItemId}; + let mut s = session(); + let frame = |s: &mut Session, x: f64| { + let r = s.execute("frame.create", &json!({"rect": [x, 36, x + 100.0, 136], "content": "unassigned"})).unwrap(); + r["id"].as_u64().unwrap() + }; + let item = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().clone(); + let shadow = |s: &Session, id: u64| item(s, id).effects.drop_shadow; + let a = frame(&mut s, 36.0); + s.execute("object.dropShadow", &json!({"on": true, "distance": 0, "size": 12, "opacity": 0.28, "ids": [a]})).unwrap(); + s.execute("selection.set", &json!({"ids": [a]})).unwrap(); + s.execute("style.object.create", &json!({"name": "Photo", "fromSelection": true})).unwrap(); + let b = frame(&mut s, 200.0); + let c = frame(&mut s, 360.0); + s.execute("style.object.apply", &json!({"name": "Photo", "ids": [a, b, c]})).unwrap(); + assert_eq!(shadow(&s, b), shadow(&s, a)); + assert!(shadow(&s, b).on && shadow(&s, b).size == 12.0); + s.execute("object.dropShadow", &json!({"on": true, "size": 30, "ids": [c]})).unwrap(); + + // Editing the style updates the items that follow it; `c`'s local shadow stays. + let r = s.execute("style.object.edit", &json!({"name": "Photo", "dropShadow": {"size": 20}})).unwrap(); + assert_eq!(r["updated"], 2); + assert_eq!((shadow(&s, a).size, shadow(&s, b).size, shadow(&s, c).size), (20.0, 20.0, 30.0)); + assert_eq!(shadow(&s, a).opacity, 0.28); + s.execute("style.object.clearOverrides", &json!({"ids": [c]})).unwrap(); + assert_eq!(shadow(&s, c), shadow(&s, a)); + + // Saved and opened again, the style keeps its effects. + let bytes = s.execute("file.serialize", &json!({})).unwrap(); + let mut back = Session::new(); + back.execute("file.openBytes", &json!({"name": "copy", "base64": bytes["base64"]})).unwrap(); + let d = &back.doc().unwrap().doc; + assert_eq!(d.styles.object_style("Photo").unwrap().effects.as_ref().unwrap().drop_shadow.size, 20.0); + assert!(d.styles.object_style(BASIC_GRAPHICS_FRAME).unwrap().effects.is_none()); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(shadow(&s, c).size, 30.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((shadow(&s, a).size, shadow(&s, b).size), (12.0, 12.0)); + assert_eq!(s.doc().unwrap().doc.styles.object_style("Photo").unwrap().effects.as_ref().unwrap().drop_shadow.size, 12.0); + + // A style that didn't set effects gives them to every item using it; `null` stops setting them. + let e = frame(&mut s, 36.0); + s.execute("style.object.apply", &json!({"name": BASIC_GRAPHICS_FRAME, "ids": [e]})).unwrap(); + s.execute("object.dropShadow", &json!({"on": true, "ids": [e]})).unwrap(); + s.execute("style.object.edit", &json!({"name": BASIC_GRAPHICS_FRAME, "feather": 4})).unwrap(); + assert_eq!(item(&s, e).effects, designcraft_doc::Effects { feather: 4.0, ..Default::default() }); + s.execute("style.object.edit", &json!({"name": BASIC_GRAPHICS_FRAME, "effects": null})).unwrap(); + assert!(s.doc().unwrap().doc.styles.object_style(BASIC_GRAPHICS_FRAME).unwrap().effects.is_none()); + assert_eq!(item(&s, e).effects.feather, 4.0); + assert!(s.execute("style.object.edit", &json!({"name": "Photo", "dropShadow": {"size": "big"}})).is_err()); +} + /// Unicode text per page, through hayro's interpreter (ToUnicode / ActualText). fn pdf_text(bytes: &[u8]) -> Vec { use hayro_interpret::font::Glyph; diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b9..5a44dfe19 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1034,9 +1034,31 @@ impl<'a> Ex<'a> { el.set("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, ps)); } let mut el = with_props(el, props); + if let Some(e) = &os.effects { + let mut t = El::new("TransparencySetting"); + for c in self.effect_els(e, true) { + t.push(c); + } + el.push(t); + } if let Some(tf) = &os.text_frame { el.push(text_frame_pref(tf, &self.sw(&tf.column_rule_color))); } + // Effects: a style with effects sets the drop shadow and feather, never blending. + let on = bool_s(os.effects.is_some()); + el.push( + El::new("ObjectStyleObjectEffectsCategorySettings") + .attr("EnableTransparency", "false") + .attr("EnableDropShadow", on) + .attr("EnableFeather", on) + .attr("EnableInnerShadow", "false") + .attr("EnableOuterGlow", "false") + .attr("EnableInnerGlow", "false") + .attr("EnableBevelEmboss", "false") + .attr("EnableSatin", "false") + .attr("EnableDirectionalFeather", "false") + .attr("EnableGradientFeather", "false"), + ); og.insert(&os.name, el); } let mut ro = El::new("RootObjectStyleGroup").attr("Self", self.fresh()); @@ -1686,21 +1708,8 @@ impl<'a> Ex<'a> { if it.opacity < 1.0 || it.blend != designcraft_color::BlendMode::Normal || ds.on || it.effects.feather > 0.0 { let mut t = El::new("TransparencySetting"); t.push(El::new("BlendingSetting").attr("BlendMode", names::blend_out(it.blend)).attr("Opacity", pct(it.opacity as f64))); - if ds.on { - let a = self.d.light_angle(ds.angle, ds.global_light).to_radians(); - t.push( - El::new("DropShadowSetting") - .attr("Mode", "Drop") - .attr("XOffset", num(-ds.distance * a.cos())) - .attr("YOffset", num(ds.distance * a.sin())) - .attr("Size", num(ds.size)) - .attr("Spread", num(ds.spread)) - .attr("Opacity", pct(ds.opacity as f64)) - .attr("EffectColor", self.sw(&ds.color)), - ); - } - if it.effects.feather > 0.0 { - t.push(El::new("FeatherSetting").attr("Mode", "Standard").attr("Width", num(it.effects.feather))); + for c in self.effect_els(&it.effects, false) { + t.push(c); } el.push(t); } @@ -1760,6 +1769,30 @@ impl<'a> Ex<'a> { el } + /// The `TransparencySetting` children for `e`'s drop shadow and feather; `all` also writes the + /// effects that are off (`Mode="None"`), as an object style that sets effects does. + fn effect_els(&self, e: &designcraft_doc::Effects, all: bool) -> Vec { + let mut out = Vec::new(); + let ds = &e.drop_shadow; + if ds.on || all { + let a = self.d.light_angle(ds.angle, ds.global_light).to_radians(); + out.push( + El::new("DropShadowSetting") + .attr("Mode", if ds.on { "Drop" } else { "None" }) + .attr("XOffset", num(-ds.distance * a.cos())) + .attr("YOffset", num(ds.distance * a.sin())) + .attr("Size", num(ds.size)) + .attr("Spread", num(ds.spread)) + .attr("Opacity", pct(ds.opacity as f64)) + .attr("EffectColor", self.sw(&ds.color)), + ); + } + if e.feather > 0.0 || all { + out.push(El::new("FeatherSetting").attr("Mode", if e.feather > 0.0 { "Standard" } else { "None" }).attr("Width", num(e.feather))); + } + out + } + fn image_el(&mut self, asset: &designcraft_doc::Asset, g: &designcraft_doc::Graphic) -> El { let is_pdf = asset.mime == "application/pdf"; let mut el = El::new(if is_pdf { "PDF" } else { "Image" }).attr("Self", self.fresh()); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b6..93fbe375a 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -983,7 +983,8 @@ impl<'r> Importer<'r> { } else { None }; - let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, text_frame }; + let effects = self.style_effects(e); + let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, text_frame, effects }; match self.styles.object.iter_mut().find(|o| o.name == name) { Some(slot) => *slot = s, None => self.styles.object.push(s), @@ -2008,6 +2009,53 @@ impl<'r> Importer<'r> { None } + /// Reads the drop shadow and feather of a `TransparencySetting` into `fx`. + fn effects_in(&mut self, t: &El, fx: &mut Effects) { + if let Some(ds) = t.find("DropShadowSetting") + && ds.get("Mode") == Some("Drop") + { + let x = ds.num("XOffset").unwrap_or(4.95); + let y = ds.num("YOffset").unwrap_or(4.95); + let def = DropShadow::default(); + fx.drop_shadow = DropShadow { + on: true, + color: ds.get("EffectColor").map(|c| self.swatch_ref(c)).unwrap_or(def.color), + opacity: ds.num("Opacity").map(|o| (o / 100.0) as f32).unwrap_or(def.opacity), + angle: names_angle(x, y), + distance: x.hypot(y), + size: ds.num("Size").unwrap_or(def.size), + spread: ds.num("Spread").unwrap_or(def.spread), + global_light: false, + }; + } + if let Some(f) = t.find("FeatherSetting") + && f.get("Mode").is_some_and(|m| m != "None") + { + fx.feather = f.num("Width").unwrap_or(9.0); + } + } + + /// An object style's effects: set when its effects category turns on an effect this model + /// reads, or (without category settings) when it gives a drop shadow or feather that is on. + fn style_effects(&mut self, e: &El) -> Option { + let t = e.find("TransparencySetting"); + let set = match e.find("ObjectStyleObjectEffectsCategorySettings") { + Some(c) => ["EnableDropShadow", "EnableFeather"].iter().any(|k| c.get(k) == Some("true")), + None => t.is_some_and(|t| { + t.find("DropShadowSetting").is_some_and(|d| d.get("Mode") == Some("Drop")) + || t.find("FeatherSetting").is_some_and(|f| f.get("Mode").is_some_and(|m| m != "None")) + }), + }; + if !set { + return None; + } + let mut fx = Effects::default(); + if let Some(t) = t { + self.effects_in(t, &mut fx); + } + Some(fx) + } + fn stroke_from(&mut self, e: &El, item: Option<&El>) -> Stroke { let g = |s: &Self, k: &str| -> Option { if item.is_some() { s.attr_or_style(e, k) } else { e.get(k).map(str::to_string) } }; let color = g(self, "StrokeColor"); @@ -2135,28 +2183,7 @@ impl<'r> Importer<'r> { it.blend = names::blend_in(m); } } - if let Some(ds) = t.find("DropShadowSetting") - && ds.get("Mode") == Some("Drop") - { - let x = ds.num("XOffset").unwrap_or(4.95); - let y = ds.num("YOffset").unwrap_or(4.95); - let def = DropShadow::default(); - it.effects.drop_shadow = DropShadow { - on: true, - color: ds.get("EffectColor").map(|c| self.swatch_ref(c)).unwrap_or(def.color), - opacity: ds.num("Opacity").map(|o| (o / 100.0) as f32).unwrap_or(def.opacity), - angle: names_angle(x, y), - distance: x.hypot(y), - size: ds.num("Size").unwrap_or(def.size), - spread: ds.num("Spread").unwrap_or(def.spread), - global_light: false, - }; - } - if let Some(f) = t.find("FeatherSetting") - && f.get("Mode").is_some_and(|m| m != "None") - { - it.effects = Effects { feather: f.num("Width").unwrap_or(9.0), ..it.effects.clone() }; - } + self.effects_in(t, &mut it.effects); } // Text wrap. if let Some(w) = e.find("TextWrapPreference") { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 23ec99bdf..ba3d4c714 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1454,3 +1454,34 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() { check(&d); check(&import_idml(&export_idml(&d)).unwrap()); } + +#[test] +fn object_style_effects_round_trip() { + use designcraft_doc::{DropShadow, Effects, ObjectStyle}; + let mut d = Document::new(&NewDocument::default()); + let fx = Effects { + drop_shadow: DropShadow { on: true, distance: 5.0, size: 12.0, opacity: 0.28, ..Default::default() }, + feather: 3.0, + ..Default::default() + }; + d.styles_mut().object.push(ObjectStyle { name: "Photo".into(), effects: Some(fx.clone()), ..Default::default() }); + d.styles_mut().object.push(ObjectStyle { name: "Plain".into(), fill: Some(Fill::swatch("Black")), ..Default::default() }); + let lid = d.default_layer(); + let id = designcraft_doc::ItemId(d.alloc()); + let mut it = designcraft_doc::Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0))); + it.object_style = "Photo".into(); + it.effects = fx.clone(); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + + let back = import_idml(&export_idml(&d)).unwrap(); + let style = |n: &str| back.styles.object.iter().find(|s| s.name == n).unwrap(); + let got = style("Photo").effects.clone().unwrap(); + let ds = &got.drop_shadow; + assert!(ds.on && ds.size == 12.0 && (ds.opacity - 0.28).abs() < 1e-6, "{ds:?}"); + assert!((ds.distance - 5.0).abs() < 1e-6 && (ds.angle - 135.0).abs() < 1e-6, "{ds:?}"); + assert_eq!(got.feather, 3.0); + assert!(style("Plain").effects.is_none()); + // The frame's effects are the style's, so it follows later edits of the style. + let frame = back.spreads[0].items.iter().find(|i| i.object_style == "Photo").unwrap(); + assert_eq!(frame.effects, got); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 78e968dcf..4f1421671 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (object styles carry effects) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -401,6 +401,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. - Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles). +- Object styles carry effects: `style.object.create` keeps the selection's effects, `style.object.apply` sets them, and `style.object.edit {name, dropShadow?, feather?, …, effects?: null}` changes them on the style and on every item whose effects still match it (items with their own effects keep them; `style.object.clearOverrides` returns them to the style). IDML ObjectStyles carry the drop shadow and feather. There is no Object Style Options dialog yet. - Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point. - Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it. - Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`). From 462831bf810df99433120cbf44ed7b59a0e2af2d Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 00:58:47 +0000 Subject: [PATCH 2/2] fix(styles): object style effects keep local effects, validate values, follow BasedOn in IDML (#653) --- ROADMAP.md | 2 +- crates/doc/src/edit.rs | 10 +- crates/doc/src/item.rs | 56 ++++++++ crates/doc/src/styles.rs | 24 +++- crates/engine/src/cmd/file.rs | 6 + crates/engine/src/cmd/object.rs | 237 +++++++++++++++----------------- crates/engine/src/cmd/paths.rs | 1 + crates/engine/src/cmd/style.rs | 61 ++++---- crates/engine/src/tests.rs | 59 +++++++- crates/idml/src/export.rs | 7 +- crates/idml/src/import.rs | 52 +++++-- crates/idml/src/tests.rs | 55 +++++++- docs/target-app-parity.md | 2 +- 13 files changed, 382 insertions(+), 190 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e1a6d9f07..a1209f723 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-13** · Object styles carry effects (drop shadow, feather, inner and outer shadows and glows, bevel, satin, gradient and directional feather). New styles from a selection keep its effects, `style.object.edit` changes a style's effects and every item that follows them, `style.object.clearOverrides` returns items to their style, and IDML ObjectStyles write and read the drop shadow and feather (#653). No readiness percentage change. +- **2026-10-13** · Object styles carry effects (drop shadow, feather, inner and outer shadows and glows, bevel, satin, gradient and directional feather). New styles from a selection keep its effects, `style.object.edit` changes a style's effects and every item that follows them, `style.object.clearOverrides` returns items to their style, and new frames take their default style's effects. IDML carries only the drop shadow and feather, for ObjectStyles (with their effects category and BasedOn chain) as for items (#653). No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 90bf45012..e03db9d6c 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -187,13 +187,9 @@ impl Document { self.stories.insert(sid, Arc::new(story)); let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect)); item.object_style = self.styles.default_text_frame.clone(); - if let Some(os) = self.styles.object_style(&self.styles.default_text_frame).cloned() { - if let Some(f) = os.fill { - item.fill = f; - } - if let Some(s) = os.stroke { - item.stroke = s; - } + // Applied before the content exists, so the frame keeps default text frame options. + if let Some(os) = self.styles.object_style(&self.styles.default_text_frame) { + os.apply_to(&mut item); } item.content = Content::Text(TextFrame { story: sid, options: TextFrameOptions::default() }); self.insert_item(r, item, None)?; diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 5cfe05fe2..9b38f3d01 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -911,6 +911,62 @@ impl GradientFeather { } impl Effects { + /// These effects with opacities in 0–1, percentages in 0–100 and sizes, widths and distances + /// not below 0, or an error naming the first value that isn't a finite number. + pub fn sanitized(mut self) -> Result { + fn finite(name: &str, v: f64) -> Result { + if v.is_finite() { Ok(v) } else { Err(format!("{name} must be a finite number")) } + } + let len = |name: &str, v: f64| finite(name, v).map(|v| v.max(0.0)); + let pct = |name: &str, v: f64| finite(name, v).map(|v| v.clamp(0.0, 100.0)); + let opacity = |name: &str, v: f32| finite(name, f64::from(v)).map(|v| v.clamp(0.0, 1.0) as f32); + let ds = &mut self.drop_shadow; + ds.opacity = opacity("dropShadow.opacity", ds.opacity)?; + ds.angle = finite("dropShadow.angle", ds.angle)?; + ds.distance = len("dropShadow.distance", ds.distance)?; + ds.size = len("dropShadow.size", ds.size)?; + ds.spread = pct("dropShadow.spread", ds.spread)?; + self.feather = len("feather", self.feather)?; + let e = &mut self.inner_shadow; + e.opacity = opacity("innerShadow.opacity", e.opacity)?; + e.angle = finite("innerShadow.angle", e.angle)?; + e.distance = len("innerShadow.distance", e.distance)?; + e.size = len("innerShadow.size", e.size)?; + e.choke = pct("innerShadow.choke", e.choke)?; + let e = &mut self.outer_glow; + e.opacity = opacity("outerGlow.opacity", e.opacity)?; + e.size = len("outerGlow.size", e.size)?; + e.spread = pct("outerGlow.spread", e.spread)?; + let e = &mut self.gradient_feather; + e.angle = finite("gradientFeather.angle", e.angle)?; + e.start = opacity("gradientFeather.start", e.start)?; + e.end = opacity("gradientFeather.end", e.end)?; + if let Some(v) = &mut e.vector { + for c in v.iter_mut() { + *c = finite("gradientFeather.vector", *c)?; + } + } + let e = &mut self.inner_glow; + e.opacity = opacity("innerGlow.opacity", e.opacity)?; + e.size = len("innerGlow.size", e.size)?; + e.choke = pct("innerGlow.choke", e.choke)?; + let e = &mut self.bevel; + e.size = len("bevel.size", e.size)?; + e.depth = len("bevel.depth", e.depth)?; + e.angle = finite("bevel.angle", e.angle)?; + e.highlight_opacity = opacity("bevel.highlightOpacity", e.highlight_opacity)?; + e.shadow_opacity = opacity("bevel.shadowOpacity", e.shadow_opacity)?; + let e = &mut self.satin; + e.opacity = opacity("satin.opacity", e.opacity)?; + e.angle = finite("satin.angle", e.angle)?; + e.distance = len("satin.distance", e.distance)?; + e.size = len("satin.size", e.size)?; + for w in self.directional_feather.widths.iter_mut() { + *w = len("directionalFeather.widths", *w)?; + } + Ok(self) + } + /// Any effect drawn with soft (raster) filters? pub fn any(&self) -> bool { self.drop_shadow.on diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index de76c7f11..88c09b4dc 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::attrs::{CharAttrs, CharProps, ParaAttrs, ParaProps}; -use crate::item::{Effects, Fill, Stroke, TextFrameOptions}; +use crate::item::{Effects, Fill, Item, Stroke, TextFrameOptions}; use crate::story::{BASIC_PARAGRAPH, CharFormat, NO_CHAR_STYLE, ParaFormat}; pub const NO_PARA_STYLE: &str = "[No Paragraph Style]"; @@ -61,6 +61,24 @@ pub struct ObjectStyle { pub effects: Option, } +impl ObjectStyle { + /// Gives `it` the fill, stroke, effects and text frame options this style sets. + pub fn apply_to(&self, it: &mut Item) { + if let Some(f) = &self.fill { + it.fill = f.clone(); + } + if let Some(st) = &self.stroke { + it.stroke = st.clone(); + } + if let Some(e) = &self.effects { + it.effects = e.clone(); + } + if let (Some(tf), Some(o)) = (it.text_frame_mut(), &self.text_frame) { + tf.options = o.clone(); + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Styles { @@ -695,6 +713,10 @@ impl Styles { pub fn object_style(&self, name: &str) -> Option<&ObjectStyle> { self.object.iter().find(|s| s.name == name) } + /// The effects object style `name` sets, if any (for new items that get a default style). + pub fn object_effects(&self, name: &str) -> Option<&Effects> { + self.object_style(name)?.effects.as_ref() + } /// The paragraph style chain from root to `name` (cycle-safe). fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 692c363e8..a1e69e4e7 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -376,6 +376,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result { let (bx, by, bw, bh) = crop_box.unwrap_or((0.0, 0.0, nw, nh)); let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(x, y, x + bw * k, y + bh * k))); it.object_style = d.styles.default_graphic_frame.clone(); + if let Some(e) = d.styles.object_effects(&it.object_style) { + it.effects = e.clone(); + } it.content = Content::Graphic(Graphic { asset: aid, size: (nw, nh), @@ -491,6 +494,9 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { let id = ItemId(d.alloc()); let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(frame_r)); it.object_style = d.styles.default_graphic_frame.clone(); + if let Some(e) = d.styles.object_effects(&it.object_style) { + it.effects = e.clone(); + } it.content = Content::Graphic(Graphic { asset: aid, size: (nw, nh), diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be117..03194519c 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -6,7 +6,8 @@ use std::sync::Arc; use designcraft_color::BlendMode; use designcraft_doc::{ - Content, Document, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions, TextSel, + Content, Document, Effects, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions, + TextSel, }; use designcraft_geom::{Affine, Point, Rect, Vec2, shapes}; use serde_json::{Value, json}; @@ -276,24 +277,19 @@ pub fn specs() -> Vec { has_selection, |s, p| { let p2 = p.clone(); - set_flag( - s, - p, - move |i| { - let ds = &mut i.effects.drop_shadow; - ds.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!ds.on); - ds.distance = f64_or(&p2, "distance", ds.distance); - ds.angle = f64_or(&p2, "angle", ds.angle); - ds.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(ds.global_light); - ds.opacity = f64_or(&p2, "opacity", ds.opacity as f64) as f32; - ds.size = f64_or(&p2, "size", ds.size); - ds.spread = f64_or(&p2, "spread", ds.spread); - if let Some(c) = str_param(&p2, "color") { - ds.color = c.into(); - } - }, - false, - ) + set_effects(s, p, "object.dropShadow", move |fx| { + let ds = &mut fx.drop_shadow; + ds.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!ds.on); + ds.distance = f64_or(&p2, "distance", ds.distance); + ds.angle = f64_or(&p2, "angle", ds.angle); + ds.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(ds.global_light); + ds.opacity = f64_or(&p2, "opacity", ds.opacity as f64) as f32; + ds.size = f64_or(&p2, "size", ds.size); + ds.spread = f64_or(&p2, "spread", ds.spread); + if let Some(c) = str_param(&p2, "color") { + ds.color = c.into(); + } + }) } ), cmd!( @@ -305,24 +301,19 @@ pub fn specs() -> Vec { has_selection, |s, p| { let p2 = p.clone(); - set_flag( - s, - p, - move |i| { - let e = &mut i.effects.inner_shadow; - e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); - e.distance = f64_or(&p2, "distance", e.distance); - e.angle = f64_or(&p2, "angle", e.angle); - e.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(e.global_light); - e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32; - e.size = f64_or(&p2, "size", e.size); - e.choke = f64_or(&p2, "choke", e.choke); - if let Some(c) = str_param(&p2, "color") { - e.color = c.into(); - } - }, - false, - ) + set_effects(s, p, "object.innerShadow", move |fx| { + let e = &mut fx.inner_shadow; + e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); + e.distance = f64_or(&p2, "distance", e.distance); + e.angle = f64_or(&p2, "angle", e.angle); + e.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(e.global_light); + e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32; + e.size = f64_or(&p2, "size", e.size); + e.choke = f64_or(&p2, "choke", e.choke); + if let Some(c) = str_param(&p2, "color") { + e.color = c.into(); + } + }) } ), cmd!( @@ -334,21 +325,16 @@ pub fn specs() -> Vec { has_selection, |s, p| { let p2 = p.clone(); - set_flag( - s, - p, - move |i| { - let e = &mut i.effects.outer_glow; - e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); - e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32; - e.size = f64_or(&p2, "size", e.size); - e.spread = f64_or(&p2, "spread", e.spread); - if let Some(c) = str_param(&p2, "color") { - e.color = c.into(); - } - }, - false, - ) + set_effects(s, p, "object.outerGlow", move |fx| { + let e = &mut fx.outer_glow; + e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); + e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32; + e.size = f64_or(&p2, "size", e.size); + e.spread = f64_or(&p2, "spread", e.spread); + if let Some(c) = str_param(&p2, "color") { + e.color = c.into(); + } + }) } ), cmd!( @@ -394,22 +380,17 @@ pub fn specs() -> Vec { has_selection, |s, p| { let p2 = p.clone(); - set_flag( - s, - p, - move |i| { - let e = &mut i.effects.inner_glow; - e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); - e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32; - e.size = f64_or(&p2, "size", e.size); - e.choke = f64_or(&p2, "choke", e.choke); - e.center = p2.get("center").and_then(Value::as_bool).unwrap_or(e.center); - if let Some(c) = str_param(&p2, "color") { - e.color = c.into(); - } - }, - false, - ) + set_effects(s, p, "object.innerGlow", move |fx| { + let e = &mut fx.inner_glow; + e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); + e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32; + e.size = f64_or(&p2, "size", e.size); + e.choke = f64_or(&p2, "choke", e.choke); + e.center = p2.get("center").and_then(Value::as_bool).unwrap_or(e.center); + if let Some(c) = str_param(&p2, "color") { + e.color = c.into(); + } + }) } ), cmd!( @@ -421,27 +402,22 @@ pub fn specs() -> Vec { has_selection, |s, p| { let p2 = p.clone(); - set_flag( - s, - p, - move |i| { - let e = &mut i.effects.bevel; - e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); - e.size = f64_or(&p2, "size", e.size); - e.depth = f64_or(&p2, "depth", e.depth); - e.angle = f64_or(&p2, "angle", e.angle); - e.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(e.global_light); - e.highlight_opacity = f64_or(&p2, "highlightOpacity", e.highlight_opacity as f64) as f32; - e.shadow_opacity = f64_or(&p2, "shadowOpacity", e.shadow_opacity as f64) as f32; - if let Some(c) = str_param(&p2, "highlight") { - e.highlight = c.into(); - } - if let Some(c) = str_param(&p2, "shadow") { - e.shadow = c.into(); - } - }, - false, - ) + set_effects(s, p, "object.bevel", move |fx| { + let e = &mut fx.bevel; + e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); + e.size = f64_or(&p2, "size", e.size); + e.depth = f64_or(&p2, "depth", e.depth); + e.angle = f64_or(&p2, "angle", e.angle); + e.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(e.global_light); + e.highlight_opacity = f64_or(&p2, "highlightOpacity", e.highlight_opacity as f64) as f32; + e.shadow_opacity = f64_or(&p2, "shadowOpacity", e.shadow_opacity as f64) as f32; + if let Some(c) = str_param(&p2, "highlight") { + e.highlight = c.into(); + } + if let Some(c) = str_param(&p2, "shadow") { + e.shadow = c.into(); + } + }) } ), cmd!( @@ -453,23 +429,18 @@ pub fn specs() -> Vec { has_selection, |s, p| { let p2 = p.clone(); - set_flag( - s, - p, - move |i| { - let e = &mut i.effects.satin; - e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); - e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32; - e.angle = f64_or(&p2, "angle", e.angle); - e.distance = f64_or(&p2, "distance", e.distance); - e.size = f64_or(&p2, "size", e.size); - e.invert = p2.get("invert").and_then(Value::as_bool).unwrap_or(e.invert); - if let Some(c) = str_param(&p2, "color") { - e.color = c.into(); - } - }, - false, - ) + set_effects(s, p, "object.satin", move |fx| { + let e = &mut fx.satin; + e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); + e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32; + e.angle = f64_or(&p2, "angle", e.angle); + e.distance = f64_or(&p2, "distance", e.distance); + e.size = f64_or(&p2, "size", e.size); + e.invert = p2.get("invert").and_then(Value::as_bool).unwrap_or(e.invert); + if let Some(c) = str_param(&p2, "color") { + e.color = c.into(); + } + }) } ), cmd!( @@ -481,32 +452,27 @@ pub fn specs() -> Vec { has_selection, |s, p| { let p2 = p.clone(); - set_flag( - s, - p, - move |i| { - let e = &mut i.effects.directional_feather; - e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); - match p2.get("widths") { - Some(Value::Number(n)) => e.widths = [n.as_f64().unwrap_or(0.0).max(0.0); 4], - Some(v @ Value::Array(_)) => { - if let Ok(w) = serde_json::from_value::<[f64; 4]>(v.clone()) { - e.widths = w.map(|x| x.max(0.0)); - } + set_effects(s, p, "object.directionalFeather", move |fx| { + let e = &mut fx.directional_feather; + e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); + match p2.get("widths") { + Some(Value::Number(n)) => e.widths = [n.as_f64().unwrap_or(0.0).max(0.0); 4], + Some(v @ Value::Array(_)) => { + if let Ok(w) = serde_json::from_value::<[f64; 4]>(v.clone()) { + e.widths = w.map(|x| x.max(0.0)); } - _ => {} } - if e.on && e.widths.iter().all(|w| *w <= 0.0) { - e.widths = [9.0; 4]; - } - }, - false, - ) + _ => {} + } + if e.on && e.widths.iter().all(|w| *w <= 0.0) { + e.widths = [9.0; 4]; + } + }) } ), cmd!("object.feather", "Basic Feather", ["Object", "Effects"], None, "{width (0 = off), ids?}", has_selection, |s, p| { let w = f64_or(p, "width", 9.0).max(0.0); - set_flag(s, p, move |i| i.effects.feather = w, false) + set_effects(s, p, "object.feather", move |fx| fx.feather = w) }), cmd!("object.content", "Content", ["Object", "Content"], None, "{type: graphic|text|unassigned, ids?}", has_selection, content_type), cmd!( @@ -863,6 +829,9 @@ fn frame_create(s: &mut Session, p: &Value) -> Result { let mut it = Item::new(id, lid, sh, path); if content == "graphic" { it.object_style = d.styles.default_graphic_frame.clone(); + if let Some(e) = d.styles.object_effects(&it.object_style) { + it.effects = e.clone(); + } } else { // Shapes drawn with Rectangle/Ellipse/Polygon get the default 1 pt black stroke. it.stroke = Stroke::default(); @@ -1404,6 +1373,22 @@ fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) - }) } +/// Edits the targets' effects. A value that isn't a finite number is refused; the others are +/// clamped to their ranges. +fn set_effects(s: &mut Session, p: &Value, cmd: &'static str, f: impl Fn(&mut Effects)) -> Result { + let ids = targets(s, p)?; + s.edit(|d, _| { + for id in &ids { + if let Some(it) = d.item_mut(*id) { + let mut fx = it.effects.clone(); + f(&mut fx); + it.effects = fx.sanitized().map_err(|m| bad(cmd, m))?; + } + } + Ok(json!({"changed": ids.len()})) + }) +} + fn all_flag(s: &mut Session, f: impl Fn(&mut Item)) -> Result { s.edit(|d, _| { for i in 0..d.spreads.len() { diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..eb0a6b76a 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -194,6 +194,7 @@ fn gradient_feather(s: &mut Session, p: &Value) -> Result { gf.vector = Some([a.x, a.y, b.x, b.y]); } } + it.effects = it.effects.clone().sanitized().map_err(|m| bad("object.gradientFeather", m))?; } Ok(json!({"changed": ids.len()})) }) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 44eec0ba5..c5d592e4b 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -151,7 +151,7 @@ pub fn specs() -> Vec { "Object Style Options…", [], None, - "{name, effects?: {…} | null (the style no longer sets effects), dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} — effect objects merge field by field into the style's effects (fields as in `document.inspect` item effects); items using the style whose effects match the style's take the new effects, items with their own effects keep them → {name, updated}", + "{name, effects?: {…} | null (the style no longer sets effects), dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} — effect objects merge field by field into the style's effects (fields as in `document.inspect` item effects); values that aren't finite numbers are refused, the others clamped (opacities 0–1, sizes and distances ≥ 0); items using the style whose effects match the style's (no effects, when the style set none) take the new effects, items with their own effects keep them → {name, updated}", has_doc, edit_object ), @@ -160,7 +160,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", + "{ids?} — each item takes its object style's fill, stroke, effects and text frame options again → {changed}", super::has_selection, clear_object_overrides ), @@ -780,7 +780,7 @@ fn apply_object(s: &mut Session, p: &Value) -> Result { for id in &ids { let Some(it) = d.item_mut(*id) else { continue }; it.object_style = name.clone(); - set_object_attrs(it, &os); + os.apply_to(it); if let Some(tf) = it.text_frame() { stories.push(tf.story); } @@ -797,32 +797,29 @@ fn apply_object(s: &mut Session, p: &Value) -> Result { }) } -/// Gives `it` the attributes the object style `os` sets. -fn set_object_attrs(it: &mut Item, os: &ObjectStyle) { - if let Some(f) = &os.fill { - it.fill = f.clone(); - } - if let Some(st) = &os.stroke { - it.stroke = st.clone(); - } - if let Some(e) = &os.effects { - it.effects = e.clone(); - } - if let (Some(tf), Some(o)) = (it.text_frame_mut(), &os.text_frame) { - tf.options = o.clone(); - } -} - 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. + 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() { + return Ok(json!({"changed": 0})); + } s.edit(|d, _| { - for id in &ids { - let Some(os) = d.item(*id).and_then(|it| d.styles.object_style(&it.object_style)).cloned() else { continue }; - if let Some(it) = d.item_mut(*id) { - set_object_attrs(it, &os); + for next in &changed { + if let Some(it) = d.item_mut(next.id) { + *it = next.clone(); } } - Ok(Value::Null) + Ok(json!({"changed": changed.len()})) }) } @@ -850,7 +847,8 @@ fn effects_param(cmd: &str, p: &Value, base: &Effects) -> Result } let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?; merge_json(&mut v, Value::Object(patch), 0); - serde_json::from_value(v).map(Some).map_err(|e| bad(cmd, format!("effects: {e}"))) + let e: Effects = serde_json::from_value(v).map_err(|e| bad(cmd, format!("effects: {e}")))?; + e.sanitized().map(Some).map_err(|m| bad(cmd, m)) } /// Merges `top` into `base`: objects key by key, anything else replaced. @@ -916,17 +914,20 @@ fn edit_object(s: &mut Session, p: &Value) -> Result { None => return Ok(json!({"name": name, "updated": 0})), } }; + if effects == old.effects { + return Ok(json!({"name": name, "updated": 0})); + } s.edit(|d, _| { let Some(os) = d.styles_mut().object.iter_mut().find(|o| o.name == name) else { return Err(bad(CMD, format!("no object style `{name}`"))); }; os.effects = effects.clone(); - // An item follows the style while its effects are the style's; when the style didn't set - // effects before, every item using it takes them. + // An item follows the style while its effects are the style's. When the style didn't set + // effects before, only items without effects of their own follow (the default styles are + // on most frames). + let follows = old.effects.clone().unwrap_or_default(); let updated = match &effects { - Some(new) => { - d.update_items(|it| it.object_style == name && old.effects.as_ref().is_none_or(|e| it.effects == *e), |it| it.effects = new.clone()) - } + Some(new) => d.update_items(|it| it.object_style == name && it.effects == follows, |it| it.effects = new.clone()), None => 0, }; Ok(json!({"name": name, "updated": updated})) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a3b1e9897..2d8e0a101 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -481,16 +481,65 @@ fn object_style_effects_follow_the_style_until_overridden() { assert_eq!((shadow(&s, a).size, shadow(&s, b).size), (12.0, 12.0)); assert_eq!(s.doc().unwrap().doc.styles.object_style("Photo").unwrap().effects.as_ref().unwrap().drop_shadow.size, 12.0); - // A style that didn't set effects gives them to every item using it; `null` stops setting them. + // A style that didn't set effects gives them to the items using it that have no effects of + // their own, and to new frames that get it by default; `null` stops setting them. let e = frame(&mut s, 36.0); - s.execute("style.object.apply", &json!({"name": BASIC_GRAPHICS_FRAME, "ids": [e]})).unwrap(); + let f = frame(&mut s, 200.0); + s.execute("style.object.apply", &json!({"name": BASIC_GRAPHICS_FRAME, "ids": [e, f]})).unwrap(); s.execute("object.dropShadow", &json!({"on": true, "ids": [e]})).unwrap(); - s.execute("style.object.edit", &json!({"name": BASIC_GRAPHICS_FRAME, "feather": 4})).unwrap(); - assert_eq!(item(&s, e).effects, designcraft_doc::Effects { feather: 4.0, ..Default::default() }); + let r = s.execute("style.object.edit", &json!({"name": BASIC_GRAPHICS_FRAME, "feather": 4})).unwrap(); + assert_eq!(r["updated"], 1); + assert!(item(&s, e).effects.drop_shadow.on && item(&s, e).effects.feather == 0.0); + assert_eq!(item(&s, f).effects, designcraft_doc::Effects { feather: 4.0, ..Default::default() }); + let g = s.execute("frame.create", &json!({"rect": [36, 300, 136, 400], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + assert_eq!((item(&s, g).object_style.as_str(), item(&s, g).effects.feather), (BASIC_GRAPHICS_FRAME, 4.0)); s.execute("style.object.edit", &json!({"name": BASIC_GRAPHICS_FRAME, "effects": null})).unwrap(); assert!(s.doc().unwrap().doc.styles.object_style(BASIC_GRAPHICS_FRAME).unwrap().effects.is_none()); - assert_eq!(item(&s, e).effects.feather, 4.0); + assert_eq!(item(&s, f).effects.feather, 4.0); + + // Nothing to change: no edit, no undo step. + let rev = s.doc().unwrap().revision; + s.execute("style.object.edit", &json!({"name": BASIC_GRAPHICS_FRAME, "effects": null})).unwrap(); + s.execute("style.object.clearOverrides", &json!({"ids": [a, b]})).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev); +} + +#[test] +fn object_style_effects_reach_grouped_and_anchored_items() { + use designcraft_doc::ItemId; + let mut s = session(); + let frame = |s: &mut Session, x: f64| { + s.execute("frame.create", &json!({"rect": [x, 36, x + 50.0, 86], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap() + }; + let (a, b, c) = (frame(&mut s, 36.0), frame(&mut s, 100.0), frame(&mut s, 200.0)); + s.execute("style.object.create", &json!({"name": "Photo", "fromSelection": false, "dropShadow": {"on": true}})).unwrap(); + s.execute("style.object.apply", &json!({"name": "Photo", "ids": [a, b, c]})).unwrap(); + s.execute("object.group", &json!({"ids": [a, b]})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 200, 400, 400], "content": "text", "text": "Before after."})).unwrap(); + let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("anchored.insert", &json!({"ids": [c], "story": sid.0, "pos": 7})).unwrap(); + + let r = s.execute("style.object.edit", &json!({"name": "Photo", "dropShadow": {"size": 25}})).unwrap(); + assert_eq!(r["updated"], 3); + let d = &s.doc().unwrap().doc; + assert_eq!(d.item(ItemId(a)).unwrap().effects.drop_shadow.size, 25.0); + assert_eq!(d.story(sid).unwrap().objects[0].item.effects.drop_shadow.size, 25.0); + + // Values that aren't finite numbers are refused (an f32 opacity of 1e39 is infinite); the + // others are clamped, and the document still saves and opens. + assert!(s.execute("style.object.edit", &json!({"name": "Photo", "dropShadow": {"opacity": 1e39}})).is_err()); + assert!(s.execute("object.dropShadow", &json!({"opacity": 1e39, "ids": [b]})).is_err()); assert!(s.execute("style.object.edit", &json!({"name": "Photo", "dropShadow": {"size": "big"}})).is_err()); + s.execute("style.object.edit", &json!({"name": "Photo", "dropShadow": {"opacity": 3, "size": -5}})).unwrap(); + s.execute("object.innerShadow", &json!({"on": true, "distance": -2, "ids": [b]})).unwrap(); + let d = &s.doc().unwrap().doc; + let ds = &d.styles.object_style("Photo").unwrap().effects.as_ref().unwrap().drop_shadow; + assert_eq!((ds.opacity, ds.size), (1.0, 0.0)); + assert_eq!(d.item(ItemId(b)).unwrap().effects.inner_shadow.distance, 0.0); + let bytes = s.execute("file.serialize", &json!({})).unwrap(); + let mut back = Session::new(); + back.execute("file.openBytes", &json!({"name": "copy", "base64": bytes["base64"]})).unwrap(); + assert_eq!(back.doc().unwrap().doc.styles.object_style("Photo").unwrap().effects.as_ref().unwrap().drop_shadow.opacity, 1.0); } /// Unicode text per page, through hayro's interpreter (ToUnicode / ActualText). diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 5a44dfe19..07f14a852 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1704,11 +1704,14 @@ impl<'a> Ex<'a> { } let mut el = with_props(el, props); // Transparency. + // An item whose style sets effects writes the effects it has turned off too, so that on + // import it doesn't take the style's. + let styled = self.d.styles.object_effects(os).is_some(); let ds = &it.effects.drop_shadow; - if it.opacity < 1.0 || it.blend != designcraft_color::BlendMode::Normal || ds.on || it.effects.feather > 0.0 { + if it.opacity < 1.0 || it.blend != designcraft_color::BlendMode::Normal || ds.on || it.effects.feather > 0.0 || styled { let mut t = El::new("TransparencySetting"); t.push(El::new("BlendingSetting").attr("BlendMode", names::blend_out(it.blend)).attr("Opacity", pct(it.opacity as f64))); - for c in self.effect_els(&it.effects, false) { + for c in self.effect_els(&it.effects, styled) { t.push(c); } el.push(t); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 93fbe375a..27966ca29 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2035,24 +2035,48 @@ impl<'r> Importer<'r> { } } - /// An object style's effects: set when its effects category turns on an effect this model - /// reads, or (without category settings) when it gives a drop shadow or feather that is on. + /// An object style element and the styles it is based on, nearest first (cycle-safe, bounded). + fn object_style_chain(&self, e: &El) -> Vec { + let mut chain = vec![e.clone()]; + let mut seen: HashSet = e.get("Self").map(str::to_string).into_iter().collect(); + let mut next = e.prop("BasedOn"); + while let Some(b) = next { + let id = if b.starts_with("ObjectStyle/") { b } else { format!("ObjectStyle/{}", names::escape_id(&b)) }; + if chain.len() >= 16 || !seen.insert(id.clone()) { + break; + } + let Some(s) = self.object_els.get(&id) else { break }; + chain.push(s.clone()); + next = s.prop("BasedOn"); + } + chain + } + + /// An object style's drop shadow and feather. Each is read when the effects category turns it + /// on (without category settings: when its setting is on), from the nearest style in the + /// BasedOn chain that has the setting. `None` when the style sets neither. fn style_effects(&mut self, e: &El) -> Option { - let t = e.find("TransparencySetting"); - let set = match e.find("ObjectStyleObjectEffectsCategorySettings") { - Some(c) => ["EnableDropShadow", "EnableFeather"].iter().any(|k| c.get(k) == Some("true")), - None => t.is_some_and(|t| { - t.find("DropShadowSetting").is_some_and(|d| d.get("Mode") == Some("Drop")) - || t.find("FeatherSetting").is_some_and(|f| f.get("Mode").is_some_and(|m| m != "None")) - }), - }; - if !set { + let chain = self.object_style_chain(e); + let category = chain.iter().find_map(|s| s.find("ObjectStyleObjectEffectsCategorySettings")).cloned(); + let setting = |name: &str| chain.iter().find_map(|s| s.find("TransparencySetting")?.find(name)).cloned(); + let mut t = El::new("TransparencySetting"); + let mut any = false; + for (name, flag) in [("DropShadowSetting", "EnableDropShadow"), ("FeatherSetting", "EnableFeather")] { + let el = setting(name); + let on = match &category { + Some(c) => c.get(flag) == Some("true"), + None => el.as_ref().and_then(|x| x.get("Mode")).is_some_and(|m| m != "None"), + }; + any |= on; + if let (true, Some(el)) = (on, el) { + t.push(el); + } + } + if !any { return None; } let mut fx = Effects::default(); - if let Some(t) = t { - self.effects_in(t, &mut fx); - } + self.effects_in(&t, &mut fx); Some(fx) } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index ba3d4c714..92d83c1df 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1472,8 +1472,23 @@ fn object_style_effects_round_trip() { it.object_style = "Photo".into(); it.effects = fx.clone(); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + // A frame that turned the style's effects off writes them off, so it doesn't take the style's. + let id = designcraft_doc::ItemId(d.alloc()); + let mut off = designcraft_doc::Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(200.0, 40.0, 300.0, 140.0))); + off.object_style = "Photo".into(); + d.insert_item(SpreadRef::Doc(0), off, None).unwrap(); - let back = import_idml(&export_idml(&d)).unwrap(); + let bytes = export_idml(&d); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap(); + let mut spreads = String::new(); + for i in 0..zip.len() { + let mut f = zip.by_index(i).unwrap(); + if f.name().starts_with("Spreads/") { + std::io::Read::read_to_string(&mut f, &mut spreads).unwrap(); + } + } + assert!(spreads.contains(r#" = back.spreads[0].items.iter().filter(|i| i.object_style == "Photo").collect(); + assert_eq!(frames[0].effects, got); + assert_eq!(frames[1].effects, Effects::default()); +} + +#[test] +fn object_style_effects_import_by_category_and_based_on() { + let styles = r#" + + + + + + + + + ObjectStyle/Base + + + + + + + + ObjectStyle/Loop + + "#; + let d = inset_fixture("", styles); + let fx = |n: &str| d.styles.object.iter().find(|s| s.name == n).unwrap().effects.clone(); + let base = fx("Base").unwrap(); + assert!(base.drop_shadow.on && base.drop_shadow.size == 9.0 && (base.drop_shadow.distance - 5.0).abs() < 1e-9); + assert_eq!(base.feather, 0.0, "EnableFeather=false"); + let child = fx("Child").unwrap(); + assert_eq!((child.drop_shadow.size, child.feather), (9.0, 6.0), "settings from the BasedOn style"); + assert!(fx("Off").is_none()); + assert!(!fx("Loop").unwrap().drop_shadow.on); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4f1421671..9f924101b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -401,7 +401,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. - Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles). -- Object styles carry effects: `style.object.create` keeps the selection's effects, `style.object.apply` sets them, and `style.object.edit {name, dropShadow?, feather?, …, effects?: null}` changes them on the style and on every item whose effects still match it (items with their own effects keep them; `style.object.clearOverrides` returns them to the style). IDML ObjectStyles carry the drop shadow and feather. There is no Object Style Options dialog yet. +- Object styles carry effects: `style.object.create` keeps the selection's effects, `style.object.apply` sets them, and `style.object.edit {name, dropShadow?, feather?, …, effects?: null}` changes them on the style and on every item whose effects still match it (items with their own effects keep them; `style.object.clearOverrides` returns them to the style). New frames take their default style's effects. Effect values that aren't finite numbers are refused and the others clamped, in these commands and in the `object.*` effect commands. IDML carries only the drop shadow and feather, for ObjectStyles (read per effects category flag, settings from the BasedOn chain) as for items; the other effects stay in `.designcraft` files. There is no Object Style Options dialog yet. - Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point. - Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it. - Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`).