From 0e57712bba7d405002804049c97980fc37081bf5 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:24:56 +0200 Subject: [PATCH 01/36] Object styles: corner options, and editing a style's fill, stroke and corners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An object style now carries Corner Options (`corners`, in a rectangle's path order). New styles take them from the selection, applying the style sets them, and IDML reads and writes them as InDesign's Stroke & Corner Options (EnableStrokeAndCornerOptions, TopLeftCornerOption…). style.object.edit changes more than effects: `fill` (a swatch or {swatch, tint}), `stroke` (fields merged over the style's), `corners` ({shape, size} or four corners), null to stop setting one, and `rename` (every use follows; built-in names and names in use are refused). Items using the style follow each changed attribute while they have the style's value; overridden ones keep theirs. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/styles.rs | 9 +- crates/engine/src/cmd/style.rs | 182 +++++++++++++++++++++++++++++---- crates/engine/src/tests.rs | 49 +++++++++ crates/idml/src/export.rs | 7 ++ crates/idml/src/import.rs | 6 +- crates/idml/src/tests.rs | 5 + 7 files changed, 236 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..b1199ed8f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Object styles carry Corner Options (`corners`), taken from the selection by `style.object.create` and written to IDML (`EnableStrokeAndCornerOptions`, `TopLeftCornerOption`…). `style.object.edit` also changes a style's fill, stroke and corners and renames it (`rename`); the items using the style that haven't overridden an attribute follow each change, as in InDesign. Found in the Learn Skills Daily course, chapter 15. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 5c747fdbf..380f0fd3a 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -1,5 +1,6 @@ //! Paragraph, character and object styles with based-on inheritance. +use designcraft_geom::corners::CornerOptions; use serde::{Deserialize, Serialize}; use crate::attrs::{CharAttrs, CharProps, ParaAttrs, ParaProps}; @@ -59,11 +60,17 @@ 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. + #[serde(skip_serializing_if = "Option::is_none")] + pub corners: Option, } impl ObjectStyle { - /// Gives `it` the fill, stroke, effects and text frame options this style sets. + /// 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; + } 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 d1bd06ad0..e0eee77b9 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -359,7 +359,7 @@ pub fn specs() -> Vec { "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)", + "{name, fromSelection?: true (fill, stroke, corners, effects and text frame options of the first selected item), fill?: swatch | {swatch?, tint?}, stroke?: {swatch?, weight?, tint?, type?, …}, corners?: {shape?, size?} | {corners: [{shape, size}] ×4}, 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 ), @@ -368,7 +368,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); 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}", + "{name, rename?: new name (every use follows), fill?: swatch | {swatch?, tint?} | null, stroke?: {swatch?, weight?, tint?, type?, …} | null, corners?: {shape?: none|rounded|inverseRounded|inset|bevel|fancy, size?} | {corners: [{shape, size}] ×4} | null (null: the style no longer sets it; items keep theirs), 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 (fill, stroke, corners) match the style's (an item's defaults, when the style set none) take the new ones, items with their own keep them → {name, updated}", has_doc, edit_object ), @@ -1507,11 +1507,19 @@ 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.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)); + const CREATE: &str = "style.object.create"; + if let Some(f) = fill_param(CREATE, p, os.fill.as_ref())? { + os.fill = f; + } + if let Some(st) = group_param(CREATE, p, "stroke", os.stroke.as_ref().unwrap_or(&designcraft_doc::Stroke::default()))? { + os.stroke = st; + } + if let Some(c) = corners_param(CREATE, p, os.corners.unwrap_or_default())? { + os.corners = c; } if let Some(ps) = str_param(p, "paragraphStyle") { os.paragraph_style = Some(ps.to_string()); @@ -1527,6 +1535,68 @@ fn create_object(s: &mut Session, p: &Value) -> Result { }) } +/// `p[key]`, an attribute group of a style, merged field by field over `base`: `None` when not +/// given, `Some(None)` for null (the style no longer sets it). +fn group_param(cmd: &str, p: &Value, key: &str, base: &T) -> Result>> { + match p.get(key) { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(v @ Value::Object(_)) => { + let mut cur = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?; + merge_json(&mut cur, v.clone(), 0); + serde_json::from_value(cur).map(|t| Some(Some(t))).map_err(|e| bad(cmd, format!("{key}: {e}"))) + } + Some(_) => Err(bad(cmd, format!("`{key}` must be an object or null"))), + } +} + +/// `fill`: a swatch name, `{swatch?, tint?}` over `base`, or null. +fn fill_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Fill>) -> Result>> { + if let Some(sw) = p.get("fill").and_then(Value::as_str) { + return Ok(Some(Some(designcraft_doc::Fill::swatch(sw)))); + } + let fill = group_param(cmd, p, "fill", base.unwrap_or(&designcraft_doc::Fill::default()))?; + if let Some(Some(f)) = &fill + && !(f.tint.is_finite() && (0.0..=1.0).contains(&f.tint)) + { + return Err(bad(cmd, "fill tint must be 0–1")); + } + Ok(fill) +} + +/// `corners`: `{shape?, size?}` for all four, `{corners: [{shape, size}] ×4}`, or null. +fn corners_param( + cmd: &str, + p: &Value, + base: designcraft_geom::corners::CornerOptions, +) -> Result>> { + let Some(v) = p.get("corners") else { return Ok(None) }; + if v.is_null() { + return Ok(Some(None)); + } + let o = v.as_object().ok_or_else(|| bad(cmd, "`corners` must be an object or null"))?; + let mut c = if o.contains_key("corners") { + serde_json::from_value::(v.clone()).map_err(|e| bad(cmd, format!("corners: {e}")))? + } else { + base + }; + if let Some(sh) = o.get("shape") { + let shape = serde_json::from_value(sh.clone()).map_err(|e| bad(cmd, format!("corner shape: {e}")))?; + c.corners.iter_mut().for_each(|k| k.shape = shape); + } + if let Some(sz) = o.get("size") { + let size = sz.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "corner size must be a number"))?; + c.corners.iter_mut().for_each(|k| k.size = size); + } + for k in &mut c.corners { + if !k.size.is_finite() { + return Err(bad(cmd, "corner sizes must be finite")); + } + k.size = k.size.clamp(0.0, 10_000.0); + } + Ok(Some(Some(c))) +} + 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(); @@ -1534,34 +1604,104 @@ fn edit_object(s: &mut Session, p: &Value) -> Result { 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) { + let mut new = old.clone(); + 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})), + new.effects = None; + } else if let Some(e) = effects_param(CMD, p, old.effects.as_ref().unwrap_or(&Effects::default()))? { + new.effects = Some(e); + } + if let Some(f) = fill_param(CMD, p, old.fill.as_ref())? { + new.fill = f; + } + if let Some(st) = group_param(CMD, p, "stroke", old.stroke.as_ref().unwrap_or(&designcraft_doc::Stroke::default()))? { + if let Some(st) = &st + && !(st.weight.is_finite() && st.weight >= 0.0 && st.tint.is_finite()) + { + return Err(bad(CMD, "stroke weight must be a number ≥ 0")); } - }; - if effects == old.effects { + new.stroke = st; + } + if let Some(c) = corners_param(CMD, p, old.corners.unwrap_or_default())? { + new.corners = c; + } + let rename = str_param(p, "rename").map(str::trim).filter(|r| *r != name).map(str::to_string); + if let Some(r) = &rename { + let builtin = name.starts_with('[') && name.ends_with(']'); + if builtin || r.is_empty() || (r.starts_with('[') && r.ends_with(']')) { + return Err(bad(CMD, format!("`{name}` can't be renamed to `{r}`"))); + } + if s.doc()?.doc.styles.object_style(r).is_some() { + return Err(bad(CMD, format!("an object style is already named `{r}`"))); + } + new.name = r.clone(); + } + if new == old { 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, 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 && it.effects == follows, |it| it.effects = new.clone()), - None => 0, + *os = new.clone(); + if let Some(r) = &rename { + let st = d.styles_mut(); + for o in &mut st.object { + if o.based_on.as_deref() == Some(name.as_str()) { + o.based_on = Some(r.clone()); + } + } + for n in [&mut st.default_text_frame, &mut st.default_graphic_frame] { + if *n == name { + *n = r.clone(); + } + } + d.update_items(|it| it.object_style == name, |it| it.object_style = r.clone()); + } + // An item follows each attribute the style sets while it has the style's value. When the + // style didn't set it before, items with the default follow (the default styles are on + // most frames). An attribute the style no longer sets stays as it is on the items. + let effects = (new.effects != old.effects).then_some(new.effects.clone()).flatten().map(|e| (old.effects.clone().unwrap_or_default(), e)); + let fill = (new.fill != old.fill).then_some(new.fill.clone()).flatten().map(|f| (old.fill.clone().unwrap_or_default(), f)); + let stroke = (new.stroke != old.stroke) + .then_some(new.stroke.clone()) + .flatten() + .map(|f| (old.stroke.clone().unwrap_or_else(designcraft_doc::Stroke::none), f)); + let corners = (new.corners != old.corners).then_some(new.corners).flatten().map(|c| (old.corners.unwrap_or_default(), c)); + let follows = |it: &Item| { + 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) }; - Ok(json!({"name": name, "updated": updated})) + let updated = d.update_items( + |it| it.object_style == new.name && follows(it), + |it| { + if let Some((o, n)) = &effects + && it.effects == *o + { + it.effects = n.clone(); + } + if let Some((o, n)) = &fill + && it.fill == *o + { + it.fill = n.clone(); + } + if let Some((o, n)) = &stroke + && it.stroke == *o + { + it.stroke = n.clone(); + } + if let Some((o, n)) = &corners + && it.corners == *o + { + it.corners = *n; + } + }, + ); + Ok(json!({"name": new.name, "updated": updated})) }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aacf469d..47bfcb523 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -685,6 +685,55 @@ fn object_style_effects_follow_the_style_until_overridden() { assert_eq!(s.doc().unwrap().revision, rev); } +/// The course's "Rounded Corner Image, White Stroke" style: made from a frame with rounded corners +/// and a paper stroke, applied to another frame, then edited (a thicker stroke, bigger corners): +/// the frames that follow the style change, one with its own stroke keeps it; a rename follows. +#[test] +fn object_styles_carry_corners_and_their_stroke_and_corners_can_be_edited() { + use designcraft_doc::ItemId; + use designcraft_geom::corners::CornerShape; + let mut s = session(); + let frame = |s: &mut Session, x: f64| { + s.execute("frame.create", &json!({"rect": [x, 36, x + 100.0, 136], "content": "graphic"})).unwrap()["id"].as_u64().unwrap() + }; + let item = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().clone(); + let a = frame(&mut s, 36.0); + s.execute("object.cornerOptions", &json!({"shape": "rounded", "size": 18, "ids": [a]})).unwrap(); + s.execute("object.stroke", &json!({"swatch": "[Paper]", "weight": 2.5, "ids": [a]})).unwrap(); + s.execute("selection.set", &json!({"ids": [a]})).unwrap(); + let name = + s.execute("style.object.create", &json!({"name": "Rounded Corner Image, White Stroke"})).unwrap()["name"].as_str().unwrap().to_string(); + let os = s.doc().unwrap().doc.styles.object_style(&name).cloned().unwrap(); + assert_eq!(os.corners.unwrap().corners[0].shape, CornerShape::Rounded); + let (b, c) = (frame(&mut s, 200.0), frame(&mut s, 360.0)); + s.execute("style.object.apply", &json!({"name": name, "ids": [a, b, c]})).unwrap(); + assert_eq!((item(&s, b).corners, item(&s, b).stroke.weight, item(&s, b).stroke.swatch.as_str()), (item(&s, a).corners, 2.5, "[Paper]")); + s.execute("object.stroke", &json!({"weight": 1, "ids": [c]})).unwrap(); + + let r = s.execute("style.object.edit", &json!({"name": name, "stroke": {"weight": 6}, "corners": {"size": 30}})).unwrap(); + assert_eq!(r["updated"], 3); + assert_eq!((item(&s, a).stroke.weight, item(&s, b).stroke.weight, item(&s, c).stroke.weight), (6.0, 6.0, 1.0)); + assert_eq!(item(&s, b).stroke.swatch, "[Paper]"); + assert!(item(&s, c).corners.corners.iter().all(|k| k.shape == CornerShape::Rounded && k.size == 30.0), "corners follow apart from the stroke"); + s.execute("style.object.edit", &json!({"name": name, "fill": "[Black]"})).unwrap(); + assert_eq!(item(&s, b).fill.swatch, "[Black]"); + + // Renamed, the items keep the style; a name in use or a built-in one is refused. + s.execute("style.object.edit", &json!({"name": name, "rename": "Framed"})).unwrap(); + assert_eq!(item(&s, a).object_style, "Framed"); + assert!(s.doc().unwrap().doc.styles.object_style(&name).is_none()); + assert!(s.execute("style.object.edit", &json!({"name": "Framed", "rename": "[Basic Text Frame]"})).is_err()); + assert!(s.execute("style.object.edit", &json!({"name": "[Basic Graphics Frame]", "rename": "Mine"})).is_err()); + assert!(s.execute("style.object.edit", &json!({"name": "Framed", "corners": {"size": "big"}})).is_err()); + // null: the style stops setting corners, the items keep theirs. + s.execute("style.object.edit", &json!({"name": "Framed", "corners": null})).unwrap(); + assert!(s.doc().unwrap().doc.styles.object_style("Framed").unwrap().corners.is_none()); + assert_eq!(item(&s, a).corners.corners[0].size, 30.0); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(item(&s, a).object_style, name); +} + #[test] fn object_style_effects_reach_grouped_and_anchored_items() { use designcraft_doc::ItemId; diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 86fef8e46..e29a6838e 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1058,6 +1058,13 @@ impl<'a> Ex<'a> { el.set("EnableStroke", bool_s(os.stroke.is_some())); el.set("EnableParagraphStyle", bool_s(os.paragraph_style.is_some())); el.set("EnableTextFrameGeneralOptions", bool_s(os.text_frame.is_some())); + if let Some(c) = &os.corners { + el.set("EnableStrokeAndCornerOptions", "true"); + for (n, c) in ["TopLeft", "TopRight", "BottomRight", "BottomLeft"].iter().zip(c.corners) { + el.set(&format!("{n}CornerOption"), names::corner_out(c.shape)); + el.set(&format!("{n}CornerRadius"), num(c.size)); + } + } if let Some(f) = &os.fill { el.set("FillColor", self.sw(&f.swatch)); el.set("FillTint", pct(f.tint as f64)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d770aef79..6160bb599 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1271,7 +1271,11 @@ impl<'r> Importer<'r> { None }; let effects = self.style_effects(e); - let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, text_frame, effects }; + // 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 }); + let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, text_frame, effects, corners }; match self.styles.object.iter_mut().find(|o| o.name == name) { Some(slot) => *slot = s, None => self.styles.object.push(s), diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 035333da4..19232c54c 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -3553,6 +3553,8 @@ fn object_style_effects_round_trip() { }; 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 rounded = designcraft_geom::corners::CornerOptions::uniform(designcraft_geom::corners::CornerShape::Rounded, 9.0); + d.styles_mut().object.push(ObjectStyle { name: "Rounded".into(), corners: Some(rounded), ..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))); @@ -3583,6 +3585,9 @@ fn object_style_effects_round_trip() { assert!((ds.distance - 5.0).abs() < 1e-6 && (ds.angle - DropShadow::default().angle).abs() < 1e-6, "{ds:?}"); assert_eq!(got.feather, 3.0); assert!(style("Plain").effects.is_none()); + // Corner options (Stroke & Corner Options). + assert_eq!(style("Rounded").corners, Some(rounded)); + assert_eq!(style("Plain").corners, None); // The frame's effects are the style's, so it follows later edits of the style. let frames: Vec<_> = back.spreads[0].items.iter().filter(|i| i.object_style == "Photo").collect(); assert_eq!(frames[0].effects, got); From 17da588d8468e64000fd7ef50d97ccddee2747e8 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:39:44 +0200 Subject: [PATCH 02/36] Object Styles panel and the New Object Style / Object Style Options dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window › Styles › Object Styles, as InDesign's: the selected object's style on top (+ when the object differs from it), the document's object styles (click applies, double-click opens the options, the default graphic and text frame styles marked), Clear Overrides and Create New Style (Alt-click: the dialog), and a menu with New Object Style… and Object Style Options…. The dialog: Style Name; Basic Attributes General (Based On and the Style Settings summary), Fill and Stroke (swatch list, tint, weight) and Stroke & Corner Options (corner shape and size), each included in the style by its checkbox; Effects for Object, with each effect's page from the Effects dialog; Apply Style to Selection for a new style. OK runs style.object.create (then style.object.apply) or style.object.edit. UI command objectStyle.dialog {name?}. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 4 + crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/effects_dialog.rs | 8 +- crates/ui-egui/src/i18n.rs | 39 ++ crates/ui-egui/src/i18n/ar.rs | 12 + crates/ui-egui/src/i18n/cs.rs | 12 + crates/ui-egui/src/i18n/it.rs | 13 + crates/ui-egui/src/i18n/ka.rs | 12 + crates/ui-egui/src/i18n/pl.rs | 12 + crates/ui-egui/src/i18n/pt_br.rs | 13 + crates/ui-egui/src/i18n/ru.rs | 12 + crates/ui-egui/src/i18n/uk.rs | 13 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 14 + crates/ui-egui/src/object_style_dialog.rs | 467 ++++++++++++++++++++++ crates/ui-egui/src/panels/styles.rs | 143 ++++++- 17 files changed, 773 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/object_style_dialog.rs diff --git a/ROADMAP.md b/ROADMAP.md index b1199ed8f..e4cf4399e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Window › Styles › Object Styles: InDesign's Object Styles panel (the selected object's style, `+` when overridden; a click applies a style, a double-click opens its options; the default graphic and text frame styles marked; Clear Overrides; Create New Style, ⌥/Alt-click for the dialog) and its New Object Style / Object Style Options dialog (Style Name; Basic Attributes Fill, Stroke, Stroke & Corner Options, each included by its checkbox; Effects for Object with the effects' pages; Style Settings; Apply Style to Selection). UI command `objectStyle.dialog`. Found in the Learn Skills Daily course, chapter 15. No readiness percentage change. - **2026-10-21** · Object styles carry Corner Options (`corners`), taken from the selection by `style.object.create` and written to IDML (`EnableStrokeAndCornerOptions`, `TopLeftCornerOption`…). `style.object.edit` also changes a style's fill, stroke and corners and renames it (`rename`); the items using the style that haven't overridden an attribute follow each change, as in InDesign. Found in the Learn Skills Daily course, chapter 15. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..251ce9005 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2087,6 +2087,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "colorGroupOptions" => crate::i18n::tr(&app.ui.language, "Color Group Options"), "layerSettings" => crate::i18n::tr(&app.ui.language, "Layer Options"), crate::effects_dialog::ID => crate::i18n::tr(&app.ui.language, "Effects"), + crate::object_style_dialog::ID => crate::i18n::tr(&app.ui.language, crate::object_style_dialog::title(&d)), id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) { Some(c) => crate::i18n::tr(&app.ui.language, c.label.trim_end_matches('…')), None => crate::i18n::tr(&app.ui.language, "Dialog"), @@ -2104,6 +2105,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "newDocument" => 960.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, + crate::object_style_dialog::ID => 760.0, _ => 640.0, }; let width = preferred_width.min(available.x); @@ -2361,6 +2363,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "colorGroupOptions" => options::color_group_options(app, ui, &mut d), "layerSettings" => options::layer_options(app, ui, &mut d), crate::effects_dialog::ID => crate::effects_dialog::show(app, ui, &mut d), + crate::object_style_dialog::ID => crate::object_style_dialog::show(app, ui, &mut d), "preferences" => preferences(app, ui, &mut d, body_height), "print" => print_dialog(app, ui, &mut d), "pdfImport" => { @@ -3573,6 +3576,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "alert" => Ok(Value::Null), crate::effects_dialog::ID => crate::effects_dialog::confirm(app, &d), + crate::object_style_dialog::ID => crate::object_style_dialog::confirm(app, &d), "findChange" => { let result = if d.b("objectMode") { Ok(Value::Null) } else { app.run("find.change", find_change_params(&d)) }; // Confirmation consumes retained bounds before closing, even on an error. diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index d4551e813..3612e2968 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -19,6 +19,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("effects", "Effects", "panel-effects"), ("paragraphStyles", "Paragraph Styles", "panel-pstyles"), ("characterStyles", "Character Styles", "panel-cstyles"), + ("objectStyles", "Object Styles", "frame-dashed"), ("character", "Character", "panel-character"), ("typography", "Typography", "panel-typography"), ("glyphs", "Glyphs", "panel-character"), @@ -280,6 +281,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "swatches" => panels::swatches::show(app, ui), "paragraphStyles" => panels::styles::paragraph(app, ui), "characterStyles" => panels::styles::character(app, ui), + "objectStyles" => panels::styles::object(app, ui), "stroke" => panels::properties::stroke_panel(app, ui), "character" => panels::properties::character_panel(app, ui), "typography" => panels::typography::show(app, ui), diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index 456ca4a58..cd54fc225 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -65,7 +65,7 @@ pub fn open(app: &mut DesignApp, page: &str) { } /// Every effect's settings, those still at their defaults included (the document leaves them out). -fn effects_json(e: &designcraft_doc::Effects) -> Value { +pub(crate) fn effects_json(e: &designcraft_doc::Effects) -> Value { use designcraft_doc::{Bevel, DirectionalFeather, DropShadow, FeatherOptions, GradientFeather, InnerGlow, InnerShadow, OuterGlow, Satin}; let mut fx = serde_json::to_value(e).unwrap_or_else(|_| json!({})); let defaults = [ @@ -90,7 +90,7 @@ fn effects_json(e: &designcraft_doc::Effects) -> Value { } /// Turn the effect of `page` on, as choosing it does (Basic Feather: its default width). -fn turn_on(fx: &mut Value, page: &str) { +pub(crate) fn turn_on(fx: &mut Value, page: &str) { match page { "transparency" => {} "feather" => { @@ -106,7 +106,7 @@ fn turn_on(fx: &mut Value, page: &str) { } } -fn is_on(fx: &Value, page: &str) -> bool { +pub(crate) fn is_on(fx: &Value, page: &str) -> bool { match page { "transparency" => true, "feather" => fx["feather"].as_f64().unwrap_or(0.0) > 0.0, @@ -269,7 +269,7 @@ fn offset(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, global: f64, } /// The settings of `page`. -fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { +pub(crate) fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let lang = app.ui.language.clone(); let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); let unit = app.session.active().map_or(Unit::Points, |st| st.doc.settings.horizontal_units); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..72d756c7b 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -36,6 +36,45 @@ pub const LANGUAGES: &[(&str, &str)] = &[ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ("New Object Style", ["Neues Objektformat", "Nouveau style d'objet", "Nuevo estilo de objeto", "新規オブジェクトスタイル", "新建对象样式"]), + ( + "Object Style Options", + ["Objektformatoptionen", "Options de style d'objet", "Opciones de estilo de objeto", "オブジェクトスタイルの設定", "对象样式选项"], + ), + ( + "Object Style Options…", + ["Objektformatoptionen…", "Options de style d'objet…", "Opciones de estilo de objeto…", "オブジェクトスタイルの設定…", "对象样式选项…"], + ), + ("Basic Attributes", ["Grundattribute", "Attributs de base", "Atributos básicos", "基本属性", "基本属性"]), + ( + "Stroke & Corner Options", + ["Kontur- und Eckenoptionen", "Options de contour et d'angle", "Opciones de trazo y esquina", "線とコーナーのオプション", "描边和角选项"], + ), + ("Effects for:", ["Effekte für:", "Effets pour :", "Efectos para:", "効果 :", "效果:"]), + ( + "Apply Style to Selection", + [ + "Format auf Auswahl anwenden", + "Appliquer le style à la sélection", + "Aplicar estilo a la selección", + "選択範囲にスタイルを適用", + "将样式应用于选区", + ], + ), + ("Create New Style", ["Neues Format erstellen", "Créer un style", "Crear nuevo estilo", "新規スタイルを作成", "创建新样式"]), + ("Clear Overrides", ["Abweichungen löschen", "Effacer les remplacements", "Borrar modificaciones", "オーバーライドを消去", "清除覆盖"]), + ("Shape:", ["Form:", "Forme :", "Forma:", "形状 :", "形状:"]), + ("(no attributes)", ["(keine Attribute)", "(aucun attribut)", "(sin atributos)", "(属性なし)", "(无属性)"]), + ( + "Enter a style name.", + [ + "Geben Sie einen Formatnamen ein.", + "Saisissez un nom de style.", + "Escriba un nombre de estilo.", + "スタイル名を入力してください。", + "请输入样式名称。", + ], + ), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..1872dd73c 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("New Object Style", "نمط كائن جديد"), + ("Object Style Options", "خيارات نمط الكائن"), + ("Object Style Options…", "خيارات نمط الكائن…"), + ("Basic Attributes", "السمات الأساسية"), + ("Stroke & Corner Options", "خيارات الحد والزوايا"), + ("Effects for:", "التأثيرات لـ:"), + ("Apply Style to Selection", "تطبيق النمط على التحديد"), + ("Create New Style", "إنشاء نمط جديد"), + ("Clear Overrides", "مسح التجاوزات"), + ("Shape:", "الشكل:"), + ("(no attributes)", "(لا سمات)"), + ("Enter a style name.", "أدخل اسم نمط."), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..64261a4de 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("New Object Style", "Nový styl objektu"), + ("Object Style Options", "Volby stylu objektu"), + ("Object Style Options…", "Volby stylu objektu…"), + ("Basic Attributes", "Základní atributy"), + ("Stroke & Corner Options", "Volby tahu a rohů"), + ("Effects for:", "Efekty pro:"), + ("Apply Style to Selection", "Použít styl na výběr"), + ("Create New Style", "Vytvořit nový styl"), + ("Clear Overrides", "Vymazat změny"), + ("Shape:", "Tvar:"), + ("(no attributes)", "(žádné atributy)"), + ("Enter a style name.", "Zadejte název stylu."), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..ab7a6840f 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("New Object Style", "Nuovo stile oggetto"), + ("Object Style Options", "Opzioni stile oggetto"), + ("Object Style Options…", "Opzioni stile oggetto…"), + ("Basic Attributes", "Attributi di base"), + ("Stroke & Corner Options", "Opzioni traccia e angoli"), + ("Effects for:", "Effetti per:"), + ("Apply Style to Selection", "Applica stile alla selezione"), + ("Create New Style", "Crea nuovo stile"), + ("Clear Overrides", "Cancella sostituzioni"), + ("Shape:", "Forma:"), + ("(no attributes)", "(nessun attributo)"), + ("Enter a style name.", "Inserisci un nome di stile."), + ("Corner Options", "Opzioni angolo"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..fe735f4d7 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("New Object Style", "ახალი ობიექტის სტილი"), + ("Object Style Options", "ობიექტის სტილის პარამეტრები"), + ("Object Style Options…", "ობიექტის სტილის პარამეტრები…"), + ("Basic Attributes", "ძირითადი ატრიბუტები"), + ("Stroke & Corner Options", "კონტურისა და კუთხეების პარამეტრები"), + ("Effects for:", "ეფექტები:"), + ("Apply Style to Selection", "სტილის გამოყენება მონიშნულზე"), + ("Create New Style", "ახალი სტილის შექმნა"), + ("Clear Overrides", "გადაფარვების გასუფთავება"), + ("Shape:", "ფორმა:"), + ("(no attributes)", "(ატრიბუტების გარეშე)"), + ("Enter a style name.", "შეიყვანეთ სტილის სახელი."), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..f0f7f58a1 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("New Object Style", "Nowy styl obiektu"), + ("Object Style Options", "Opcje stylu obiektu"), + ("Object Style Options…", "Opcje stylu obiektu…"), + ("Basic Attributes", "Atrybuty podstawowe"), + ("Stroke & Corner Options", "Opcje obrysu i narożników"), + ("Effects for:", "Efekty dla:"), + ("Apply Style to Selection", "Zastosuj styl do zaznaczenia"), + ("Create New Style", "Utwórz nowy styl"), + ("Clear Overrides", "Wyczyść zmiany"), + ("Shape:", "Kształt:"), + ("(no attributes)", "(brak atrybutów)"), + ("Enter a style name.", "Wpisz nazwę stylu."), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..b922a37c9 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("New Object Style", "Novo estilo de objeto"), + ("Object Style Options", "Opções de estilo de objeto"), + ("Object Style Options…", "Opções de estilo de objeto…"), + ("Basic Attributes", "Atributos básicos"), + ("Stroke & Corner Options", "Opções de traçado e canto"), + ("Effects for:", "Efeitos para:"), + ("Apply Style to Selection", "Aplicar estilo à seleção"), + ("Create New Style", "Criar novo estilo"), + ("Clear Overrides", "Limpar substituições"), + ("Shape:", "Forma:"), + ("(no attributes)", "(sem atributos)"), + ("Enter a style name.", "Digite um nome de estilo."), + ("Corner Options", "Opções de canto"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..d8630b712 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("New Object Style", "Новый стиль объекта"), + ("Object Style Options", "Параметры стиля объекта"), + ("Object Style Options…", "Параметры стиля объекта…"), + ("Basic Attributes", "Основные атрибуты"), + ("Stroke & Corner Options", "Параметры обводки и углов"), + ("Effects for:", "Эффекты для:"), + ("Apply Style to Selection", "Применить стиль к выделенному"), + ("Create New Style", "Создать новый стиль"), + ("Clear Overrides", "Удалить переопределения"), + ("Shape:", "Форма:"), + ("(no attributes)", "(нет атрибутов)"), + ("Enter a style name.", "Введите имя стиля."), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..242cb0d1e 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("New Object Style", "Новий стиль об'єкта"), + ("Object Style Options", "Параметри стилю об'єкта"), + ("Object Style Options…", "Параметри стилю об'єкта…"), + ("Basic Attributes", "Основні атрибути"), + ("Stroke & Corner Options", "Параметри обведення й кутів"), + ("Effects for:", "Ефекти для:"), + ("Apply Style to Selection", "Застосувати стиль до виділеного"), + ("Create New Style", "Створити новий стиль"), + ("Clear Overrides", "Очистити перевизначення"), + ("Shape:", "Форма:"), + ("(no attributes)", "(немає атрибутів)"), + ("Enter a style name.", "Введіть назву стилю."), + ("Corner Options", "Параметри кутів"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cb98cac7f..1842bdb49 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,7 @@ pub mod menus; mod modal_drag; mod new_document; mod object_control; +pub mod object_style_dialog; mod panel_docking; pub mod panels; mod prefs; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 79b8b2c4e..237e64f55 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -39,6 +39,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{page?: transparency|dropShadow|innerShadow|outerGlow|innerGlow|bevel|satin|feather|directionalFeather|gradientFeather} — the Effects dialog for the selected objects", ), + ( + "objectStyle.dialog", + "Object Style…", + None, + "{name?: style} — Object Style Options for the style `name`, or (without it) New Object Style from the selected object", + ), ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{} — choose the package folder to create (file.package)"), ("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"), ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"), @@ -878,6 +884,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:window.panel|Stroke|{\"panel\": \"stroke\"}", ">Styles", "ui:window.panel|Character Styles|{\"panel\": \"characterStyles\"}", + "ui:window.panel|Object Styles|{\"panel\": \"objectStyles\"}", "ui:window.panel|Paragraph Styles|{\"panel\": \"paragraphStyles\"}", "<", "ui:window.panel|Text Wrap|{\"panel\": \"textWrap\"}", @@ -1188,6 +1195,13 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + match p.get("name").and_then(Value::as_str) { + Some(n) => crate::object_style_dialog::open_edit(app, n), + None => crate::object_style_dialog::open_new(app), + } + Ok(Value::Null) + } "app.loadSwatches" => { if let Some(pick) = app.services.pick_open.as_mut() { return Some(match pick("swatches") { diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs new file mode 100644 index 000000000..b9e1b3ce5 --- /dev/null +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -0,0 +1,467 @@ +//! The Object Styles panel's New Object Style and Object Style Options dialog, laid out as +//! InDesign's: the style's name on top; on the left its Basic Attributes (a checkbox includes one +//! in the style, a click shows its settings) and its effects; on the right the settings of the +//! attribute shown (General sums the style up); Apply Style to Selection for a new style. + +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::dialogs::Dialog; +use crate::theme::semibold; + +/// The dialog's id. +pub const ID: &str = "objectStyle"; + +/// Basic Attributes: (key, label). General isn't an attribute: it sums the style up. +const ATTRIBUTES: [(&str, &str); 4] = [("general", "General"), ("fill", "Fill"), ("stroke", "Stroke"), ("corners", "Stroke & Corner Options")]; + +/// The dialog for a new style from the selected object (its fill, stroke, corners and effects, +/// as InDesign's New Object Style), named "Object Style n". +pub fn open_new(app: &mut DesignApp) { + let Some(st) = app.session.active() else { return }; + let it = st.selection.items.first().and_then(|i| st.doc.item(*i)).cloned(); + let taken = |n: &str| st.doc.styles.object_style(n).is_some(); + let name = (1..).map(|i| format!("Object Style {i}")).find(|n| !taken(n)).unwrap_or_default(); + let from = it.is_some(); + let fields = json!({ + "new": true, + "name": name, + "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(), + "fx": crate::effects_dialog::effects_json(&it.map(|i| i.effects).unwrap_or_default()), + "hadEffects": from, + "inc": {"fill": from, "stroke": from, "corners": from}, + "page": "general", + "apply": from, + }); + app.ui.dialog = Some(Dialog::new(ID, fields)); +} + +/// Object Style Options for style `name`. +pub fn open_edit(app: &mut DesignApp, name: &str) { + let Some(os) = app.session.active().and_then(|st| st.doc.styles.object_style(name).cloned()) else { return }; + if name == designcraft_doc::NO_OBJECT_STYLE { + return; + } + let fields = json!({ + "new": false, + "orig": name, + "name": name, + "basedOn": os.based_on.as_deref().unwrap_or(designcraft_doc::NO_OBJECT_STYLE), + "fill": os.fill.clone().unwrap_or_default(), + "stroke": os.stroke.clone().unwrap_or_default(), + "corners": os.corners.unwrap_or_default(), + "fx": crate::effects_dialog::effects_json(&os.effects.clone().unwrap_or_default()), + "hadEffects": os.effects.is_some(), + "inc": {"fill": os.fill.is_some(), "stroke": os.stroke.is_some(), "corners": os.corners.is_some()}, + "page": "general", + "apply": false, + }); + app.ui.dialog = Some(Dialog::new(ID, fields)); +} + +/// The dialog's title: New Object Style or Object Style Options. +pub fn title(d: &Dialog) -> &'static str { + if d.b("new") { "New Object Style" } else { "Object Style Options" } +} + +fn included(d: &Dialog, key: &str) -> bool { + d.fields.get("inc").and_then(|i| i[key].as_bool()).unwrap_or(false) +} + +/// Whether the style sets effects: it did, or one is on now. +fn sets_effects(d: &Dialog) -> bool { + let fx = d.fields.get("fx").cloned().unwrap_or(Value::Null); + d.b("hadEffects") || crate::effects_dialog::PAGES.iter().skip(1).any(|(k, _)| crate::effects_dialog::is_on(&fx, k)) +} + +/// The style's attributes as the commands take them (null: not in the style). +fn attrs(d: &Dialog) -> serde_json::Map { + let mut p = serde_json::Map::new(); + for k in ["fill", "stroke", "corners"] { + p.insert(k.into(), if included(d, k) { d.fields.get(k).cloned().unwrap_or(Value::Null) } else { Value::Null }); + } + p.insert("effects".into(), if sets_effects(d) { d.fields.get("fx").cloned().unwrap_or(Value::Null) } else { Value::Null }); + p +} + +/// OK: the new style (applied to the selection when asked), or the edited one. +pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { + let name = d.s("name").trim().to_string(); + if name.is_empty() { + return Err(crate::i18n::tr(&app.ui.language, "Enter a style name.").to_string()); + } + let mut p = attrs(d); + if d.b("new") { + p.insert("name".into(), json!(name)); + p.insert("fromSelection".into(), json!(false)); + let r = app.run("style.object.create", Value::Object(p)).map_err(|e| e.to_string())?; + let made = r["name"].as_str().unwrap_or(&name).to_string(); + if d.b("apply") && app.session.active().is_some_and(|st| !st.selection.items.is_empty()) { + app.run("style.object.apply", json!({"name": made})).map_err(|e| e.to_string())?; + } + Ok(r) + } else { + let orig = d.s("orig"); + p.insert("name".into(), json!(orig)); + if name != orig { + p.insert("rename".into(), json!(name)); + } + // A style that didn't set effects and still doesn't: nothing to say about them. + if p.get("effects").is_some_and(Value::is_null) && !d.b("hadEffects") { + p.remove("effects"); + } + app.run("style.object.edit", Value::Object(p)).map_err(|e| e.to_string()) + } +} + +fn set(d: &mut Dialog, path: &[&str], v: Value) { + let Some((first, rest)) = path.split_first() else { return }; + let mut cur = d.fields.entry(first.to_string()).or_insert(Value::Null); + for k in rest { + if !cur.is_object() { + *cur = json!({}); + } + cur = &mut cur[*k]; + } + *cur = v; +} + +fn get<'a>(d: &'a Dialog, path: &[&str]) -> &'a Value { + let Some((first, rest)) = path.split_first() else { return &Value::Null }; + let mut v = d.fields.get(*first).unwrap_or(&Value::Null); + for k in rest { + v = &v[*k]; + } + v +} + +/// A row of the left-hand lists: a checkbox (when `check` is given) and the label, which shows +/// `page`. +fn list_row(ui: &mut egui::Ui, d: &mut Dialog, page: &str, label: &str, check: Option) -> Option { + let mut out = None; + ui.horizontal(|ui| { + match check { + Some(mut on) => { + if ui.checkbox(&mut on, "").changed() { + out = Some(on); + } + } + None => ui.add_space(24.0), + } + let cur = d.fields.get("page").and_then(Value::as_str) == Some(page); + if ui.selectable_label(cur, crate::rtl::widget(ui, label)).clicked() { + d.fields.insert("page".into(), json!(page)); + } + }); + out +} + +/// The document's swatches, as a list to pick from (InDesign's Fill and Stroke pages). +fn swatch_list(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, key: &str) { + let Some(doc) = app.session.active().map(|st| &st.doc) else { return }; + let cur = get(d, &[key, "swatch"]).as_str().unwrap_or("").to_string(); + let t = crate::theme::Tokens::get(ui.ctx()); + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.divider)).inner_margin(egui::Margin::same(4)).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt(("os_swatches", key.to_string())).max_height(220.0).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + for w in doc.swatches.iter().filter(|w| !w.hidden || w.name == cur) { + let picked = ui + .horizontal(|ui| { + let (c, g) = crate::widgets::swatch_colors(doc, &w.name, 1.0); + let r = crate::widgets::swatch_chip(ui, 14.0, c, g); + ui.selectable_label(w.name == cur, &w.name).clicked() || r.clicked() + }) + .inner; + if picked { + set(d, &[key, "swatch"], json!(w.name)); + } + } + }); + }); +} + +/// A number field of the dialog at `path` (a percentage of 0–1 with `percent`). +fn number(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], suffix: &str, range: (f64, f64), percent: bool) { + let v = get(d, path).as_f64().unwrap_or(0.0); + let shown = if percent { (v * 100.0).round() } else { v }; + let id = format!("os_{}", path.join("_")); + let decimals = if percent { 0 } else { 2 }; + if let Some(n) = crate::widgets::NumField::number(&id, Some(shown), suffix, decimals).width(90.0).spinner().range(range.0, range.1).show(ui) { + set(d, path, json!(if percent { n / 100.0 } else { n })); + } +} + +/// One line per attribute the style sets (InDesign's Style Settings). +fn settings(app: &DesignApp, d: &Dialog) -> Vec { + let tr = |s: &str| crate::i18n::tr(&app.ui.language, s).to_string(); + let mut out = Vec::new(); + if included(d, "fill") { + let f = get(d, &["fill"]); + out.push(format!("{}: {} {:.0}%", tr("Fill"), f["swatch"].as_str().unwrap_or(""), f["tint"].as_f64().unwrap_or(1.0) * 100.0)); + } + if included(d, "stroke") { + let s = get(d, &["stroke"]); + out.push(format!("{}: {}, {} pt", tr("Stroke"), s["swatch"].as_str().unwrap_or(""), s["weight"].as_f64().unwrap_or(0.0))); + } + if included(d, "corners") { + let c = &get(d, &["corners"])["corners"][0]; + let shape: designcraft_geom::corners::CornerShape = serde_json::from_value(c["shape"].clone()).unwrap_or_default(); + out.push(format!("{}: {} {} pt", tr("Corner Options"), tr(shape.label()), c["size"].as_f64().unwrap_or(0.0))); + } + if sets_effects(d) { + let fx = d.fields.get("fx").cloned().unwrap_or(Value::Null); + let on: Vec = + crate::effects_dialog::PAGES.iter().skip(1).filter(|(k, _)| crate::effects_dialog::is_on(&fx, k)).map(|(_, l)| tr(l)).collect(); + out.push(format!("{}: {}", tr("Effects"), if on.is_empty() { tr("(no effects)") } else { on.join(", ") })); + } + out +} + +/// The settings of the attribute shown. +fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { + let tr = |s: &str| crate::i18n::tr(&app.ui.language, s).to_string(); + let title = ATTRIBUTES.iter().find(|a| a.0 == page).map(|a| a.1); + if let Some(title) = title { + crate::rtl::label(ui, egui::RichText::new(tr(title)).font(semibold(14.0))); + ui.add_space(6.0); + } + match page { + "general" => { + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Based On:")); + // Based On isn't editable yet: the style's parent, as it is. + ui.add_enabled_ui(false, |ui| { + let based = d.s("basedOn"); + egui::ComboBox::from_id_salt("os_based_on") + .selected_text(crate::i18n::style_name(&app.ui.language, &based)) + .width(220.0) + .show_ui(ui, |_| {}); + }); + }); + ui.add_space(10.0); + crate::rtl::label(ui, egui::RichText::new(tr("Style Settings:")).font(semibold(12.0))); + let t = crate::theme::Tokens::get(ui.ctx()); + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.divider)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.set_min_height(160.0); + let lines = settings(app, d); + if lines.is_empty() { + crate::rtl::label(ui, egui::RichText::new(tr("(no attributes)")).color(t.text_dim)); + } + for l in lines { + crate::rtl::label(ui, l); + } + }); + } + "fill" | "stroke" => { + let key = page; + ui.add_enabled_ui(included(d, key), |ui| { + swatch_list(app, ui, d, key); + ui.add_space(6.0); + ui.horizontal(|ui| { + if key == "stroke" { + crate::rtl::label(ui, tr("Weight:")); + number(ui, d, &["stroke", "weight"], " pt", (0.0, 1000.0), false); + ui.add_space(12.0); + } + crate::rtl::label(ui, tr("Tint:")); + number(ui, d, &[key, "tint"], "%", (0.0, 100.0), true); + }); + }); + } + "corners" => { + ui.add_enabled_ui(included(d, "corners"), |ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Corner Options")).font(semibold(12.0))); + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Size:")); + let size = get(d, &["corners", "corners"])[0]["size"].as_f64().unwrap_or(0.0); + if let Some(v) = + crate::widgets::NumField::number("os_corner_size", Some(size), " pt", 2).width(90.0).spinner().range(0.0, 10_000.0).show(ui) + { + let mut c = get(d, &["corners"]).clone(); + if let Some(cs) = c["corners"].as_array_mut() { + cs.iter_mut().for_each(|k| k["size"] = json!(v)); + } + d.fields.insert("corners".into(), c); + } + ui.add_space(12.0); + crate::rtl::label(ui, tr("Shape:")); + let cur: designcraft_geom::corners::CornerShape = + serde_json::from_value(get(d, &["corners", "corners"])[0]["shape"].clone()).unwrap_or_default(); + let mut picked = None; + egui::ComboBox::from_id_salt("os_corner_shape").selected_text(tr(cur.label())).width(140.0).show_ui(ui, |ui| { + for s in designcraft_geom::corners::CornerShape::ALL { + if ui.selectable_label(s == cur, tr(s.label())).clicked() { + picked = Some(s); + } + } + }); + if let Some(s) = picked { + let mut c = get(d, &["corners"]).clone(); + if let Some(cs) = c["corners"].as_array_mut() { + cs.iter_mut().for_each(|k| k["shape"] = json!(s)); + } + d.fields.insert("corners".into(), c); + } + }); + }); + } + p => crate::effects_dialog::page_ui(app, ui, d, p), + } +} + +/// The dialog's body. +pub fn show(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Style Name:")); + let mut name = d.s("name"); + if ui.add(egui::TextEdit::singleline(&mut name).desired_width(300.0)).changed() { + d.fields.insert("name".into(), json!(name)); + } + }); + ui.add_space(8.0); + let page = d.fields.get("page").and_then(Value::as_str).unwrap_or("general").to_string(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(230.0); + crate::rtl::label(ui, egui::RichText::new(tr("Basic Attributes")).font(semibold(12.0))); + for (key, label) in ATTRIBUTES { + let check = (key != "general").then(|| included(d, key)); + if let Some(on) = list_row(ui, d, key, &tr(label), check) { + set(d, &["inc", key], json!(on)); + } + } + ui.add_space(8.0); + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Effects for:")); + // Stroke, Fill and Text levels aren't in the document model yet: the object only. + ui.add_enabled_ui(false, |ui| { + egui::ComboBox::from_id_salt("os_fx_level").selected_text(tr("Object")).width(100.0).show_ui(ui, |_| {}); + }); + }); + for (key, label) in crate::effects_dialog::PAGES.iter().skip(1) { + let fx = d.fields.get("fx").cloned().unwrap_or(Value::Null); + if let Some(on) = list_row(ui, d, key, &tr(label), Some(crate::effects_dialog::is_on(&fx, key))) + && let Some(f) = d.fields.get_mut("fx") + { + if on { + crate::effects_dialog::turn_on(f, key); + } else if *key == "feather" { + f["feather"] = json!(0.0); + } else { + f[*key]["on"] = json!(false); + } + } + } + }); + ui.add_space(16.0); + ui.vertical(|ui| { + ui.set_min_width(440.0); + page_ui(app, ui, d, &page); + }); + }); + ui.add_space(10.0); + if d.b("new") { + let mut apply = d.b("apply"); + if ui.checkbox(&mut apply, crate::rtl::widget(ui, tr("Apply Style to Selection"))).changed() { + d.fields.insert("apply".into(), json!(apply)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app_with_frame() -> (DesignApp, u64) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 172, 172], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + (app, id) + } + + fn frame(app: &mut DesignApp, ctx: &egui::Context) -> Vec { + let mut texts = Vec::new(); + for _ in 0..3 { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + fn walk(s: egui::epaint::Shape, texts: &mut Vec) { + match s { + egui::epaint::Shape::Text(t) => texts.push(t.galley.text().to_string()), + egui::epaint::Shape::Vec(v) => v.into_iter().for_each(|s| walk(s, texts)), + _ => {} + } + } + texts.clear(); + for c in out.shapes { + walk(c.shape, &mut texts); + } + } + texts + } + + /// The course's chapter 15: New Object Style from a frame with rounded corners, a paper + /// stroke and a drop shadow, named and applied to the selection; then Object Style Options + /// with a thicker stroke: the frames using the style follow. + #[test] + fn a_new_object_style_from_the_selection_and_its_options() { + let (mut app, a) = app_with_frame(); + app.run("object.cornerOptions", json!({"shape": "rounded", "size": 12, "ids": [a]})).unwrap(); + app.run("object.stroke", json!({"swatch": "[Paper]", "weight": 2.5, "ids": [a]})).unwrap(); + app.run("object.dropShadow", json!({"on": true, "ids": [a]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + open_new(&mut app); + let texts = frame(&mut app, &ctx); + for t in [ + "New Object Style", + "Style Name:", + "Basic Attributes", + "Fill", + "Stroke & Corner Options", + "Effects for:", + "Drop Shadow", + "Style Settings:", + "Apply Style to Selection", + ] { + assert!(texts.iter().any(|x| x.trim() == t), "{t}: {texts:?}"); + } + assert!(texts.iter().any(|x| x.starts_with("Stroke: [Paper], 2.5 pt")), "{texts:?}"); + assert!(texts.iter().any(|x| x.starts_with("Effects: Drop Shadow")), "{texts:?}"); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("name".into(), json!("Rounded Corner Image, White Stroke")); + d.fields.insert("page".into(), json!("corners")); + } + let texts = frame(&mut app, &ctx); + assert!(texts.iter().any(|x| x.trim() == "Rounded"), "{texts:?}"); + crate::dialogs::confirm(&mut app).unwrap(); + let name = "Rounded Corner Image, White Stroke"; + let doc = |app: &DesignApp| app.session.active().unwrap().doc.clone(); + let item = |app: &DesignApp, id: u64| doc(app).item(designcraft_doc::ItemId(id)).unwrap().clone(); + assert_eq!(item(&app, a).object_style, name, "applied to the selection"); + let os = doc(&app).styles.object_style(name).cloned().unwrap(); + assert!(os.corners.is_some() && os.effects.as_ref().unwrap().drop_shadow.on && os.stroke.as_ref().unwrap().weight == 2.5); + + let b = app.run("frame.create", json!({"rect": [200, 72, 300, 172], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + app.run("style.object.apply", json!({"name": name, "ids": [b]})).unwrap(); + open_edit(&mut app, name); + if let Some(d) = app.ui.dialog.as_mut() { + assert_eq!(title(d), "Object Style Options"); + set(d, &["stroke", "weight"], json!(5.0)); + set(d, &["inc", "fill"], json!(false)); + } + frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!((item(&app, a).stroke.weight, item(&app, b).stroke.weight), (5.0, 5.0)); + assert!(doc(&app).styles.object_style(name).unwrap().fill.is_none(), "Fill unchecked: no longer in the style"); + assert!(doc(&app).styles.object_style(name).unwrap().effects.as_ref().unwrap().drop_shadow.on, "effects kept"); + } +} diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index f7e6fa803..0f2295340 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -1,4 +1,4 @@ -//! Paragraph Styles and Character Styles panels. +//! Paragraph Styles, Character Styles and Object Styles panels. use egui::{Sense, vec2}; use serde_json::json; @@ -105,6 +105,106 @@ pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) { list(app, ui, false); } +/// The selected object's style, and whether the object differs from it (an override: `+`). +fn object_style_of_selection(app: &DesignApp) -> Option<(String, bool)> { + let st = app.session.active()?; + let it = st.selection.items.first().and_then(|i| st.doc.item(*i))?; + let mut styled = it.clone(); + if let Some(os) = st.doc.styles.object_style(&it.object_style) { + os.apply_to(&mut styled); + } + Some((it.object_style.clone(), styled != *it)) +} + +/// Window › Styles › Object Styles, as InDesign's: the selected object's style on top (`+` when +/// it is overridden), the document's object styles (a click applies one to the selection, a +/// double-click opens its options; the default graphic and text frame styles are marked), and at +/// the bottom Clear Overrides and Create New Style (⌥/Alt-click: the New Object Style dialog). +pub fn object(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let Some(st) = app.session.active() else { return }; + let names: Vec = st.doc.styles.object.iter().map(|s| s.name.clone()).collect(); + let (default_graphic, default_text) = (st.doc.styles.default_graphic_frame.clone(), st.doc.styles.default_text_frame.clone()); + let current = object_style_of_selection(app); + let selected = current.is_some(); + let shown = |n: &str, over: bool| format!("{}{}", crate::i18n::style_name(&lang, n), if over { "+" } else { "" }); + crate::rtl::label(ui, egui::RichText::new(current.as_ref().map_or_else(String::new, |(n, over)| shown(n, *over))).size(11.0).color(t.text_dim)); + for n in &names { + let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + let is_current = current.as_ref().is_some_and(|(c, _)| c == n); + if is_current { + ui.painter().rect_filled(row, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(row, 0.0, t.hover); + } + let label = shown(n, is_current && current.as_ref().is_some_and(|c| c.1)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, is_current, &label)); + crate::rtl::paint(ui.painter(), row.min + vec2(24.0, 11.0), egui::Align2::LEFT_CENTER, &label, egui::FontId::proportional(12.5), t.text); + // The default styles of new graphic and text frames, as InDesign marks them. + let mark = egui::Rect::from_center_size(egui::pos2(row.max.x - 14.0, row.center().y), vec2(11.0, 11.0)); + if *n == default_graphic { + ui.painter().rect_stroke(mark, 0.0, egui::Stroke::new(1.0, t.icon), egui::StrokeKind::Inside); + } + if *n == default_text { + ui.painter().rect_stroke(mark, 0.0, egui::Stroke::new(1.0, t.icon), egui::StrokeKind::Inside); + ui.painter().text(mark.center(), egui::Align2::CENTER_CENTER, "T", egui::FontId::proportional(8.5), t.icon); + } + let editable = n != designcraft_doc::NO_OBJECT_STYLE; + if resp.double_clicked() && editable { + crate::object_style_dialog::open_edit(app, n); + } else if resp.clicked() && selected { + let _ = app.run("style.object.apply", json!({"name": n})); + } + resp.context_menu(|ui| { + let label = crate::i18n::style_name(&lang, n); + if editable && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Edit \"{name}\"…").replace("{name}", label))).clicked() { + crate::object_style_dialog::open_edit(app, n); + ui.close(); + } + if ui + .add_enabled(selected, egui::Button::new(crate::rtl::widget(ui, format!("{} \"{}\"", crate::i18n::tr(&lang, "Apply"), label)))) + .clicked() + { + let _ = app.run("style.object.apply", json!({"name": n})); + ui.close(); + } + }); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icons::button(ui, "style-new", 20.0, false, crate::i18n::tr(&lang, "Create New Style")).clicked() { + if ui.input(|i| i.modifiers.alt) { + crate::object_style_dialog::open_new(app); + } else if selected { + let _ = app.run("style.object.create", json!({"name": "Object Style 1", "fromSelection": true})); + } else { + crate::object_style_dialog::open_new(app); + } + } + let over = current.as_ref().is_some_and(|c| c.1); + if ui.add_enabled_ui(over, |ui| icons::button(ui, "style-clear", 20.0, false, crate::i18n::tr(&lang, "Clear Overrides"))).inner.clicked() + && over + { + let _ = app.run("style.object.clearOverrides", json!({})); + } + crate::menus::menu_button(ui, "☰", |ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "New Object Style…"))).clicked() { + crate::object_style_dialog::open_new(app); + ui.close(); + } + if let Some((n, _)) = current.as_ref().filter(|(n, _)| n != designcraft_doc::NO_OBJECT_STYLE) + && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Object Style Options…"))).clicked() + { + crate::object_style_dialog::open_edit(app, n); + ui.close(); + } + }); + }); + }); +} + /// One style in the list: click applies (⌥ clears overrides), double-click edits, /// right-click: edit, apply, break link, move to group. fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, current: Option<&str>, overridden: bool, groups: &[String], t: &Tokens) { @@ -235,6 +335,47 @@ mod tests { assert_eq!(h.state().session.doc().unwrap().doc.styles.para("Header").unwrap().chars.size, Some(24.0)); } + /// The Object Styles panel: the selected frame's style is marked (`+` once overridden), a + /// click applies a style, the eraser clears the overrides, + makes a style from the selection. + #[test] + fn object_styles_panel_applies_marks_overrides_and_creates() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [36, 36, 136, 136], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + app.run("style.object.create", json!({"name": "Framed", "fromSelection": false, "stroke": {"swatch": "[Paper]", "weight": 3}})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + object(app, ui); + }, + app, + ); + h.run_steps(4); + h.get_by_label("Framed").click(); + h.run_steps(3); + // The style on top and its row. + assert_eq!(h.get_all_by_label("Framed").count(), 2); + let style = |h: &Harness| h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().clone(); + assert_eq!((style(&h).object_style.as_str(), style(&h).stroke.weight), ("Framed", 3.0)); + h.state_mut().run("object.stroke", json!({"weight": 1})).unwrap(); + h.run_steps(3); + assert_eq!(h.get_all_by_label("Framed+").count(), 2); + h.get_by_label("Clear Overrides").click(); + h.run_steps(3); + assert_eq!(style(&h).stroke.weight, 3.0); + assert_eq!(h.get_all_by_label("Framed").count(), 2); + h.get_by_label("Create New Style").click(); + h.run_steps(3); + let styles = h.state().session.active().unwrap().doc.styles.object.iter().map(|o| o.name.clone()).collect::>(); + assert!(styles.contains(&"Object Style 1".to_string()), "{styles:?}"); + } + #[test] fn paragraph_color_override_is_marked_and_cleared_from_the_row_menu() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 025db568cdb16f2073a728647c0ebc278d2b4cd5 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:07:03 +0200 Subject: [PATCH 03/36] style.object.create validates the stroke weight as edit does Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/style.rs | 21 ++++++++++++++------- crates/engine/src/tests.rs | 7 +++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index e0eee77b9..0ff7c8a4f 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1515,7 +1515,7 @@ fn create_object(s: &mut Session, p: &Value) -> Result { if let Some(f) = fill_param(CREATE, p, os.fill.as_ref())? { os.fill = f; } - if let Some(st) = group_param(CREATE, p, "stroke", os.stroke.as_ref().unwrap_or(&designcraft_doc::Stroke::default()))? { + if let Some(st) = stroke_param(CREATE, p, os.stroke.as_ref())? { os.stroke = st; } if let Some(c) = corners_param(CREATE, p, os.corners.unwrap_or_default())? { @@ -1535,6 +1535,18 @@ fn create_object(s: &mut Session, p: &Value) -> Result { }) } +/// `p.stroke` merged over `base` (as [`group_param`]), refused when the weight isn't a number ≥ 0 +/// or the tint isn't finite. +fn stroke_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Stroke>) -> Result>> { + let st = group_param(cmd, p, "stroke", base.unwrap_or(&designcraft_doc::Stroke::default()))?; + if let Some(Some(st)) = &st + && !(st.weight.is_finite() && st.weight >= 0.0 && st.tint.is_finite()) + { + return Err(bad(cmd, "stroke weight must be a number ≥ 0")); + } + Ok(st) +} + /// `p[key]`, an attribute group of a style, merged field by field over `base`: `None` when not /// given, `Some(None)` for null (the style no longer sets it). fn group_param(cmd: &str, p: &Value, key: &str, base: &T) -> Result>> { @@ -1616,12 +1628,7 @@ fn edit_object(s: &mut Session, p: &Value) -> Result { if let Some(f) = fill_param(CMD, p, old.fill.as_ref())? { new.fill = f; } - if let Some(st) = group_param(CMD, p, "stroke", old.stroke.as_ref().unwrap_or(&designcraft_doc::Stroke::default()))? { - if let Some(st) = &st - && !(st.weight.is_finite() && st.weight >= 0.0 && st.tint.is_finite()) - { - return Err(bad(CMD, "stroke weight must be a number ≥ 0")); - } + if let Some(st) = stroke_param(CMD, p, old.stroke.as_ref())? { new.stroke = st; } if let Some(c) = corners_param(CMD, p, old.corners.unwrap_or_default())? { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 47bfcb523..0441f9bd9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -725,6 +725,13 @@ fn object_styles_carry_corners_and_their_stroke_and_corners_can_be_edited() { assert!(s.execute("style.object.edit", &json!({"name": "Framed", "rename": "[Basic Text Frame]"})).is_err()); assert!(s.execute("style.object.edit", &json!({"name": "[Basic Graphics Frame]", "rename": "Mine"})).is_err()); assert!(s.execute("style.object.edit", &json!({"name": "Framed", "corners": {"size": "big"}})).is_err()); + // A negative stroke weight is refused when creating a style as when editing one. + let styles = s.doc().unwrap().doc.styles.object.len(); + for cmd in ["style.object.create", "style.object.edit"] { + let e = s.execute(cmd, &json!({"name": "Framed", "stroke": {"weight": -3}})).unwrap_err(); + assert!(e.to_string().contains("stroke weight"), "{cmd}: {e}"); + } + assert_eq!(s.doc().unwrap().doc.styles.object.len(), styles); // null: the style stops setting corners, the items keep theirs. s.execute("style.object.edit", &json!({"name": "Framed", "corners": null})).unwrap(); assert!(s.doc().unwrap().doc.styles.object_style("Framed").unwrap().corners.is_none()); From b8e229a5cc692a815307609f62188e2c3e2b3e99 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:10:12 +0200 Subject: [PATCH 04/36] Object style dialog: a refused OK keeps it open with the reason; Czech terms as the catalog's Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 11 +++++- crates/ui-egui/src/i18n/cs.rs | 8 ++--- crates/ui-egui/src/object_style_dialog.rs | 42 +++++++++++++++++++++-- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 251ce9005..8be3aafb5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3576,7 +3576,16 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "alert" => Ok(Value::Null), crate::effects_dialog::ID => crate::effects_dialog::confirm(app, &d), - crate::object_style_dialog::ID => crate::object_style_dialog::confirm(app, &d), + crate::object_style_dialog::ID => { + let r = crate::object_style_dialog::confirm(app, &d); + if let Err(e) = &r { + // Keep the dialog and its edits open with the reason (an empty or taken name). + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + r + } "findChange" => { let result = if d.b("objectMode") { Ok(Value::Null) } else { app.run("find.change", find_change_params(&d)) }; // Confirmation consumes retained bounds before closing, even on an error. diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 64261a4de..b46318852 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -32,14 +32,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "Kopírovat"), ("Paste", "Vložit"), ("New Object Style", "Nový styl objektu"), - ("Object Style Options", "Volby stylu objektu"), - ("Object Style Options…", "Volby stylu objektu…"), + ("Object Style Options", "Možnosti stylu objektu"), + ("Object Style Options…", "Možnosti stylu objektu…"), ("Basic Attributes", "Základní atributy"), - ("Stroke & Corner Options", "Volby tahu a rohů"), + ("Stroke & Corner Options", "Možnosti tahu a rohů"), ("Effects for:", "Efekty pro:"), ("Apply Style to Selection", "Použít styl na výběr"), ("Create New Style", "Vytvořit nový styl"), - ("Clear Overrides", "Vymazat změny"), + ("Clear Overrides", "Zrušit přepsání"), ("Shape:", "Tvar:"), ("(no attributes)", "(žádné atributy)"), ("Enter a style name.", "Zadejte název stylu."), diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs index b9e1b3ce5..038e9b7b4 100644 --- a/crates/ui-egui/src/object_style_dialog.rs +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -99,8 +99,13 @@ pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { p.insert("fromSelection".into(), json!(false)); let r = app.run("style.object.create", Value::Object(p)).map_err(|e| e.to_string())?; let made = r["name"].as_str().unwrap_or(&name).to_string(); - if d.b("apply") && app.session.active().is_some_and(|st| !st.selection.items.is_empty()) { - app.run("style.object.apply", json!({"name": made})).map_err(|e| e.to_string())?; + // The style exists now, so OK again mustn't make a second one: a refused apply (locked + // objects) goes to the status line and the dialog closes. + if d.b("apply") + && app.session.active().is_some_and(|st| !st.selection.items.is_empty()) + && let Err(e) = app.run("style.object.apply", json!({"name": made})) + { + app.status(e.to_string()); } Ok(r) } else { @@ -324,6 +329,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { d.fields.insert("name".into(), json!(name)); } }); + // Why OK was refused (an empty or taken name), until the next try. + let status = d.s("status"); + if !status.is_empty() { + crate::rtl::label(ui, egui::RichText::new(status).color(crate::theme::Tokens::get(ui.ctx()).text_strong)); + } ui.add_space(8.0); let page = d.fields.get("page").and_then(Value::as_str).unwrap_or("general").to_string(); ui.horizontal_top(|ui| { @@ -464,4 +474,32 @@ mod tests { assert!(doc(&app).styles.object_style(name).unwrap().fill.is_none(), "Fill unchecked: no longer in the style"); assert!(doc(&app).styles.object_style(name).unwrap().effects.as_ref().unwrap().drop_shadow.on, "effects kept"); } + + /// A refused OK (an empty name, a name another style has) keeps the dialog and its edits, + /// with the reason shown. + #[test] + fn a_refused_ok_keeps_the_dialog_open_with_the_reason() { + let (mut app, _) = app_with_frame(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + app.run("style.object.create", json!({"name": "Taken", "fromSelection": false})).unwrap(); + app.run("style.object.create", json!({"name": "Mine", "fromSelection": false})).unwrap(); + open_new(&mut app); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("name".into(), json!(" ")); + set(d, &["stroke", "weight"], json!(7.0)); + } + assert!(crate::dialogs::confirm(&mut app).is_err()); + let d = app.ui.dialog.as_ref().expect("still open"); + assert_eq!(d.s("status"), "Enter a style name."); + assert_eq!(get(d, &["stroke", "weight"]), &json!(7.0), "the edits are kept"); + assert!(frame(&mut app, &ctx).iter().any(|t| t.trim() == "Enter a style name.")); + open_edit(&mut app, "Mine"); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("name".into(), json!("Taken")); + } + let e = crate::dialogs::confirm(&mut app).unwrap_err(); + assert!(e.contains("already named"), "{e}"); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.s("status") == e)); + } } From ef4bbdb7087eb98d7089a94910f89eb537fe0d1d Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:36:47 +0200 Subject: [PATCH 05/36] Object styles: a stroke tint outside 0..1 is refused Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/style.rs | 13 ++++++++----- crates/engine/src/tests.rs | 4 ++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 0ff7c8a4f..76e878277 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1536,13 +1536,16 @@ fn create_object(s: &mut Session, p: &Value) -> Result { } /// `p.stroke` merged over `base` (as [`group_param`]), refused when the weight isn't a number ≥ 0 -/// or the tint isn't finite. +/// or the tint isn't 0–1. fn stroke_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Stroke>) -> Result>> { let st = group_param(cmd, p, "stroke", base.unwrap_or(&designcraft_doc::Stroke::default()))?; - if let Some(Some(st)) = &st - && !(st.weight.is_finite() && st.weight >= 0.0 && st.tint.is_finite()) - { - return Err(bad(cmd, "stroke weight must be a number ≥ 0")); + if let Some(Some(st)) = &st { + if !(st.weight.is_finite() && st.weight >= 0.0) { + return Err(bad(cmd, "stroke weight must be a number ≥ 0")); + } + if !(0.0..=1.0).contains(&st.tint) { + return Err(bad(cmd, "stroke tint must be 0..1")); + } } Ok(st) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0441f9bd9..4770fb16a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -730,6 +730,10 @@ fn object_styles_carry_corners_and_their_stroke_and_corners_can_be_edited() { for cmd in ["style.object.create", "style.object.edit"] { let e = s.execute(cmd, &json!({"name": "Framed", "stroke": {"weight": -3}})).unwrap_err(); assert!(e.to_string().contains("stroke weight"), "{cmd}: {e}"); + for tint in [-1.0, 1.5] { + let e = s.execute(cmd, &json!({"name": "Framed", "stroke": {"tint": tint}})).unwrap_err(); + assert!(e.to_string().contains("stroke tint"), "{cmd}: {e}"); + } } assert_eq!(s.doc().unwrap().doc.styles.object.len(), styles); // null: the style stops setting corners, the items keep theirs. From 88403c5319feca34d19dca6ca99b0d003ea51105 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:47:44 +0200 Subject: [PATCH 06/36] Object styles: Clear Overrides restores the paragraph style; feather choke kept to 0..100 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/item.rs | 2 ++ crates/engine/src/cmd/style.rs | 47 ++++++++++++++++++++++++---------- crates/engine/src/tests.rs | 29 +++++++++++++++++++++ 3 files changed, 65 insertions(+), 13 deletions(-) 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); +} From f45b02fcc42ff9e92d05aa7feea30c50e5b27a5f Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:51:14 +0200 Subject: [PATCH 07/36] Object Styles panel: a story's paragraph style counts as an override; a new style with no effect on sets none Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/object_style_dialog.rs | 22 +++++++++++++++-- crates/ui-egui/src/panels/styles.rs | 29 +++++++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs index 038e9b7b4..2a9a23ad4 100644 --- a/crates/ui-egui/src/object_style_dialog.rs +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -71,10 +71,11 @@ fn included(d: &Dialog, key: &str) -> bool { d.fields.get("inc").and_then(|i| i[key].as_bool()).unwrap_or(false) } -/// Whether the style sets effects: it did, or one is on now. +/// Whether the style sets effects: one is on now, or (editing) the style set them already. A new +/// style with every effect off sets none, so applying it leaves the objects' own effects. fn sets_effects(d: &Dialog) -> bool { let fx = d.fields.get("fx").cloned().unwrap_or(Value::Null); - d.b("hadEffects") || crate::effects_dialog::PAGES.iter().skip(1).any(|(k, _)| crate::effects_dialog::is_on(&fx, k)) + (!d.b("new") && d.b("hadEffects")) || crate::effects_dialog::PAGES.iter().skip(1).any(|(k, _)| crate::effects_dialog::is_on(&fx, k)) } /// The style's attributes as the commands take them (null: not in the style). @@ -388,6 +389,23 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + /// A new style made from an object with no effect on sets no effects, so applying it keeps + /// other objects' own. + #[test] + fn a_new_style_without_effects_on_sets_none() { + let (mut app, _) = app_with_frame(); + open_new(&mut app); + let d = app.ui.dialog.clone().unwrap(); + assert!(d.b("hadEffects")); + assert_eq!(attrs(&d)["effects"], Value::Null); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("name".into(), json!("Plain")); + d.fields.insert("apply".into(), json!(false)); + } + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.session.doc().unwrap().doc.styles.object_style("Plain").unwrap().effects.is_none()); + } + fn app_with_frame() -> (DesignApp, u64) { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({})).unwrap(); diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index 0f2295340..f1c057c84 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -105,15 +105,20 @@ pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) { list(app, ui, false); } -/// The selected object's style, and whether the object differs from it (an override: `+`). +/// The selected object's style, and whether the object differs from it (an override: `+`): its +/// own attributes, or a text frame's paragraphs in another style than the object style's. fn object_style_of_selection(app: &DesignApp) -> Option<(String, bool)> { let st = app.session.active()?; let it = st.selection.items.first().and_then(|i| st.doc.item(*i))?; let mut styled = it.clone(); + let mut over = false; if let Some(os) = st.doc.styles.object_style(&it.object_style) { os.apply_to(&mut styled); + if let (Some(ps), Some(tf)) = (&os.paragraph_style, it.text_frame()) { + over = st.doc.story(tf.story).is_some_and(|s| s.paras.iter().any(|f| f.style != *ps)); + } } - Some((it.object_style.clone(), styled != *it)) + Some((it.object_style.clone(), over || styled != *it)) } /// Window › Styles › Object Styles, as InDesign's: the selected object's style on top (`+` when @@ -307,6 +312,26 @@ mod tests { use super::*; + /// A text frame whose paragraphs left its object style's paragraph style shows the override + /// mark, and Clear Overrides puts them back. + #[test] + fn a_paragraph_style_change_is_an_object_style_override() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", serde_json::json!({})).unwrap(); + app.run("style.paragraph.create", serde_json::json!({"name": "Caption"})).unwrap(); + app.run("style.object.create", serde_json::json!({"name": "Captioned", "fromSelection": false, "paragraphStyle": "Caption"})).unwrap(); + let f = app.run("frame.create", serde_json::json!({"rect": [36, 36, 300, 200], "content": "text", "text": "One"})).unwrap(); + app.run("style.object.apply", serde_json::json!({"name": "Captioned", "ids": [f["id"]]})).unwrap(); + app.run("selection.set", serde_json::json!({"ids": [f["id"]]})).unwrap(); + assert_eq!(object_style_of_selection(&app), Some(("Captioned".into(), false))); + app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 3})).unwrap(); + app.run("style.paragraph.apply", serde_json::json!({"name": designcraft_doc::BASIC_PARAGRAPH})).unwrap(); + app.run("selection.set", serde_json::json!({"ids": [f["id"]]})).unwrap(); + assert_eq!(object_style_of_selection(&app), Some(("Captioned".into(), true)), "the + mark"); + app.run("style.object.clearOverrides", serde_json::json!({})).unwrap(); + assert_eq!(object_style_of_selection(&app), Some(("Captioned".into(), false))); + } + #[test] fn selected_style_context_menu_redefines_its_format() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From fd96b3700fd84d3b05608287bb9c8a1937ffe0c7 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:28:36 +0200 Subject: [PATCH 08/36] Object styles: Paragraph Styles (with Apply Next Style) and Text Frame General Options The course's chapter 39 (Nested Object Styles). Object Style Options gets InDesign's Paragraph Styles page: the paragraph style a text frame's text takes when the object style is applied, and Apply Next Style, which gives the first paragraph that style and each paragraph after it the Next Style of the one before. Its Text Frame General Options page is the Text Frame Options dialog's General page: columns, insets, vertical justification and Ignore Text Wrap. `style.object.create` and `style.object.edit` take `paragraphStyle`, `applyNextStyle` and `textFrame`. Editing moves the frames that still follow the style; text in another paragraph style is an override, which the panel marks and Clear Overrides puts back. IDML reads and writes `ApplyNextParagraphStyle`. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/styles.rs | 43 ++++- crates/engine/src/cmd/style.rs | 127 ++++++++++++-- crates/engine/src/tests.rs | 70 ++++++++ crates/idml/src/export.rs | 1 + crates/idml/src/import.rs | 3 +- crates/idml/src/tests/object_styles.rs | 28 +++ crates/ui-egui/src/dialogs.rs | 30 ++-- crates/ui-egui/src/i18n.rs | 14 ++ crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/object_style_dialog.rs | 200 +++++++++++++++++++++- crates/ui-egui/src/panels/styles.rs | 6 +- 21 files changed, 501 insertions(+), 42 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e4cf4399e..90169f585 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Object styles nest a paragraph style, as InDesign's: Object Style Options › Paragraph Styles chooses the style a text frame's text takes when the object style is applied, and Apply Next Style gives the first paragraph that style and each paragraph after it the Next Style of the one before (`style.object.create` / `edit`: `paragraphStyle`, `applyNextStyle`; IDML `ApplyNextParagraphStyle`). Object Style Options › Text Frame General Options sets columns, insets, vertical justification and Ignore Text Wrap (`textFrame`). Editing either moves the frames that still follow the style; text in another paragraph style is an override that Clear Overrides puts back. Found in the Learn Skills Daily course, chapter 39. No readiness percentage change. - **2026-10-21** · Window › Styles › Object Styles: InDesign's Object Styles panel (the selected object's style, `+` when overridden; a click applies a style, a double-click opens its options; the default graphic and text frame styles marked; Clear Overrides; Create New Style, ⌥/Alt-click for the dialog) and its New Object Style / Object Style Options dialog (Style Name; Basic Attributes Fill, Stroke, Stroke & Corner Options, each included by its checkbox; Effects for Object with the effects' pages; Style Settings; Apply Style to Selection). UI command `objectStyle.dialog`. Found in the Learn Skills Daily course, chapter 15. No readiness percentage change. - **2026-10-21** · Object styles carry Corner Options (`corners`), taken from the selection by `style.object.create` and written to IDML (`EnableStrokeAndCornerOptions`, `TopLeftCornerOption`…). `style.object.edit` also changes a style's fill, stroke and corners and renames it (`rename`); the items using the style that haven't overridden an attribute follow each change, as in InDesign. Found in the Learn Skills Daily course, chapter 15. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 380f0fd3a..14bec3776 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use crate::attrs::{CharAttrs, CharProps, ParaAttrs, ParaProps}; use crate::item::{Effects, Fill, Item, Stroke, TextFrameOptions}; -use crate::story::{BASIC_PARAGRAPH, CharFormat, NO_CHAR_STYLE, ParaFormat}; +use crate::story::{BASIC_PARAGRAPH, CharFormat, NO_CHAR_STYLE, ParaFormat, Story}; pub const NO_PARA_STYLE: &str = "[No Paragraph Style]"; pub const BASIC_GRAPHICS_FRAME: &str = "[Basic Graphics Frame]"; @@ -54,6 +54,10 @@ pub struct ObjectStyle { pub stroke: Option, #[serde(skip_serializing_if = "Option::is_none")] pub paragraph_style: Option, + /// Paragraph Styles › Apply Next Style: the paragraph style goes on a text frame's first + /// paragraph, and each paragraph after it takes the Next Style of the one before. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub apply_next_style: bool, #[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 @@ -84,6 +88,43 @@ impl ObjectStyle { tf.options = o.clone(); } } + + /// The paragraph style of each of a text frame's `n` paragraphs under this style: its + /// paragraph style for all of them, or with Apply Next Style that one first and then each + /// one's Next Style. `None` when the style sets no paragraph style. + pub fn paragraph_styles(&self, styles: &Styles, n: usize) -> Option> { + let mut cur = self.paragraph_style.clone()?; + let mut out = Vec::with_capacity(n); + for _ in 0..n { + out.push(cur.clone()); + if self.apply_next_style + && let Some(next) = styles.para(&cur).and_then(|s| s.next_style.clone()) + { + cur = next; + } + } + Some(out) + } + + /// Whether a story's paragraphs are in the paragraph styles this style gives them (they + /// always are when it sets none). + pub fn story_follows(&self, styles: &Styles, st: &Story) -> bool { + self.paragraph_styles(styles, st.paras.len()).is_none_or(|want| st.paras.iter().zip(&want).all(|(f, w)| f.style == *w)) + } + + /// Put a story's paragraphs in the paragraph styles this style gives them; `false` when it + /// sets none or they already are. + pub fn restyle(&self, styles: &Styles, st: &mut Story) -> bool { + if self.story_follows(styles, st) { + return false; + } + let Some(want) = self.paragraph_styles(styles, st.paras.len()) else { return false }; + for (f, w) in st.paras.iter_mut().zip(want) { + f.style = w; + } + st.rev = st.rev.wrapping_add(1); + true + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 7bcdcbb1d..412d97a14 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -359,7 +359,7 @@ pub fn specs() -> Vec { "New Object Style…", [], None, - "{name, fromSelection?: true (fill, stroke, corners, effects and text frame options of the first selected item), fill?: swatch | {swatch?, tint?}, stroke?: {swatch?, weight?, tint?, type?, …}, corners?: {shape?, size?} | {corners: [{shape, size}] ×4}, 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)", + "{name, fromSelection?: true (fill, stroke, corners, effects and text frame options of the first selected item), fill?: swatch | {swatch?, tint?}, stroke?: {swatch?, weight?, tint?, type?, …}, corners?: {shape?, size?} | {corners: [{shape, size}] ×4}, paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns?, columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right], verticalJustification?, ignoreWrap?, …} | null (the options of `document.inspect` text frames; null: the style sets none), 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 ), @@ -368,7 +368,7 @@ pub fn specs() -> Vec { "Object Style Options…", [], None, - "{name, rename?: new name (every use follows), fill?: swatch | {swatch?, tint?} | null, stroke?: {swatch?, weight?, tint?, type?, …} | null, corners?: {shape?: none|rounded|inverseRounded|inset|bevel|fancy, size?} | {corners: [{shape, size}] ×4} | null (null: the style no longer sets it; items keep theirs), 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 (fill, stroke, corners) match the style's (an item's defaults, when the style set none) take the new ones, items with their own keep them → {name, updated}", + "{name, rename?: new name (every use follows), fill?: swatch | {swatch?, tint?} | null, stroke?: {swatch?, weight?, tint?, type?, …} | null, corners?: {shape?: none|rounded|inverseRounded|inset|bevel|fancy, size?} | {corners: [{shape, size}] ×4} | null (null: the style no longer sets it; items keep theirs), paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns?, columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right], verticalJustification?, ignoreWrap?, …} | null (the options of `document.inspect` text frames; null: the style sets none), 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 (fill, stroke, corners) match the style's (an item's defaults, when the style set none) take the new ones, items with their own keep them → {name, updated}", has_doc, edit_object ), @@ -1416,12 +1416,11 @@ fn apply_object(s: &mut Session, p: &Value) -> Result { 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()); - } + // The text takes the style's paragraph style (with Apply Next Style, a chain of them). + let styles = d.styles.clone(); + for sid in stories { + if let Some(st) = d.story_mut(sid) { + os.restyle(&styles, st); } } Ok(Value::Null) @@ -1434,7 +1433,7 @@ fn clear_object_overrides(s: &mut Session, p: &Value) -> Result { // and a text frame's story back in the style's paragraph style, as applying the style does. let d = &s.doc()?.doc; let mut changed: Vec = Vec::new(); - let mut restyled: Vec<(designcraft_doc::StoryId, String)> = Vec::new(); + let mut restyled: Vec<(designcraft_doc::StoryId, ObjectStyle)> = Vec::new(); let mut n = 0; for id in &ids { let Some(it) = d.item(*id) else { continue }; @@ -1446,12 +1445,12 @@ fn clear_object_overrides(s: &mut Session, p: &Value) -> Result { 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)) + if let Some(tf) = it.text_frame() + && d.story(tf.story).is_some_and(|st| !os.story_follows(&d.styles, st)) { story_changed = true; if !restyled.iter().any(|(sid, _)| *sid == tf.story) { - restyled.push((tf.story, ps.clone())); + restyled.push((tf.story, os.clone())); } } n += usize::from(item_changed || story_changed); @@ -1465,10 +1464,10 @@ fn clear_object_overrides(s: &mut Session, p: &Value) -> Result { *it = next.clone(); } } - for (sid, ps) in &restyled { + let styles = d.styles.clone(); + for (sid, os) in &restyled { if let Some(st) = d.story_mut(*sid) { - let len = st.len(); - st.format_paras(0..len, |f| f.style = ps.clone()); + os.restyle(&styles, st); } } Ok(json!({"changed": n})) @@ -1542,8 +1541,14 @@ fn create_object(s: &mut Session, p: &Value) -> Result { if let Some(c) = corners_param(CREATE, p, os.corners.unwrap_or_default())? { os.corners = c; } - if let Some(ps) = str_param(p, "paragraphStyle") { - os.paragraph_style = Some(ps.to_string()); + if let Some(ps) = paragraph_style_param(CREATE, s, p)? { + os.paragraph_style = ps; + } + if let Some(next) = bool_param(CREATE, p, "applyNextStyle")? { + os.apply_next_style = next; + } + if let Some(tf) = text_frame_param(CREATE, p, os.text_frame.as_ref())? { + os.text_frame = tf; } if let Some(e) = effects_param("style.object.create", p, os.effects.as_ref().unwrap_or(&Effects::default()))? { os.effects = Some(e); @@ -1556,6 +1561,46 @@ fn create_object(s: &mut Session, p: &Value) -> Result { }) } +/// `paragraphStyle`: a paragraph style's name, or null (the object style sets none). +fn paragraph_style_param(cmd: &str, s: &Session, p: &Value) -> Result>> { + match p.get("paragraphStyle") { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(Value::String(n)) if s.doc()?.doc.styles.para(n).is_some() => Ok(Some(Some(n.clone()))), + Some(Value::String(n)) => Err(bad(cmd, format!("no paragraph style `{n}`"))), + Some(_) => Err(bad(cmd, "`paragraphStyle` must be a paragraph style's name or null")), + } +} + +/// A true/false parameter, when given. +fn bool_param(cmd: &str, p: &Value, key: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(bad(cmd, format!("`{key}` must be true or false"))), + } +} + +/// `textFrame` (a text frame's options, as `document.inspect` shows them) merged over `base`, or +/// null; refused when the columns aren't 1–40 or a gutter, width or inset isn't 0–15552 pt. +fn text_frame_param( + cmd: &str, + p: &Value, + base: Option<&designcraft_doc::TextFrameOptions>, +) -> Result>> { + let tf = group_param(cmd, p, "textFrame", base.unwrap_or(&designcraft_doc::TextFrameOptions::default()))?; + if let Some(Some(o)) = &tf { + if !(1..=40).contains(&o.columns) { + return Err(bad(cmd, "text frame columns must be 1–40")); + } + let mut lengths = [o.gutter, o.column_width, o.vj_paragraph_spacing_limit, o.first_baseline_min].into_iter().chain(o.inset); + if !lengths.all(|v| v.is_finite() && (0.0..=15552.0).contains(&v)) { + return Err(bad(cmd, "a text frame's gutter, widths and insets must be 0–15552 pt")); + } + } + Ok(tf) +} + /// `p.stroke` merged over `base` (as [`group_param`]), refused when the weight isn't a number ≥ 0 /// or the tint isn't 0–1. fn stroke_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Stroke>) -> Result>> { @@ -1658,6 +1703,15 @@ fn edit_object(s: &mut Session, p: &Value) -> Result { if let Some(c) = corners_param(CMD, p, old.corners.unwrap_or_default())? { new.corners = c; } + if let Some(ps) = paragraph_style_param(CMD, s, p)? { + new.paragraph_style = ps; + } + if let Some(next) = bool_param(CMD, p, "applyNextStyle")? { + new.apply_next_style = next; + } + if let Some(tf) = text_frame_param(CMD, p, old.text_frame.as_ref())? { + new.text_frame = tf; + } let rename = str_param(p, "rename").map(str::trim).filter(|r| *r != name).map(str::to_string); if let Some(r) = &rename { let builtin = name.starts_with('[') && name.ends_with(']'); @@ -1701,11 +1755,14 @@ fn edit_object(s: &mut Session, p: &Value) -> Result { .flatten() .map(|f| (old.stroke.clone().unwrap_or_else(designcraft_doc::Stroke::none), f)); let corners = (new.corners != old.corners).then_some(new.corners).flatten().map(|c| (old.corners.unwrap_or_default(), c)); + let frame = + (new.text_frame != old.text_frame).then_some(new.text_frame.clone()).flatten().map(|t| (old.text_frame.clone().unwrap_or_default(), t)); let follows = |it: &Item| { 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) + || frame.as_ref().is_some_and(|(o, _)| it.text_frame().is_some_and(|t| t.options == *o)) }; let updated = d.update_items( |it| it.object_style == new.name && follows(it), @@ -1730,9 +1787,43 @@ fn edit_object(s: &mut Session, p: &Value) -> Result { { it.corners = *n; } + if let Some((o, n)) = &frame + && let Some(tf) = it.text_frame_mut() + && tf.options == *o + { + tf.options = n.clone(); + } }, ); - Ok(json!({"name": new.name, "updated": updated})) + // The text of the frames using the style takes a new paragraph style (or chain) while it + // is in the old one's; when the style set none before, all of it does. + let mut restyled = 0; + if new.paragraph_style.is_some() && (new.paragraph_style != old.paragraph_style || new.apply_next_style != old.apply_next_style) { + let mut stories = Vec::new(); + for sp in d.spreads.iter().chain(d.parents.iter()) { + for it in &sp.items { + it.walk(&mut |i| { + if i.object_style == new.name + && let Some(tf) = i.text_frame() + { + stories.push(tf.story); + } + }); + } + } + stories.sort_unstable(); + stories.dedup(); + let styles = d.styles.clone(); + for sid in stories { + if d.story(sid).is_some_and(|st| old.story_follows(&styles, st)) + && let Some(st) = d.story_mut(sid) + && new.restyle(&styles, st) + { + restyled += 1; + } + } + } + Ok(json!({"name": new.name, "updated": updated, "restyled": restyled})) }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6471eb131..a2ac00a60 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -745,6 +745,76 @@ fn object_styles_carry_corners_and_their_stroke_and_corners_can_be_edited() { assert_eq!(item(&s, a).object_style, name); } +/// The course's chapter 39 (nested object styles): an object style carries a paragraph style, +/// with Apply Next Style a chain of them, and text frame options. Applying it styles the frame's +/// text; editing it moves the frames that still follow it, not one whose text took another style, +/// which Clear Overrides puts back. +#[test] +fn object_styles_carry_a_paragraph_style_and_text_frame_options() { + use designcraft_doc::ItemId; + let mut s = session(); + s.execute("style.paragraph.create", &json!({"name": "Body Text"})).unwrap(); + s.execute( + "style.paragraph.create", + &json!({"name": "Drop Cap First Paragraph", "nextStyle": "Body Text", "para": {"dropCapLines": 3, "dropCapChars": 1}}), + ) + .unwrap(); + let name = "Text Box Drop Cap"; + s.execute( + "style.object.create", + &json!({"name": name, "fromSelection": false, "paragraphStyle": "Drop Cap First Paragraph", "applyNextStyle": true, "textFrame": {"inset": [4, 4, 4, 4]}}), + ) + .unwrap(); + let frame = |s: &mut Session, x: f64, text: &str| { + let r = s.execute("frame.create", &json!({"rect": [x, 36, x + 200.0, 300], "content": "text", "text": text, "caret": false})).unwrap(); + (r["id"].as_u64().unwrap(), r["story"].clone()) + }; + let ((a, _), (b, b_story)) = (frame(&mut s, 36.0, "One\nTwo\nThree"), frame(&mut s, 300.0, "Alone")); + s.execute("style.object.apply", &json!({"name": name, "ids": [a, b]})).unwrap(); + let styles_of = |s: &Session, id: u64| { + let d = &s.doc().unwrap().doc; + let sid = d.item(ItemId(id)).unwrap().text_frame().unwrap().story; + d.story(sid).unwrap().paras.iter().map(|p| p.style.clone()).collect::>() + }; + let options = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().text_frame().unwrap().options.clone(); + assert_eq!(styles_of(&s, a), ["Drop Cap First Paragraph", "Body Text", "Body Text"], "Apply Next Style"); + assert_eq!(styles_of(&s, b), ["Drop Cap First Paragraph"]); + assert_eq!(options(&s, a).inset, [4.0; 4]); + + // b's text takes another paragraph style: its own, kept when the object style changes. + s.execute("text.select", &json!({"story": b_story, "anchor": 0, "focus": 5})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Body Text"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Box Body Text"})).unwrap(); + let r = s + .execute("style.object.edit", &json!({"name": name, "paragraphStyle": "Box Body Text", "applyNextStyle": false, "textFrame": {"columns": 2}})) + .unwrap(); + assert_eq!((r["updated"].as_u64(), r["restyled"].as_u64()), (Some(2), Some(1))); + assert_eq!(styles_of(&s, a), ["Box Body Text"; 3]); + assert_eq!(styles_of(&s, b), ["Body Text"], "its own style stays"); + assert_eq!((options(&s, a).columns, options(&s, a).inset, options(&s, b).columns), (2, [4.0; 4], 2)); + s.execute("style.object.clearOverrides", &json!({"ids": [b]})).unwrap(); + assert_eq!(styles_of(&s, b), ["Box Body Text"]); + + // Refused: an unknown paragraph style, no columns, a negative inset, a word for a switch. + for bad in [ + json!({"paragraphStyle": "Nope"}), + json!({"paragraphStyle": 3}), + json!({"textFrame": {"columns": 0}}), + json!({"textFrame": {"inset": [-1, 0, 0, 0]}}), + json!({"applyNextStyle": "yes"}), + ] { + let mut p = bad.clone(); + p["name"] = json!(name); + assert!(s.execute("style.object.edit", &p).is_err(), "{bad}"); + assert!(s.execute("style.object.create", &p).is_err(), "{bad}"); + } + // null: the style no longer sets a paragraph style or frame options; the frames keep theirs. + s.execute("style.object.edit", &json!({"name": name, "paragraphStyle": null, "textFrame": null})).unwrap(); + let os = s.doc().unwrap().doc.styles.object_style(name).cloned().unwrap(); + assert!(os.paragraph_style.is_none() && os.text_frame.is_none()); + assert_eq!((styles_of(&s, a), options(&s, a).columns), (vec!["Box Body Text".to_string(); 3], 2)); +} + #[test] fn object_style_effects_reach_grouped_and_anchored_items() { use designcraft_doc::ItemId; diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index e29a6838e..ef3f0e683 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1074,6 +1074,7 @@ impl<'a> Ex<'a> { } if let Some(ps) = &os.paragraph_style { el.set("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, ps)); + el.set("ApplyNextParagraphStyle", bool_s(os.apply_next_style)); } let mut el = with_props(el, props); if let Some(e) = &os.effects { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 6160bb599..bef706ba3 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1275,7 +1275,8 @@ impl<'r> Importer<'r> { // in a rectangle's path order. let corners = (e.get("EnableStrokeAndCornerOptions") != Some("false")).then(|| corners(e, "")).flatten().map(|c| CornerOptions { corners: c }); - let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, text_frame, effects, corners }; + 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) { Some(slot) => *slot = s, None => self.styles.object.push(s), diff --git a/crates/idml/src/tests/object_styles.rs b/crates/idml/src/tests/object_styles.rs index 4e7f5ef5d..f445c7f41 100644 --- a/crates/idml/src/tests/object_styles.rs +++ b/crates/idml/src/tests/object_styles.rs @@ -149,6 +149,34 @@ fn item_overrides_and_disabled_categories_turn_style_effects_off() { assert!((ds.opacity - 0.75).abs() < 1e-6); } +/// An object style's paragraph style and Apply Next Style (`ApplyNextParagraphStyle`) round-trip. +#[test] +fn object_style_apply_next_style_round_trips() { + let mut d = small_doc(); + let styles = std::sync::Arc::make_mut(&mut d.styles); + styles.paragraph.push(designcraft_doc::ParagraphStyle { + name: "Opener".into(), + based_on: None, + next_style: Some(designcraft_doc::BASIC_PARAGRAPH.into()), + para: Default::default(), + chars: Default::default(), + shortcut: String::new(), + }); + for (name, next) in [("Chained", true), ("Single", false)] { + styles.object.push(designcraft_doc::ObjectStyle { + name: name.into(), + paragraph_style: Some("Opener".into()), + apply_next_style: next, + ..Default::default() + }); + } + let back = import_idml(&export_idml(&d)).unwrap(); + for (name, next) in [("Chained", true), ("Single", false)] { + let os = back.styles.object_style(name).unwrap(); + assert_eq!((os.paragraph_style.as_deref(), os.apply_next_style), (Some("Opener"), next), "{name}"); + } +} + #[test] fn exported_frames_keep_their_own_values_over_their_object_style() { let mut d = small_doc(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8be3aafb5..762cfcb05 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -103,7 +103,7 @@ impl Dialog { None => String::new(), } } - fn m(&self, k: &str) -> Option { + pub(crate) fn m(&self, k: &str) -> Option { match self.fields.get(k) { Some(Value::Number(n)) => n.as_f64(), Some(Value::String(s)) => parse_measure(s, self.unit).ok(), @@ -511,16 +511,29 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { } let o = selected_frame_options(app).unwrap_or_default(); let units = app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas); + let shown = text_frame_fields(&o, units, selected_frame_width(app)); + if let Value::Object(m) = &shown { + for (k, v) in m { + d.fields.entry(k.clone()).or_insert_with(|| v.clone()); + } + } + d.fields.entry("insetLinked".to_string()).or_insert_with(|| json!(o.inset.iter().all(|v| (v - o.inset[0]).abs() < 1e-9))); + d.fields.insert("_shown".into(), shown); +} + +/// Text frame options `o` as the Text Frame Options dialog's fields, measures in `units`; with the +/// frame's width, a Fixed Number column shows the width it gets. +pub(crate) fn text_frame_fields(o: &designcraft_doc::TextFrameOptions, units: Unit, frame_width: Option) -> Value { let fm = |v: f64| json!(format_measure(v, units)); // Fixed Number: the width each column gets in the frame (shown, as in InDesign). - let inner = selected_frame_width(app).map(|w| w - o.inset[1] - o.inset[3]).unwrap_or(0.0); + let inner = frame_width.map(|w| w - o.inset[1] - o.inset[3]).unwrap_or(0.0); let n = o.columns.max(1) as f64; let width = match o.columns_kind { designcraft_doc::ColumnsKind::FixedWidth if o.column_width > 0.0 => o.column_width, _ => ((inner - o.gutter * (n - 1.0)) / n).max(0.0), }; let kind = |v: Value| v.as_str().unwrap_or_default().to_string(); - let shown = json!({ + json!({ "columns": o.columns, "gutter": fm(o.gutter), "columnsKind": kind(serde_json::to_value(o.columns_kind).unwrap_or_default()), "columnWidth": fm(width), "balanceColumns": o.balance_columns, "ignoreWrap": o.ignore_wrap, @@ -535,14 +548,7 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { "useMinimumHeight": o.auto_size_use_min_height, "minimumHeight": fm(o.auto_size_min_height), "useMinimumWidth": o.auto_size_use_min_width, "minimumWidth": fm(o.auto_size_min_width), "noLineBreaks": o.auto_size_no_line_breaks, - }); - if let Value::Object(m) = &shown { - for (k, v) in m { - d.fields.entry(k.clone()).or_insert_with(|| v.clone()); - } - } - d.fields.entry("insetLinked".to_string()).or_insert_with(|| json!(o.inset.iter().all(|v| (v - o.inset[0]).abs() < 1e-9))); - d.fields.insert("_shown".into(), shown); + }) } /// A 0..1 tint as a percentage ("40", "12.5"). @@ -595,7 +601,7 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) } /// Text Frame Options › General: columns, inset spacing, vertical justification, text wrap. -fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { +pub(crate) fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { let tr = |s: &'static str| crate::i18n::tr(lang, s); crate::rtl::label(ui, egui::RichText::new(tr("Columns")).font(semibold(12.0))); egui::Grid::new("tfo_cols").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 72d756c7b..7a4683f6f 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1034,6 +1034,20 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ( + "Apply Next Style", + ["Nächstes Format anwenden", "Appliquer le style suivant", "Aplicar estilo siguiente", "次のスタイルを適用", "应用下一样式"], + ), + ( + "Text Frame General Options", + [ + "Allgemeine Textrahmenoptionen", + "Options générales du bloc de texte", + "Opciones generales de marco de texto", + "テキストフレーム一般オプション", + "文本框架常规选项", + ], + ), ( "Differs from this document", ["Weicht von diesem Dokument ab", "Diffère de ce document", "Difiere de este documento", "このドキュメントと異なります", "与此文档不同"], diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 1872dd73c..0f0764a47 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Apply Next Style", "تطبيق النمط التالي"), + ("Text Frame General Options", "خيارات عامة لإطار النص"), ("New Object Style", "نمط كائن جديد"), ("Object Style Options", "خيارات نمط الكائن"), ("Object Style Options…", "خيارات نمط الكائن…"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index b46318852..70530a249 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Apply Next Style", "Použít další styl"), + ("Text Frame General Options", "Obecné volby textového rámečku"), ("New Object Style", "Nový styl objektu"), ("Object Style Options", "Možnosti stylu objektu"), ("Object Style Options…", "Možnosti stylu objektu…"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index efc128858..38c2a409d 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Apply Next Style", "Nächstes Format anwenden"), + ("Text Frame General Options", "Allgemeine Textrahmenoptionen"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index ab7a6840f..7835da9dc 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Apply Next Style", "Applica stile successivo"), + ("Text Frame General Options", "Opzioni generali cornice di testo"), ("New Object Style", "Nuovo stile oggetto"), ("Object Style Options", "Opzioni stile oggetto"), ("Object Style Options…", "Opzioni stile oggetto…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5647ca940..ece8f73e9 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Apply Next Style", "次のスタイルを適用"), + ("Text Frame General Options", "テキストフレーム一般オプション"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index fe735f4d7..655dd5363 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Apply Next Style", "შემდეგი სტილის გამოყენება"), + ("Text Frame General Options", "ტექსტის ჩარჩოს ზოგადი პარამეტრები"), ("New Object Style", "ახალი ობიექტის სტილი"), ("Object Style Options", "ობიექტის სტილის პარამეტრები"), ("Object Style Options…", "ობიექტის სტილის პარამეტრები…"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index f0f7f58a1..9543ff5a7 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Apply Next Style", "Zastosuj następny styl"), + ("Text Frame General Options", "Ogólne opcje ramki tekstowej"), ("New Object Style", "Nowy styl obiektu"), ("Object Style Options", "Opcje stylu obiektu"), ("Object Style Options…", "Opcje stylu obiektu…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index b922a37c9..721b08f98 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Apply Next Style", "Aplicar próximo estilo"), + ("Text Frame General Options", "Opções gerais do quadro de texto"), ("New Object Style", "Novo estilo de objeto"), ("Object Style Options", "Opções de estilo de objeto"), ("Object Style Options…", "Opções de estilo de objeto…"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index d8630b712..e8334fd61 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Apply Next Style", "Применить следующий стиль"), + ("Text Frame General Options", "Общие параметры текстового фрейма"), ("New Object Style", "Новый стиль объекта"), ("Object Style Options", "Параметры стиля объекта"), ("Object Style Options…", "Параметры стиля объекта…"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 242cb0d1e..cefcad96a 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Apply Next Style", "Застосувати наступний стиль"), + ("Text Frame General Options", "Загальні параметри текстового фрейму"), ("New Object Style", "Новий стиль об'єкта"), ("Object Style Options", "Параметри стилю об'єкта"), ("Object Style Options…", "Параметри стилю об'єкта…"), diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs index 2a9a23ad4..0b4055f7d 100644 --- a/crates/ui-egui/src/object_style_dialog.rs +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -2,6 +2,9 @@ //! InDesign's: the style's name on top; on the left its Basic Attributes (a checkbox includes one //! in the style, a click shows its settings) and its effects; on the right the settings of the //! attribute shown (General sums the style up); Apply Style to Selection for a new style. +//! +//! Paragraph Styles nests a paragraph style in the object style (the course's chapter 39), and +//! Text Frame General Options is the Text Frame Options dialog's General page. use serde_json::{Value, json}; @@ -12,8 +15,30 @@ use crate::theme::semibold; /// The dialog's id. pub const ID: &str = "objectStyle"; -/// Basic Attributes: (key, label). General isn't an attribute: it sums the style up. -const ATTRIBUTES: [(&str, &str); 4] = [("general", "General"), ("fill", "Fill"), ("stroke", "Stroke"), ("corners", "Stroke & Corner Options")]; +/// Basic Attributes: (key, label), in InDesign's order. General isn't an attribute: it sums the +/// style up. +const ATTRIBUTES: [(&str, &str); 6] = [ + ("general", "General"), + ("fill", "Fill"), + ("stroke", "Stroke"), + ("corners", "Stroke & Corner Options"), + ("paragraph", "Paragraph Styles"), + ("textFrame", "Text Frame General Options"), +]; + +/// The fields of the Paragraph Styles and Text Frame General Options pages: the style's paragraph +/// style (`""`: none), Apply Next Style, and text frame options `tf` as the Text Frame Options +/// dialog shows them (`tfBase` keeps the options the page doesn't show). +fn text_fields(app: &DesignApp, fields: &mut Value, paragraph: Option<&str>, next: bool, tf: &designcraft_doc::TextFrameOptions) { + let units = app.session.active().map_or(designcraft_geom::Unit::Picas, |s| s.doc.settings.horizontal_units); + fields["paragraphStyle"] = json!(paragraph.unwrap_or("")); + fields["applyNext"] = json!(next); + fields["tfBase"] = serde_json::to_value(tf).unwrap_or_default(); + fields["insetLinked"] = json!(tf.inset.iter().all(|v| (v - tf.inset[0]).abs() < 1e-9)); + if let (Value::Object(f), Value::Object(shown)) = (fields, crate::dialogs::text_frame_fields(tf, units, None)) { + f.extend(shown); + } +} /// The dialog for a new style from the selected object (its fill, stroke, corners and effects, /// as InDesign's New Object Style), named "Object Style n". @@ -23,7 +48,9 @@ pub fn open_new(app: &mut DesignApp) { let taken = |n: &str| st.doc.styles.object_style(n).is_some(); let name = (1..).map(|i| format!("Object Style {i}")).find(|n| !taken(n)).unwrap_or_default(); let from = it.is_some(); - let fields = json!({ + // A text frame's options come along; its paragraph style is left to choose, as in InDesign. + let frame = it.as_ref().and_then(|i| i.text_frame()).map(|t| t.options.clone()); + let mut fields = json!({ "new": true, "name": name, "basedOn": designcraft_doc::NO_OBJECT_STYLE, @@ -32,10 +59,11 @@ pub fn open_new(app: &mut DesignApp) { "corners": it.as_ref().map(|i| i.corners).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}, + "inc": {"fill": from, "stroke": from, "corners": from, "paragraph": from, "textFrame": frame.is_some()}, "page": "general", "apply": from, }); + text_fields(app, &mut fields, None, false, &frame.unwrap_or_default()); app.ui.dialog = Some(Dialog::new(ID, fields)); } @@ -45,7 +73,7 @@ pub fn open_edit(app: &mut DesignApp, name: &str) { if name == designcraft_doc::NO_OBJECT_STYLE { return; } - let fields = json!({ + let mut fields = json!({ "new": false, "orig": name, "name": name, @@ -55,10 +83,14 @@ pub fn open_edit(app: &mut DesignApp, name: &str) { "corners": os.corners.unwrap_or_default(), "fx": crate::effects_dialog::effects_json(&os.effects.clone().unwrap_or_default()), "hadEffects": os.effects.is_some(), - "inc": {"fill": os.fill.is_some(), "stroke": os.stroke.is_some(), "corners": os.corners.is_some()}, + "inc": { + "fill": os.fill.is_some(), "stroke": os.stroke.is_some(), "corners": os.corners.is_some(), + "paragraph": os.paragraph_style.is_some(), "textFrame": os.text_frame.is_some(), + }, "page": "general", "apply": false, }); + text_fields(app, &mut fields, os.paragraph_style.as_deref(), os.apply_next_style, &os.text_frame.clone().unwrap_or_default()); app.ui.dialog = Some(Dialog::new(ID, fields)); } @@ -85,9 +117,36 @@ fn attrs(d: &Dialog) -> serde_json::Map { p.insert(k.into(), if included(d, k) { d.fields.get(k).cloned().unwrap_or(Value::Null) } else { Value::Null }); } p.insert("effects".into(), if sets_effects(d) { d.fields.get("fx").cloned().unwrap_or(Value::Null) } else { Value::Null }); + let paragraph = d.s("paragraphStyle"); + p.insert("paragraphStyle".into(), if included(d, "paragraph") && !paragraph.is_empty() { json!(paragraph) } else { Value::Null }); + p.insert("applyNextStyle".into(), json!(d.b("applyNext"))); p } +/// The Text Frame General Options page as the style's text frame options (the options it doesn't +/// show as they were), or `None` when the style doesn't set them. +fn frame_options(d: &Dialog) -> Result, String> { + if !included(d, "textFrame") { + return Ok(None); + } + let mut o: designcraft_doc::TextFrameOptions = d.fields.get("tfBase").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let bad = |what: &str| format!("Text Frame General Options: {what}"); + let measure = |key: &str| d.m(key).filter(|v| v.is_finite() && (0.0..=15552.0).contains(v)); + o.columns = d.n("columns").filter(|n| (1.0..=40.0).contains(n)).ok_or_else(|| bad("the number of columns is 1–40"))? as u32; + o.gutter = measure("gutter").ok_or_else(|| bad("the gutter is not a measure"))?; + o.columns_kind = serde_json::from_value(json!(d.s("columnsKind"))).unwrap_or(o.columns_kind); + if o.columns_kind == designcraft_doc::ColumnsKind::FixedWidth { + o.column_width = measure("columnWidth").ok_or_else(|| bad("the column width is not a measure"))?; + } + o.balance_columns = d.b("balanceColumns"); + for (side, key) in o.inset.iter_mut().zip(["insetTop", "insetLeft", "insetBottom", "insetRight"]) { + *side = measure(key).ok_or_else(|| bad("an inset is not a measure"))?; + } + o.vertical_justification = serde_json::from_value(json!(d.s("verticalJustification"))).unwrap_or(o.vertical_justification); + o.ignore_wrap = d.b("ignoreWrap"); + Ok(Some(o)) +} + /// OK: the new style (applied to the selection when asked), or the edited one. pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { let name = d.s("name").trim().to_string(); @@ -95,6 +154,7 @@ pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { return Err(crate::i18n::tr(&app.ui.language, "Enter a style name.").to_string()); } let mut p = attrs(d); + p.insert("textFrame".into(), frame_options(d)?.map_or(Value::Null, |o| serde_json::to_value(o).unwrap_or_default())); if d.b("new") { p.insert("name".into(), json!(name)); p.insert("fromSelection".into(), json!(false)); @@ -119,6 +179,10 @@ pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { if p.get("effects").is_some_and(Value::is_null) && !d.b("hadEffects") { p.remove("effects"); } + // Apply Next Style means nothing without a paragraph style. + if p.get("paragraphStyle").is_some_and(Value::is_null) { + p.remove("applyNextStyle"); + } app.run("style.object.edit", Value::Object(p)).map_err(|e| e.to_string()) } } @@ -217,6 +281,24 @@ fn settings(app: &DesignApp, d: &Dialog) -> Vec { let shape: designcraft_geom::corners::CornerShape = serde_json::from_value(c["shape"].clone()).unwrap_or_default(); out.push(format!("{}: {} {} pt", tr("Corner Options"), tr(shape.label()), c["size"].as_f64().unwrap_or(0.0))); } + let paragraph = d.s("paragraphStyle"); + if included(d, "paragraph") && !paragraph.is_empty() { + let next = if d.b("applyNext") { format!(", {}", tr("Apply Next Style")) } else { String::new() }; + out.push(format!("{}: {}{next}", tr("Paragraph Styles"), crate::i18n::style_name(&app.ui.language, ¶graph))); + } + if included(d, "textFrame") { + let insets: Vec = ["insetTop", "insetLeft", "insetBottom", "insetRight"].iter().map(|k| d.s(k)).collect(); + let inset = if insets.iter().all(|i| *i == insets[0]) { insets[0].clone() } else { insets.join(", ") }; + out.push(format!( + "{}: {} {}, {} {}, {}: {inset}", + tr("Text Frame General Options"), + tr("Columns:"), + d.s("columns"), + tr("Gutter:"), + d.s("gutter"), + tr("Inset Spacing") + )); + } if sets_effects(d) { let fx = d.fields.get("fx").cloned().unwrap_or(Value::Null); let on: Vec = @@ -315,6 +397,34 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { }); }); } + "paragraph" => { + let Some(doc) = app.session.active().map(|st| &st.doc) else { return }; + ui.add_enabled_ui(included(d, "paragraph"), |ui| { + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Paragraph Style:")); + let cur = d.s("paragraphStyle"); + let shown = if cur.is_empty() { String::new() } else { crate::i18n::style_name(&app.ui.language, &cur).to_string() }; + let mut picked = None; + egui::ComboBox::from_id_salt("os_paragraph_style").selected_text(shown).width(240.0).show_ui(ui, |ui| { + for s in &doc.styles.paragraph { + if ui.selectable_label(s.name == cur, crate::i18n::style_name(&app.ui.language, &s.name)).clicked() { + picked = Some(s.name.clone()); + } + } + }); + if let Some(name) = picked { + d.fields.insert("paragraphStyle".into(), json!(name)); + } + }); + let mut next = d.b("applyNext"); + if ui.checkbox(&mut next, crate::rtl::widget(ui, tr("Apply Next Style"))).changed() { + d.fields.insert("applyNext".into(), json!(next)); + } + }); + } + "textFrame" => { + ui.add_enabled_ui(included(d, "textFrame"), |ui| crate::dialogs::general(&app.ui.language, ui, d)); + } p => crate::effects_dialog::page_ui(app, ui, d, p), } } @@ -414,9 +524,10 @@ mod tests { (app, id) } + /// The texts the dialog paints, once its size has settled (it grows over a few frames). fn frame(app: &mut DesignApp, ctx: &egui::Context) -> Vec { let mut texts = Vec::new(); - for _ in 0..3 { + for _ in 0..8 { let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx())); @@ -493,6 +604,81 @@ mod tests { assert!(doc(&app).styles.object_style(name).unwrap().effects.as_ref().unwrap().drop_shadow.on, "effects kept"); } + /// The course's chapter 39: a new object style from a text frame nests the "Drop Cap First + /// Paragraph" style (Paragraph Styles page), so the frame's text takes it; Object Style + /// Options › Text Frame General Options then gives the style's frames insets and two columns. + #[test] + fn an_object_style_nests_a_paragraph_style_and_sets_text_frame_options() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Body Text"})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Drop Cap First Paragraph", "nextStyle": "Body Text", "para": {"dropCapLines": 3}})) + .unwrap(); + let r = app.run("frame.create", json!({"rect": [36, 36, 336, 336], "content": "text", "caret": false})).unwrap(); + let id = r["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + open_new(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(included(d, "paragraph") && included(d, "textFrame"), "a text frame's categories are in a new style"); + assert_eq!(d.s("paragraphStyle"), "", "the paragraph style is left to choose"); + let texts = frame(&mut app, &ctx); + for t in ["Paragraph Styles", "Text Frame General Options"] { + assert!(texts.iter().any(|x| x.trim() == t), "{t}: {texts:?}"); + } + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("name".into(), json!("Text Box Drop Cap")); + d.fields.insert("page".into(), json!("paragraph")); + d.fields.insert("paragraphStyle".into(), json!("Drop Cap First Paragraph")); + } + let texts = frame(&mut app, &ctx); + for t in ["Paragraph Style:", "Apply Next Style", "Drop Cap First Paragraph"] { + assert!(texts.iter().any(|x| x.trim() == t), "{t}: {texts:?}"); + } + crate::dialogs::confirm(&mut app).unwrap(); + let doc = |app: &DesignApp| app.session.active().unwrap().doc.clone(); + let story = |app: &DesignApp| { + let d = doc(app); + let sid = d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().story; + d.story(sid).unwrap().clone() + }; + assert_eq!(doc(&app).item(designcraft_doc::ItemId(id)).unwrap().object_style, "Text Box Drop Cap"); + assert_eq!(story(&app).paras[0].style, "Drop Cap First Paragraph", "the frame's text takes the nested style"); + app.run("type.fillWithPlaceholder", json!({"frame": id})).unwrap(); + assert!(story(&app).paras.iter().all(|p| p.style == "Drop Cap First Paragraph")); + + open_edit(&mut app, "Text Box Drop Cap"); + let texts = frame(&mut app, &ctx); + assert!(texts.iter().any(|x| x.starts_with("Paragraph Styles: Drop Cap First Paragraph")), "Style Settings: {texts:?}"); + if let Some(d) = app.ui.dialog.as_mut() { + assert_eq!(d.s("paragraphStyle"), "Drop Cap First Paragraph"); + d.fields.insert("page".into(), json!("textFrame")); + for k in ["insetTop", "insetLeft", "insetBottom", "insetRight"] { + d.fields.insert(k.into(), json!("4 pt")); + } + d.fields.insert("columns".into(), json!("2")); + } + let texts = frame(&mut app, &ctx); + for t in ["Inset Spacing", "Vertical Justification", "Ignore Text Wrap"] { + assert!(texts.iter().any(|x| x.trim() == t), "{t}: {texts:?}"); + } + crate::dialogs::confirm(&mut app).unwrap(); + let o = doc(&app).item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.clone(); + assert_eq!((o.columns, o.inset), (2, [4.0; 4])); + let os = doc(&app).styles.object_style("Text Box Drop Cap").cloned().unwrap(); + assert_eq!((os.paragraph_style.as_deref(), os.text_frame.map(|t| t.columns)), (Some("Drop Cap First Paragraph"), Some(2))); + + // A word for the gutter keeps the dialog open with the reason. + open_edit(&mut app, "Text Box Drop Cap"); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("gutter".into(), json!("wide")); + } + let e = crate::dialogs::confirm(&mut app).unwrap_err(); + assert!(e.contains("gutter"), "{e}"); + assert!(app.ui.dialog.is_some()); + } + /// A refused OK (an empty name, a name another style has) keeps the dialog and its edits, /// with the reason shown. #[test] diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index f1c057c84..500e3308f 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -106,7 +106,7 @@ pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) { } /// The selected object's style, and whether the object differs from it (an override: `+`): its -/// own attributes, or a text frame's paragraphs in another style than the object style's. +/// own attributes, or a text frame's paragraphs in other styles than the object style gives them. fn object_style_of_selection(app: &DesignApp) -> Option<(String, bool)> { let st = app.session.active()?; let it = st.selection.items.first().and_then(|i| st.doc.item(*i))?; @@ -114,8 +114,8 @@ fn object_style_of_selection(app: &DesignApp) -> Option<(String, bool)> { let mut over = false; if let Some(os) = st.doc.styles.object_style(&it.object_style) { os.apply_to(&mut styled); - if let (Some(ps), Some(tf)) = (&os.paragraph_style, it.text_frame()) { - over = st.doc.story(tf.story).is_some_and(|s| s.paras.iter().any(|f| f.style != *ps)); + if let Some(tf) = it.text_frame() { + over = st.doc.story(tf.story).is_some_and(|s| !os.story_follows(&st.doc.styles, s)); } } Some((it.object_style.clone(), over || styled != *it)) From a13c6dde6793b58c0da733e0cb9538d5ab6a58c7 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 00:42:28 +0200 Subject: [PATCH 09/36] Object Styles: objectStyle.dialog reports a style with no options; Polish Clear Overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `objectStyle.dialog` with a style the document doesn't have, or with [None], returned success and opened nothing. `open_edit` now refuses them and the command passes the error on. Polish "Clear Overrides" reads "Wyczyść zastąpienia", as the table's other override action. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/pl.rs | 2 +- crates/ui-egui/src/menus.rs | 12 +++++----- crates/ui-egui/src/object_style_dialog.rs | 29 ++++++++++++++++++----- crates/ui-egui/src/panels/styles.rs | 6 ++--- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index f0f7f58a1..41bfd2fe2 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -37,7 +37,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Effects for:", "Efekty dla:"), ("Apply Style to Selection", "Zastosuj styl do zaznaczenia"), ("Create New Style", "Utwórz nowy styl"), - ("Clear Overrides", "Wyczyść zmiany"), + ("Clear Overrides", "Wyczyść zastąpienia"), ("Shape:", "Kształt:"), ("(no attributes)", "(brak atrybutów)"), ("Enter a style name.", "Wpisz nazwę stylu."), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 237e64f55..67d6708a6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1195,13 +1195,13 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { - match p.get("name").and_then(Value::as_str) { - Some(n) => crate::object_style_dialog::open_edit(app, n), - None => crate::object_style_dialog::open_new(app), + "objectStyle.dialog" => match p.get("name").and_then(Value::as_str) { + Some(n) => crate::object_style_dialog::open_edit(app, n).map(|()| Value::Null), + None => { + crate::object_style_dialog::open_new(app); + Ok(Value::Null) } - Ok(Value::Null) - } + }, "app.loadSwatches" => { if let Some(pick) = app.services.pick_open.as_mut() { return Some(match pick("swatches") { diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs index 2a9a23ad4..9dc905b3c 100644 --- a/crates/ui-egui/src/object_style_dialog.rs +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -39,11 +39,14 @@ pub fn open_new(app: &mut DesignApp) { app.ui.dialog = Some(Dialog::new(ID, fields)); } -/// Object Style Options for style `name`. -pub fn open_edit(app: &mut DesignApp, name: &str) { - let Some(os) = app.session.active().and_then(|st| st.doc.styles.object_style(name).cloned()) else { return }; +/// Object Style Options for style `name`; refused for a style the document doesn't have and for +/// `[None]`, which has no options. +pub fn open_edit(app: &mut DesignApp, name: &str) -> Result<(), String> { + let Some(os) = app.session.active().and_then(|st| st.doc.styles.object_style(name).cloned()) else { + return Err(format!("no object style `{name}`")); + }; if name == designcraft_doc::NO_OBJECT_STYLE { - return; + return Err(format!("`{name}` has no options")); } let fields = json!({ "new": false, @@ -60,6 +63,7 @@ pub fn open_edit(app: &mut DesignApp, name: &str) { "apply": false, }); app.ui.dialog = Some(Dialog::new(ID, fields)); + Ok(()) } /// The dialog's title: New Object Style or Object Style Options. @@ -480,7 +484,7 @@ mod tests { let b = app.run("frame.create", json!({"rect": [200, 72, 300, 172], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); app.run("style.object.apply", json!({"name": name, "ids": [b]})).unwrap(); - open_edit(&mut app, name); + open_edit(&mut app, name).unwrap(); if let Some(d) = app.ui.dialog.as_mut() { assert_eq!(title(d), "Object Style Options"); set(d, &["stroke", "weight"], json!(5.0)); @@ -493,6 +497,19 @@ mod tests { assert!(doc(&app).styles.object_style(name).unwrap().effects.as_ref().unwrap().drop_shadow.on, "effects kept"); } + /// `objectStyle.dialog` with a style the document lacks, or `[None]`, is an error and opens + /// nothing; without a name it opens New Object Style. + #[test] + fn the_dialog_command_refuses_a_style_with_no_options() { + let (mut app, _) = app_with_frame(); + for name in ["Nope", designcraft_doc::NO_OBJECT_STYLE] { + assert!(app.run("objectStyle.dialog", json!({"name": name})).is_err(), "{name}"); + assert!(app.ui.dialog.is_none(), "{name}"); + } + app.run("objectStyle.dialog", json!({})).unwrap(); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.b("new"))); + } + /// A refused OK (an empty name, a name another style has) keeps the dialog and its edits, /// with the reason shown. #[test] @@ -512,7 +529,7 @@ mod tests { assert_eq!(d.s("status"), "Enter a style name."); assert_eq!(get(d, &["stroke", "weight"]), &json!(7.0), "the edits are kept"); assert!(frame(&mut app, &ctx).iter().any(|t| t.trim() == "Enter a style name.")); - open_edit(&mut app, "Mine"); + open_edit(&mut app, "Mine").unwrap(); if let Some(d) = app.ui.dialog.as_mut() { d.fields.insert("name".into(), json!("Taken")); } diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index f1c057c84..1de6d53f6 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -157,14 +157,14 @@ pub fn object(app: &mut DesignApp, ui: &mut egui::Ui) { } let editable = n != designcraft_doc::NO_OBJECT_STYLE; if resp.double_clicked() && editable { - crate::object_style_dialog::open_edit(app, n); + let _ = crate::object_style_dialog::open_edit(app, n); } else if resp.clicked() && selected { let _ = app.run("style.object.apply", json!({"name": n})); } resp.context_menu(|ui| { let label = crate::i18n::style_name(&lang, n); if editable && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Edit \"{name}\"…").replace("{name}", label))).clicked() { - crate::object_style_dialog::open_edit(app, n); + let _ = crate::object_style_dialog::open_edit(app, n); ui.close(); } if ui @@ -202,7 +202,7 @@ pub fn object(app: &mut DesignApp, ui: &mut egui::Ui) { if let Some((n, _)) = current.as_ref().filter(|(n, _)| n != designcraft_doc::NO_OBJECT_STYLE) && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Object Style Options…"))).clicked() { - crate::object_style_dialog::open_edit(app, n); + let _ = crate::object_style_dialog::open_edit(app, n); ui.close(); } }); From c22c48dc8e2636b2c481299e3cce16bd1932ee94 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 10:24:19 +0200 Subject: [PATCH 10/36] Object styles hold named corners; IDML styles inherit the corners they don't set An object style's corners are top left, top right, bottom right and bottom left, as InDesign's and as IDML import and export already read them. `corners::named_slots` finds which anchor of a path is at which corner, and `CornerOptions::named_to_path` / `path_to_named` convert: applying a style puts each corner on the anchor at that corner, whatever order the path runs in; a style made from a selection, and redefining one, convert the item's corners. IDML export's `corner_names` uses the same mapping. An IDML object style that sets some corners takes the others from its Based On chain, so applying a child that changes one corner keeps the parent's other corners. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/styles.rs | 6 ++- crates/engine/src/cmd/style.rs | 8 +-- crates/geom/src/corners.rs | 67 ++++++++++++++++++++++++++ crates/idml/src/export.rs | 23 +-------- crates/idml/src/import.rs | 37 +++++++++++--- crates/idml/src/tests/object_styles.rs | 21 ++++++++ 6 files changed, 127 insertions(+), 35 deletions(-) diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 380f0fd3a..ca7b13665 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -60,7 +60,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, } @@ -69,7 +71,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 7bcdcbb1d..86ad2ea87 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1528,7 +1528,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()); } @@ -1705,7 +1705,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)) }; let updated = d.update_items( |it| it.object_style == new.name && follows(it), @@ -1726,9 +1726,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); } }, ); 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 a4330f4e2..1fb0f202b 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -3020,28 +3020,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 db1439053..46c604e8f 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 s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, text_frame, effects, corners }; match self.styles.object.iter_mut().find(|o| o.name == name) { Some(slot) => *slot = s, diff --git a/crates/idml/src/tests/object_styles.rs b/crates/idml/src/tests/object_styles.rs index 4e7f5ef5d..10b0d940b 100644 --- a/crates/idml/src/tests/object_styles.rs +++ b/crates/idml/src/tests/object_styles.rs @@ -227,3 +227,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]); +} From 496b2fe4dc2adf77831533bb3cf5afd0b647d2cb Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 10:28:47 +0200 Subject: [PATCH 11/36] New Object Style: the selected object's corners as named corners With #1455's named style corners merged, the dialog seeds the style's corners from the selected item through `path_to_named`, so a frame whose path runs in another order gives each corner its own options. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/object_style_dialog.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs index 9dc905b3c..1678a2dee 100644 --- a/crates/ui-egui/src/object_style_dialog.rs +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -29,7 +29,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}, From 7cb5ec5829c25c466c245224ef198eba2a8ee437 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 11:58:57 +0200 Subject: [PATCH 12/36] IDML object styles: legacy all-corner attributes; parity notes the Object Style Options dialog An object style that gives its corners only with `CornerOption` and `CornerRadius` keeps them; at each level of the Based On chain a per-corner attribute wins over those, as for page items. The parity assessment no longer says there is no Object Style Options dialog. Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/import.rs | 11 +++++++++-- crates/idml/src/tests/object_styles.rs | 13 +++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index a8630010e..74067df60 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -481,8 +481,15 @@ fn corners(e: &El, prefix: &str) -> Option<[Corner; 4]> { /// 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| 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")))); + // An object style (no prefix) may give all four with the legacy `CornerOption` / + // `CornerRadius`; at each level a per-corner attribute wins over it, as for page items. + let legacy = |e: &El, what: &str| if prefix.is_empty() { e.prop(&format!("Corner{what}")) } else { None }; + let shapes = keys.map(|n| levels.iter().find_map(|e| e.prop(&format!("{prefix}{n}CornerOption")).or_else(|| legacy(e, "Option")))); + let sizes = keys.map(|n| { + levels + .iter() + .find_map(|e| e.num(&format!("{prefix}{n}CornerRadius")).or_else(|| legacy(e, "Radius").and_then(|v| v.trim().parse::().ok()))) + }); if shapes.iter().all(Option::is_none) && sizes.iter().all(Option::is_none) { return None; } diff --git a/crates/idml/src/tests/object_styles.rs b/crates/idml/src/tests/object_styles.rs index 41624f855..faaef4f32 100644 --- a/crates/idml/src/tests/object_styles.rs +++ b/crates/idml/src/tests/object_styles.rs @@ -276,3 +276,16 @@ fn a_style_setting_one_corner_inherits_the_others_and_names_them() { style.apply_to(&mut it); assert_eq!(it.corners.corners.map(|c| c.shape), [CornerShape::Rounded, CornerShape::Rounded, CornerShape::Rounded, CornerShape::Bevel]); } + +/// An object style giving its corners only with the legacy all-corners `CornerOption` and +/// `CornerRadius` keeps them, so applying it to another frame rounds it. +#[test] +fn a_style_with_legacy_all_corner_attributes_keeps_its_corners() { + let legacy = r#" + $ID/[None] + "#; + let d = fixture(legacy, &[]); + let style = d.styles.object.iter().find(|s| s.name == "Legacy").unwrap(); + let c = style.corners.expect("the legacy corners").corners; + assert!(c.iter().all(|c| c.shape == CornerShape::Rounded && c.size == 9.0), "{c:?}"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c7e0678d7..8cf0029c5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -412,7 +412,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). 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. +- 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. Object Style Options is a dialog (from the Object Styles panel), with General, Fill, Stroke, Stroke & Corner Options, Paragraph Styles (with Apply Next Style) and Text Frame General Options pages. - Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point. - Spring-loaded Zoom: hold Cmd+Space (Ctrl+Space on Windows/Linux) for the Zoom cursor and click/scrubby zoom; Alt-click zooms out. Release returns to the previous tool without changing the document or undo history. Text-frame and text-field typing suppress the shortcut; Space remains Hand. - Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it. From 5d8ce25d49ae51ff77602199389a37977095aa96 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 24 Oct 2026 12:58:00 +0200 Subject: [PATCH 13/36] i18n: keys main added to fr.rs are in the shared table here: keep one The catalogue test refuses a key in both fr.rs and the shared table (CI). The shared row keeps the French; it matches, or takes fr.rs's text. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/fr.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 681230d84..3704bda04 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -233,7 +233,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Numbering & Section Options", "Options de numérotation et de section"), ("OK", "OK"), ("Object Style", "Style d'objet"), - ("Object Style Options…", "Options de style d'objet…"), ("Object Styles", "Styles d'objet"), ("Object/Anchored Object", "Objet/Objet ancré"), ("Object/Arrange", "Objet/Disposition"), From b424ffc02e2811ea29a090b384618b844c6f33bb Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:45:48 +0100 Subject: [PATCH 14/36] Text frame fields: the document's baseline grid comes in as a parameter main's custom baseline grid read it from `app` inside text_frame_fields, which this branch made app-free for the Object Style dialog; both callers pass it now. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 14 ++++++++++---- crates/ui-egui/src/object_style_dialog.rs | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c488487e6..57ceed4d2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -576,7 +576,7 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { } let o = selected_frame_options(app).unwrap_or_default(); let units = app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas); - let shown = text_frame_fields(&o, units, selected_frame_width(app)); + let shown = text_frame_fields(&o, units, selected_frame_width(app), &document_grid(app)); if let Value::Object(m) = &shown { for (k, v) in m { d.fields.entry(k.clone()).or_insert_with(|| v.clone()); @@ -586,9 +586,15 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { d.fields.insert("_shown".into(), shown); } +/// The active document's baseline grid (the default one without a document). +pub(crate) fn document_grid(app: &DesignApp) -> designcraft_doc::BaselineGrid { + app.session.active().map(|s| s.doc.settings.baseline_grid.clone()).unwrap_or_default() +} + /// Text frame options `o` as the Text Frame Options dialog's fields, measures in `units`; with the -/// frame's width, a Fixed Number column shows the width it gets. -pub(crate) fn text_frame_fields(o: &designcraft_doc::TextFrameOptions, units: Unit, frame_width: Option) -> Value { +/// frame's width, a Fixed Number column shows the width it gets. Without a custom baseline grid of +/// its own, the frame shows `doc_grid`'s start and increment. +pub(crate) fn text_frame_fields(o: &designcraft_doc::TextFrameOptions, units: Unit, frame_width: Option, doc_grid: &designcraft_doc::BaselineGrid) -> Value { let fm = |v: f64| json!(format_measure(v, units)); // Fixed Number: the width each column gets in the frame (shown, as in InDesign). let inner = frame_width.map(|w| w - o.inset[1] - o.inset[3]).unwrap_or(0.0); @@ -600,7 +606,7 @@ pub(crate) fn text_frame_fields(o: &designcraft_doc::TextFrameOptions, units: Un let kind = |v: Value| v.as_str().unwrap_or_default().to_string(); // A frame without a custom baseline grid shows the document grid's start and increment. let grid = o.baseline_grid.unwrap_or_else(|| { - let g = app.session.active().map(|s| s.doc.settings.baseline_grid.clone()).unwrap_or_default(); + let g = doc_grid; let relative_to = match g.relative_to { designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage, designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin, diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs index fc1fbbd1a..c6206fe43 100644 --- a/crates/ui-egui/src/object_style_dialog.rs +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -35,7 +35,7 @@ fn text_fields(app: &DesignApp, fields: &mut Value, paragraph: Option<&str>, nex fields["applyNext"] = json!(next); fields["tfBase"] = serde_json::to_value(tf).unwrap_or_default(); fields["insetLinked"] = json!(tf.inset.iter().all(|v| (v - tf.inset[0]).abs() < 1e-9)); - if let (Value::Object(f), Value::Object(shown)) = (fields, crate::dialogs::text_frame_fields(tf, units, None)) { + if let (Value::Object(f), Value::Object(shown)) = (fields, crate::dialogs::text_frame_fields(tf, units, None, &crate::dialogs::document_grid(app))) { f.extend(shown); } } From 443a62aa7cdc73a1f10f62a3d23534c4a31e66f4 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 25 Oct 2026 17:46:01 +0100 Subject: [PATCH 15/36] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 7 ++++++- crates/ui-egui/src/object_style_dialog.rs | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 57ceed4d2..f738d26ac 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -594,7 +594,12 @@ pub(crate) fn document_grid(app: &DesignApp) -> designcraft_doc::BaselineGrid { /// Text frame options `o` as the Text Frame Options dialog's fields, measures in `units`; with the /// frame's width, a Fixed Number column shows the width it gets. Without a custom baseline grid of /// its own, the frame shows `doc_grid`'s start and increment. -pub(crate) fn text_frame_fields(o: &designcraft_doc::TextFrameOptions, units: Unit, frame_width: Option, doc_grid: &designcraft_doc::BaselineGrid) -> Value { +pub(crate) fn text_frame_fields( + o: &designcraft_doc::TextFrameOptions, + units: Unit, + frame_width: Option, + doc_grid: &designcraft_doc::BaselineGrid, +) -> Value { let fm = |v: f64| json!(format_measure(v, units)); // Fixed Number: the width each column gets in the frame (shown, as in InDesign). let inner = frame_width.map(|w| w - o.inset[1] - o.inset[3]).unwrap_or(0.0); diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs index c6206fe43..61b7cbe7b 100644 --- a/crates/ui-egui/src/object_style_dialog.rs +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -35,7 +35,9 @@ fn text_fields(app: &DesignApp, fields: &mut Value, paragraph: Option<&str>, nex fields["applyNext"] = json!(next); fields["tfBase"] = serde_json::to_value(tf).unwrap_or_default(); fields["insetLinked"] = json!(tf.inset.iter().all(|v| (v - tf.inset[0]).abs() < 1e-9)); - if let (Value::Object(f), Value::Object(shown)) = (fields, crate::dialogs::text_frame_fields(tf, units, None, &crate::dialogs::document_grid(app))) { + if let (Value::Object(f), Value::Object(shown)) = + (fields, crate::dialogs::text_frame_fields(tf, units, None, &crate::dialogs::document_grid(app))) + { f.extend(shown); } } From faee83dad68be3506a62d918df7e42723a880bed Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:24:59 +0100 Subject: [PATCH 16/36] Georgian: main's rows where the branch added the same keys again Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index cd3a889a8..d0b94f549 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -36,7 +36,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Text Frame General Options", "ტექსტის ჩარჩოს ზოგადი პარამეტრები"), ("New Object Style", "ახალი ობიექტის სტილი"), ("Object Style Options", "ობიექტის სტილის პარამეტრები"), - ("Object Style Options…", "ობიექტის სტილის პარამეტრები…"), ("Basic Attributes", "ძირითადი ატრიბუტები"), ("Stroke & Corner Options", "კონტურისა და კუთხეების პარამეტრები"), ("Effects for:", "ეფექტები:"), From 72c06ea4ac676e4bf0f10fe7061a36b9542025a3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:33:44 +0100 Subject: [PATCH 17/36] IDML object style import: the HashMap import in main's split styles module Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/import/styles.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/idml/src/import/styles.rs b/crates/idml/src/import/styles.rs index 013036d6a..9f5b6f69c 100644 --- a/crates/idml/src/import/styles.rs +++ b/crates/idml/src/import/styles.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use designcraft_geom::corners::CornerOptions; use super::object_style::{corners, corners_in}; From 1141f3a22b58d45c56e3cc4a3fe968748a1ecbbd Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:40:12 +0100 Subject: [PATCH 18/36] Object styles: the branch's true/false parameter helper, beside the other object style parameters in main's split module Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/style/object.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/engine/src/cmd/style/object.rs b/crates/engine/src/cmd/style/object.rs index 732d9fba6..7fcc3d1f3 100644 --- a/crates/engine/src/cmd/style/object.rs +++ b/crates/engine/src/cmd/style/object.rs @@ -159,6 +159,15 @@ pub(super) fn create_object(s: &mut Session, p: &Value) -> Result { }) } +/// A true/false parameter, when given. +fn bool_param(cmd: &str, p: &Value, key: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(bad(cmd, format!("`{key}` must be true or false"))), + } +} + /// `p.stroke` merged over `base` (as [`group_param`]), refused when the weight isn't a number ≥ 0 /// or the tint isn't 0–1. fn stroke_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Stroke>) -> Result>> { From 197f75941e179d5c9009fd52d75f58069c52b00c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:01:01 +0100 Subject: [PATCH 19/36] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/object_style_dialog.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs index 61b7cbe7b..5ce0d56a5 100644 --- a/crates/ui-egui/src/object_style_dialog.rs +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -333,7 +333,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { // Based On isn't editable yet: the style's parent, as it is. ui.add_enabled_ui(false, |ui| { let based = d.s("basedOn"); - egui::ComboBox::from_id_salt("os_based_on") + craft_ui::ComboBox::from_id_salt("os_based_on") .selected_text(crate::i18n::style_name(&app.ui.language, &based)) .width(220.0) .show_ui(ui, |_| {}); @@ -390,7 +390,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let cur: designcraft_geom::corners::CornerShape = serde_json::from_value(get(d, &["corners", "corners"])[0]["shape"].clone()).unwrap_or_default(); let mut picked = None; - egui::ComboBox::from_id_salt("os_corner_shape").selected_text(tr(cur.label())).width(140.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("os_corner_shape").selected_text(tr(cur.label())).width(140.0).show_ui(ui, |ui| { for s in designcraft_geom::corners::CornerShape::ALL { if ui.selectable_label(s == cur, tr(s.label())).clicked() { picked = Some(s); @@ -415,7 +415,7 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let cur = d.s("paragraphStyle"); let shown = if cur.is_empty() { String::new() } else { crate::i18n::style_name(&app.ui.language, &cur).to_string() }; let mut picked = None; - egui::ComboBox::from_id_salt("os_paragraph_style").selected_text(shown).width(240.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("os_paragraph_style").selected_text(shown).width(240.0).show_ui(ui, |ui| { for s in &doc.styles.paragraph { if ui.selectable_label(s.name == cur, crate::i18n::style_name(&app.ui.language, &s.name)).clicked() { picked = Some(s.name.clone()); @@ -472,7 +472,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { crate::rtl::label(ui, tr("Effects for:")); // Stroke, Fill and Text levels aren't in the document model yet: the object only. ui.add_enabled_ui(false, |ui| { - egui::ComboBox::from_id_salt("os_fx_level").selected_text(tr("Object")).width(100.0).show_ui(ui, |_| {}); + craft_ui::ComboBox::from_id_salt("os_fx_level").selected_text(tr("Object")).width(100.0).show_ui(ui, |_| {}); }); }); for (key, label) in crate::effects_dialog::PAGES.iter().skip(1) { From 4d30380930a6122268bcf73baf05181753fcfa5a Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 15:19:33 +0100 Subject: [PATCH 20/36] IDML export: no unused HashSet import in main's split item module Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/export/item.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/idml/src/export/item.rs b/crates/idml/src/export/item.rs index 83d689d47..f53c70465 100644 --- a/crates/idml/src/export/item.rs +++ b/crates/idml/src/export/item.rs @@ -1,6 +1,6 @@ use super::common::{affine_s, bool_s, p, pct, uid, with_props}; use super::{ - Affine, ColumnsKind, Content, El, Ex, HashSet, Item, Node, OBJECT_BUILTINS, PathData, Shape, TextFrameOptions, base64_encode, escape_id, names, + Affine, ColumnsKind, Content, El, Ex, Item, Node, OBJECT_BUILTINS, PathData, Shape, TextFrameOptions, base64_encode, escape_id, names, num, pt, }; From 1fe2e6b7e5f664359f87dcaa998577c946ff34b0 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 15:26:28 +0100 Subject: [PATCH 21/36] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/export/item.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/idml/src/export/item.rs b/crates/idml/src/export/item.rs index f53c70465..0f6b73307 100644 --- a/crates/idml/src/export/item.rs +++ b/crates/idml/src/export/item.rs @@ -1,7 +1,6 @@ use super::common::{affine_s, bool_s, p, pct, uid, with_props}; use super::{ - Affine, ColumnsKind, Content, El, Ex, Item, Node, OBJECT_BUILTINS, PathData, Shape, TextFrameOptions, base64_encode, escape_id, names, - num, pt, + Affine, ColumnsKind, Content, El, Ex, Item, Node, OBJECT_BUILTINS, PathData, Shape, TextFrameOptions, base64_encode, escape_id, names, num, pt, }; impl<'a> Ex<'a> { From 2ea8aa673930e86a014c2adb3effea291ded0a84 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 15:31:56 +0100 Subject: [PATCH 22/36] Object styles: the paragraph style and text frame parameter helpers in main's split module; Object Styles in the styles icon family Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/style/object.rs | 31 +++++++++++++++++++++++ crates/ui-egui/src/panel_docking.rs | 2 +- crates/ui-egui/src/panel_docking/tests.rs | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/style/object.rs b/crates/engine/src/cmd/style/object.rs index 7fcc3d1f3..fb2467e5f 100644 --- a/crates/engine/src/cmd/style/object.rs +++ b/crates/engine/src/cmd/style/object.rs @@ -159,6 +159,37 @@ pub(super) fn create_object(s: &mut Session, p: &Value) -> Result { }) } +/// `paragraphStyle`: a paragraph style's name, or null (the object style sets none). +fn paragraph_style_param(cmd: &str, s: &Session, p: &Value) -> Result>> { + match p.get("paragraphStyle") { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(Value::String(n)) if s.doc()?.doc.styles.para(n).is_some() => Ok(Some(Some(n.clone()))), + Some(Value::String(n)) => Err(bad(cmd, format!("no paragraph style `{n}`"))), + Some(_) => Err(bad(cmd, "`paragraphStyle` must be a paragraph style's name or null")), + } +} + +/// `textFrame` (a text frame's options, as `document.inspect` shows them) merged over `base`, or +/// null; refused when the columns aren't 1–40 or a gutter, width or inset isn't 0–15552 pt. +fn text_frame_param( + cmd: &str, + p: &Value, + base: Option<&designcraft_doc::TextFrameOptions>, +) -> Result>> { + let tf = group_param(cmd, p, "textFrame", base.unwrap_or(&designcraft_doc::TextFrameOptions::default()))?; + if let Some(Some(o)) = &tf { + if !(1..=40).contains(&o.columns) { + return Err(bad(cmd, "text frame columns must be 1–40")); + } + let mut lengths = [o.gutter, o.column_width, o.vj_paragraph_spacing_limit, o.first_baseline_min].into_iter().chain(o.inset); + if !lengths.all(|v| v.is_finite() && (0.0..=15552.0).contains(&v)) { + return Err(bad(cmd, "a text frame's gutter, widths and insets must be 0–15552 pt")); + } + } + Ok(tf) +} + /// A true/false parameter, when given. fn bool_param(cmd: &str, p: &Value, key: &str) -> Result> { match p.get(key) { diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 801a96ae3..709eb0d02 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -114,7 +114,7 @@ impl Placed { const ICON_GROUPS: &[&[&str]] = &[ &["stroke", "color", "swatches", "gradient"], &["effects"], - &["characterStyles", "paragraphStyles"], + &["characterStyles", "paragraphStyles", "objectStyles"], &["character", "paragraph", "typography"], &["glyphs", "story"], &["table"], diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index 77b721098..a4e7f97ef 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -881,7 +881,7 @@ fn placed_panels_leave_their_icon_groups() { let column = icon_column(None, &mut placed).unwrap(); let groups: Vec> = column.groups.iter().map(|g| g.panels.iter().map(String::as_str).collect()).collect(); assert_eq!(groups.first().unwrap(), &["stroke", "color", "gradient"]); - assert_eq!(groups.get(1).unwrap(), &["characterStyles", "paragraphStyles"]); + assert_eq!(groups.get(1).unwrap(), &["characterStyles", "paragraphStyles", "objectStyles"]); assert_eq!(groups.len(), ICON_GROUPS.len() - 1); } From e0b8e593e3212eb96af3ecf0919184d0372da142 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 18:01:53 +0100 Subject: [PATCH 23/36] egui 0.37: the renamed APIs in this branch's code Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/styles.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index 2b3b159c5..fac694ace 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -144,7 +144,7 @@ pub fn object(app: &mut DesignApp, ui: &mut egui::Ui) { ui.painter().rect_filled(row, 0.0, t.hover); } let label = shown(n, is_current && current.as_ref().is_some_and(|c| c.1)); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, is_current, &label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, is_current, &label)); crate::rtl::paint(ui.painter(), row.min + vec2(24.0, 11.0), egui::Align2::LEFT_CENTER, &label, egui::FontId::proportional(12.5), t.text); // The default styles of new graphic and text frames, as InDesign marks them. let mark = egui::Rect::from_center_size(egui::pos2(row.max.x - 14.0, row.center().y), vec2(11.0, 11.0)); @@ -379,7 +379,7 @@ mod tests { app.run("style.object.create", json!({"name": "Framed", "fromSelection": false, "stroke": {"swatch": "[Paper]", "weight": 3}})).unwrap(); let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).build_ui_state( |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); From 332dd539b2252b981fb2ec869500a93fb7a99ce1 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 18:04:24 +0100 Subject: [PATCH 24/36] Object Styles: an icon group of its own after the paragraph and character styles The styles group stays Character and Paragraph Styles, as the floating styles group and its tests expect. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panel_docking.rs | 3 ++- crates/ui-egui/src/panel_docking/tests.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 709eb0d02..31366dfb5 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -114,7 +114,8 @@ impl Placed { const ICON_GROUPS: &[&[&str]] = &[ &["stroke", "color", "swatches", "gradient"], &["effects"], - &["characterStyles", "paragraphStyles", "objectStyles"], + &["characterStyles", "paragraphStyles"], + &["objectStyles"], &["character", "paragraph", "typography"], &["glyphs", "story"], &["table"], diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index a4e7f97ef..77b721098 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -881,7 +881,7 @@ fn placed_panels_leave_their_icon_groups() { let column = icon_column(None, &mut placed).unwrap(); let groups: Vec> = column.groups.iter().map(|g| g.panels.iter().map(String::as_str).collect()).collect(); assert_eq!(groups.first().unwrap(), &["stroke", "color", "gradient"]); - assert_eq!(groups.get(1).unwrap(), &["characterStyles", "paragraphStyles", "objectStyles"]); + assert_eq!(groups.get(1).unwrap(), &["characterStyles", "paragraphStyles"]); assert_eq!(groups.len(), ICON_GROUPS.len() - 1); } From 90f3d39ff0711cfe26461bad382149a3546d1b1b Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 4 Nov 2026 19:17:06 +0100 Subject: [PATCH 25/36] i18n: drop this branch's rows main's catalogues now have Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/table.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index 6b1d2eeba..0158ec53a 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -45,16 +45,6 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ ("Reset To Base", ["Auf Basis zurücksetzen", "Rétablir la base", "Restablecer a base", "基準に戻す", "重置为基准"]), ("New Paragraph Style", ["Neues Absatzformat", "Nouveau style de paragraphe", "Nuevo estilo de párrafo", "新規段落スタイル", "新建段落样式"]), ("New Character Style", ["Neues Zeichenformat", "Nouveau style de caractère", "Nuevo estilo de carácter", "新規文字スタイル", "新建字符样式"]), - ( - "Apply Style to Selection", - [ - "Format auf Auswahl anwenden", - "Appliquer le style à la sélection", - "Aplicar estilo a la selección", - "選択範囲にスタイルを適用", - "将样式应用于选区", - ], - ), ( "Clear Frame Fitting Options", [ From f47acd5dfd90ae2ae9bd7900f24a52dab8fa71aa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:54:37 -0800 Subject: [PATCH 26/36] Object styles: Text Frame General Options set only the General page - An object style's text frame options set the columns, insets, vertical justification, paragraph spacing limit and Ignore Text Wrap. A frame keeps its auto-size, baseline, column rule and Type on a Path settings, and follows an edit of the style while its General options match the style's. - textFrame refuses keys outside the General options, takes one number for all four insets, and checks the values the call gives. - Applying a style that changes nothing records no undo step. - The dialog's Paragraph Spacing Limit reaches the style. - IDML import: a style's Text Frame General Options take the settings its TextFramePreference leaves out from the styles it is based on (by id or bare name), skipping styles that turn the category off. - i18n: Hungarian and Swedish rows for the object style dialog; the duplicate Brazilian Portuguese rows are removed. --- crates/doc/src/item/text_frame.rs | 43 ++++++++++++++ crates/doc/src/styles.rs | 5 +- crates/engine/src/cmd/style.rs | 6 +- crates/engine/src/cmd/style/object.rs | 42 ++++++++++--- crates/engine/src/tests.rs | 72 +++++++++++++++++++++++ crates/idml/src/import/styles.rs | 44 +++++++++----- crates/idml/src/tests/object_styles.rs | 37 ++++++++++++ crates/ui-egui/src/i18n/hu.rs | 13 ++++ crates/ui-egui/src/i18n/pt_br.rs | 2 - crates/ui-egui/src/i18n/sv.rs | 12 ++++ crates/ui-egui/src/object_style_dialog.rs | 39 +++++++++++- 11 files changed, 285 insertions(+), 30 deletions(-) diff --git a/crates/doc/src/item/text_frame.rs b/crates/doc/src/item/text_frame.rs index d8f7f29e3..5c01bb21b 100644 --- a/crates/doc/src/item/text_frame.rs +++ b/crates/doc/src/item/text_frame.rs @@ -240,6 +240,49 @@ impl Default for TextFrameOptions { } } +impl TextFrameOptions { + /// The JSON keys of the General options: columns, inset spacing, vertical justification and + /// Ignore Text Wrap. An object style's Text Frame General Options category sets these only. + pub const GENERAL_KEYS: [&str; 9] = [ + "columns", + "columnsKind", + "gutter", + "columnWidth", + "balanceColumns", + "inset", + "verticalJustification", + "vjParagraphSpacingLimit", + "ignoreWrap", + ]; + + /// Whether the General options (see [`Self::GENERAL_KEYS`]) of `self` and `other` are the same. + pub fn general_eq(&self, other: &TextFrameOptions) -> bool { + self.columns == other.columns + && self.columns_kind == other.columns_kind + && self.gutter == other.gutter + && self.column_width == other.column_width + && self.balance_columns == other.balance_columns + && self.inset == other.inset + && self.vertical_justification == other.vertical_justification + && self.vj_paragraph_spacing_limit == other.vj_paragraph_spacing_limit + && self.ignore_wrap == other.ignore_wrap + } + + /// Copies the General options of `from` (see [`Self::GENERAL_KEYS`]). Baseline, auto-size, + /// column rule and Type on a Path settings stay as they are. + pub fn set_general(&mut self, from: &TextFrameOptions) { + self.columns = from.columns; + self.columns_kind = from.columns_kind; + self.gutter = from.gutter; + self.column_width = from.column_width; + self.balance_columns = from.balance_columns; + self.inset = from.inset; + self.vertical_justification = from.vertical_justification; + self.vj_paragraph_spacing_limit = from.vj_paragraph_spacing_limit; + self.ignore_wrap = from.ignore_wrap; + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum WrapMode { diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 1ba59ef25..98923762c 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -72,7 +72,8 @@ pub struct ObjectStyle { } impl ObjectStyle { - /// Gives `it` the fill, stroke, corners, effects and text frame options this style sets. + /// Gives `it` the fill, stroke, corners, effects and text frame General options this style + /// sets. A text frame keeps its baseline, auto-size, column rule and Type on a Path settings. pub fn apply_to(&self, it: &mut Item) { if let Some(c) = self.corners { it.corners = c.named_to_path(&it.path); @@ -87,7 +88,7 @@ impl ObjectStyle { it.effects = e.clone(); } if let (Some(tf), Some(o)) = (it.text_frame_mut(), &self.text_frame) { - tf.options = o.clone(); + tf.options.set_general(o); } } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 64cc8f6f3..5b1ac047d 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -173,7 +173,7 @@ pub fn specs() -> Vec { "Apply Object Style", [], None, - "{name, ids?} — sets the style's fill, stroke, effects and text frame options on the items", + "{name, ids?} — sets the style's fill, stroke, corners, effects and text frame General options on the items, and its paragraph styles on their text; records nothing when the items already have them", super::has_selection, apply_object ), @@ -182,7 +182,7 @@ pub fn specs() -> Vec { "New Object Style…", [], None, - "{name, fromSelection?: true (fill, stroke, corners, effects and text frame options of the first selected item), fill?: swatch | {swatch?, tint?}, stroke?: {swatch?, weight?, tint?, type?, …}, corners?: {shape?, size?} | {corners: [{shape, size}] ×4}, paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns?, columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right], verticalJustification?, ignoreWrap?, …} | null (the options of `document.inspect` text frames; null: the style sets none), 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)", + "{name, fromSelection?: true (fill, stroke, corners, effects and text frame options of the first selected item), fill?: swatch | {swatch?, tint?}, stroke?: {swatch?, weight?, tint?, type?, …}, corners?: {shape?, size?} | {corners: [{shape, size}] ×4}, paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), 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 ), @@ -191,7 +191,7 @@ pub fn specs() -> Vec { "Object Style Options…", [], None, - "{name, rename?: new name (every use follows), fill?: swatch | {swatch?, tint?} | null, stroke?: {swatch?, weight?, tint?, type?, …} | null, corners?: {shape?: none|rounded|inverseRounded|inset|bevel|fancy, size?} | {corners: [{shape, size}] ×4} | null (null: the style no longer sets it; items keep theirs), paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns?, columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right], verticalJustification?, ignoreWrap?, …} | null (the options of `document.inspect` text frames; null: the style sets none), 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 (fill, stroke, corners) match the style's (an item's defaults, when the style set none) take the new ones, items with their own keep them → {name, updated}", + "{name, rename?: new name (every use follows), fill?: swatch | {swatch?, tint?} | null, stroke?: {swatch?, weight?, tint?, type?, …} | null, corners?: {shape?: none|rounded|inverseRounded|inset|bevel|fancy, size?} | {corners: [{shape, size}] ×4} | null (null: the style no longer sets it; items keep theirs), paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), 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 (fill, stroke, corners) match the style's (an item's defaults, when the style set none) take the new ones, items with their own keep them; the text of frames still in the old paragraph styles takes the new ones → {name, updated, restyled}", has_doc, edit_object ), diff --git a/crates/engine/src/cmd/style/object.rs b/crates/engine/src/cmd/style/object.rs index fb2467e5f..6c7455099 100644 --- a/crates/engine/src/cmd/style/object.rs +++ b/crates/engine/src/cmd/style/object.rs @@ -4,6 +4,18 @@ pub(super) 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 = crate::cmd::targets(s, p)?; + // Nothing to record when every target already has the style and its attributes, and its text + // the style's paragraph styles. + let d = &s.doc()?.doc; + let unchanged = ids.iter().filter_map(|id| d.item(*id)).all(|it| { + let mut next = it.clone(); + next.object_style = name.clone(); + os.apply_to(&mut next); + next == *it && it.text_frame().and_then(|tf| d.story(tf.story)).is_none_or(|st| os.story_follows(&d.styles, st)) + }); + if unchanged { + return Ok(Value::Null); + } s.edit(|d, _| { let mut stories = Vec::new(); for id in &ids { @@ -170,19 +182,33 @@ fn paragraph_style_param(cmd: &str, s: &Session, p: &Value) -> Result, ) -> Result>> { - let tf = group_param(cmd, p, "textFrame", base.unwrap_or(&designcraft_doc::TextFrameOptions::default()))?; + let general = designcraft_doc::TextFrameOptions::GENERAL_KEYS; + let mut p = p.clone(); + if let Some(Value::Object(o)) = p.get_mut("textFrame") { + if let Some(k) = o.keys().find(|k| !general.contains(&k.as_str())) { + return Err(bad(cmd, format!("textFrame.{k} is not a General option ({})", general.join(", ")))); + } + if let Some(n) = o.get("inset").filter(|v| v.is_number()).cloned() { + o.insert("inset".into(), json!([n, n, n, n])); + } + } + let tf = group_param(cmd, &p, "textFrame", base.unwrap_or(&designcraft_doc::TextFrameOptions::default()))?; if let Some(Some(o)) = &tf { - if !(1..=40).contains(&o.columns) { + // Only the values given in this call are checked. + let given = |k: &str| p.get("textFrame").is_some_and(|t| t.get(k).is_some()); + if given("columns") && !(1..=40).contains(&o.columns) { return Err(bad(cmd, "text frame columns must be 1–40")); } - let mut lengths = [o.gutter, o.column_width, o.vj_paragraph_spacing_limit, o.first_baseline_min].into_iter().chain(o.inset); + let lengths = [("gutter", o.gutter), ("columnWidth", o.column_width), ("vjParagraphSpacingLimit", o.vj_paragraph_spacing_limit)]; + let mut lengths = lengths.into_iter().filter(|(k, _)| given(k)).map(|(_, v)| v).chain(o.inset.into_iter().filter(|_| given("inset"))); if !lengths.all(|v| v.is_finite() && (0.0..=15552.0).contains(&v)) { return Err(bad(cmd, "a text frame's gutter, widths and insets must be 0–15552 pt")); } @@ -360,7 +386,7 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { || 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.named_to_path(&it.path)) - || frame.as_ref().is_some_and(|(o, _)| it.text_frame().is_some_and(|t| t.options == *o)) + || frame.as_ref().is_some_and(|(o, _)| it.text_frame().is_some_and(|t| t.options.general_eq(o))) }; let updated = d.update_items( |it| it.object_style == new.name && follows(it), @@ -387,9 +413,9 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { } if let Some((o, n)) = &frame && let Some(tf) = it.text_frame_mut() - && tf.options == *o + && tf.options.general_eq(o) { - tf.options = n.clone(); + tf.options.set_general(n); } }, ); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fad29a1d7..48cb02446 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -882,6 +882,78 @@ fn object_styles_carry_a_paragraph_style_and_text_frame_options() { assert_eq!((styles_of(&s, a), options(&s, a).columns), (vec!["Box Body Text".to_string(); 3], 2)); } +/// An object style's Text Frame General Options set the columns, insets, vertical justification +/// and Ignore Text Wrap only: a frame keeps its auto-size and column rule, follows an edit of the +/// style while its General options are the style's, and keeps the settings through save and open. +/// Applying a style that changes nothing records no undo step, threaded frames share one Next +/// Style chain, and keys outside the General options are refused. +#[test] +fn object_style_text_frame_general_options_leave_the_other_frame_settings() { + use designcraft_doc::{AutoSize, ItemId, VerticalJustification}; + let mut s = session(); + s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Lead", "nextStyle": "Body"})).unwrap(); + let name = "Callout"; + s.execute( + "style.object.create", + &json!({"name": name, "fromSelection": false, "paragraphStyle": "Lead", "applyNextStyle": true, + "textFrame": {"inset": 6, "verticalJustification": "center"}}), + ) + .unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 156, 200], "content": "text", "text": "One\nTwo", "caret": false})).unwrap(); + let a = r["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 36, 320, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.textFrameOptions", &json!({"ids": [a], "autoSize": "heightOnly", "columnRule": true})).unwrap(); + s.execute("style.object.apply", &json!({"name": name, "ids": [a]})).unwrap(); + let options = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().text_frame().unwrap().options.clone(); + let o = options(&s, a); + assert_eq!((o.inset, o.vertical_justification), ([6.0; 4], VerticalJustification::Center)); + assert_eq!((o.auto_size, o.column_rule), (AutoSize::HeightOnly, true), "the frame's own auto-size and column rule stay"); + + // Applying it again changes nothing and records no undo step. + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = undo(&s); + s.execute("style.object.apply", &json!({"name": name, "ids": [a]})).unwrap(); + assert_eq!(undo(&s), before, "no undo step when nothing changes"); + + // The frame differs from the style's options only in auto-size, so it follows the edit. + let r = s.execute("style.object.edit", &json!({"name": name, "textFrame": {"inset": [1, 2, 3, 4], "ignoreWrap": true}})).unwrap(); + assert_eq!(r["updated"], 1); + let o = options(&s, a); + assert_eq!((o.inset, o.ignore_wrap, o.auto_size), ([1.0, 2.0, 3.0, 4.0], true, AutoSize::HeightOnly)); + + // Threaded frames: the story's paragraphs take one Next Style chain across both frames. + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + s.execute("style.object.apply", &json!({"name": name, "ids": [a, b]})).unwrap(); + let d = &s.doc().unwrap().doc; + let sid = d.item(ItemId(a)).unwrap().text_frame().unwrap().story; + assert_eq!(d.item(ItemId(b)).unwrap().text_frame().unwrap().story, sid); + assert_eq!(d.story(sid).unwrap().paras.iter().map(|p| p.style.as_str()).collect::>(), ["Lead", "Body"]); + + // Save and open keep the style's General options and Apply Next Style. + 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 os = back.doc().unwrap().doc.styles.object_style(name).cloned().unwrap(); + assert!(os.apply_next_style); + assert_eq!(os.text_frame.map(|t| (t.inset, t.ignore_wrap)), Some(([1.0, 2.0, 3.0, 4.0], true))); + + // Keys outside the General options, a misspelled key and a word for the inset are refused. + for bad in [json!({"autoSize": "heightOnly"}), json!({"insets": 4}), json!({"inset": "wide"}), json!({"verticalJustification": "middle"})] { + let p = json!({"name": name, "textFrame": bad}); + assert!(s.execute("style.object.edit", &p).is_err(), "{bad}"); + } + // A style that already holds 60 columns (as a file can give it) takes other edits, and 60 + // given again is refused. + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let st = d.styles_mut().object.iter_mut().find(|o| o.name == name).unwrap(); + st.text_frame.as_mut().unwrap().columns = 60; + } + s.execute("style.object.edit", &json!({"name": name, "textFrame": {"inset": 2}})).unwrap(); + assert!(s.execute("style.object.edit", &json!({"name": name, "textFrame": {"columns": 60}})).is_err()); +} + #[test] fn object_style_effects_reach_grouped_and_anchored_items() { use designcraft_doc::ItemId; diff --git a/crates/idml/src/import/styles.rs b/crates/idml/src/import/styles.rs index 5fa1aa87e..e82d8fc0b 100644 --- a/crates/idml/src/import/styles.rs +++ b/crates/idml/src/import/styles.rs @@ -197,8 +197,16 @@ impl<'r> Importer<'r> { let paragraph_style = enabled("EnableParagraphStyle", "AppliedParagraphStyle") .then(|| e.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r))) .flatten(); + let levels = based_on_levels(e, &objects_by_self); + // Text Frame General Options: each attribute from the nearest style whose + // `TextFramePreference` sets it, among the styles that don't turn the category off. let text_frame = if e.get("EnableTextFrameGeneralOptions") == Some("true") { - e.find("TextFramePreference").map(|t| self.frame_options(&[t])) + let prefs: Vec<&El> = levels + .iter() + .filter(|l| l.get("EnableTextFrameGeneralOptions") != Some("false")) + .filter_map(|l| l.find("TextFramePreference")) + .collect(); + (!prefs.is_empty()).then(|| self.frame_options(&prefs)) } else { None }; @@ -207,19 +215,7 @@ impl<'r> Importer<'r> { // 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, "") - }) + .then(|| corners_in(&levels, "")) .flatten() .map(|c| CornerOptions { corners: c }); let apply_next_style = paragraph_style.is_some() && e.get("ApplyNextParagraphStyle") == Some("true"); @@ -421,3 +417,23 @@ impl<'r> Importer<'r> { } } } + +/// Object style `e`, then the styles it is based on, nearest first (cycle-safe, at most 16). A +/// `BasedOn` may name a style by its `Self` id or by its bare name. +fn based_on_levels<'e>(e: &'e El, objects_by_self: &HashMap<&str, &'e El>) -> Vec<&'e El> { + let id = |b: String| { + let b = b.trim(); + if b.starts_with("ObjectStyle/") { b.to_string() } else { format!("ObjectStyle/{}", names::escape_id(b)) } + }; + let mut levels = vec![e]; + let mut next = e.prop("BasedOn").map(id); + while let Some(b) = next.take() + && levels.len() < 16 + && let Some(s) = objects_by_self.get(b.as_str()).copied() + && !levels.iter().any(|l| std::ptr::eq(*l, s)) + { + levels.push(s); + next = s.prop("BasedOn").map(id); + } + levels +} diff --git a/crates/idml/src/tests/object_styles.rs b/crates/idml/src/tests/object_styles.rs index faaef4f32..52605d7a0 100644 --- a/crates/idml/src/tests/object_styles.rs +++ b/crates/idml/src/tests/object_styles.rs @@ -177,6 +177,43 @@ fn object_style_apply_next_style_round_trips() { } } +/// A style's Text Frame General Options take each setting its own `TextFramePreference` leaves out +/// from the style it is based on (named by its id or bare name, and only when that style doesn't +/// turn the category off), and export and import keep the merged options. +#[test] +fn object_style_text_frame_options_inherit_from_the_based_on_style() { + let styles = r#" + $ID/[None] + + + + ObjectStyle/Box + + + + Box + + + + $ID/[None] + + + + ObjectStyle/Off + + "#; + let d = fixture(styles, &[]); + let tf = d.styles.object_style("Inset").and_then(|s| s.text_frame.clone()).expect("Inset sets text frame options"); + assert_eq!((tf.inset, tf.columns, tf.vertical_justification), ([9.0; 4], 2, designcraft_doc::VerticalJustification::Center)); + let bare = d.styles.object_style("Bare").and_then(|s| s.text_frame.clone()).expect("Bare sets text frame options"); + assert_eq!((bare.inset, bare.columns), ([3.0; 4], 2), "a BasedOn given as a bare name"); + let on_off = d.styles.object_style("OnOff").and_then(|s| s.text_frame.clone()).expect("OnOff sets text frame options"); + assert_eq!((on_off.inset, on_off.columns), ([1.0; 4], 1), "a based-on style with the category off gives nothing"); + let back = import_idml(&export_idml(&d)).unwrap(); + let again = back.styles.object_style("Inset").and_then(|s| s.text_frame.clone()).expect("after a round trip"); + assert!(again.general_eq(&tf), "{again:?}"); +} + #[test] fn exported_frames_keep_their_own_values_over_their_object_style() { let mut d = small_doc(); diff --git a/crates/ui-egui/src/i18n/hu.rs b/crates/ui-egui/src/i18n/hu.rs index ab30bce8d..bbf3a6ed3 100644 --- a/crates/ui-egui/src/i18n/hu.rs +++ b/crates/ui-egui/src/i18n/hu.rs @@ -1979,6 +1979,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Measure Tool", "Mérőeszköz"), ("Hand Tool", "Kéz eszköz"), ("Zoom Tool", "Nagyító eszköz"), + ("Apply Next Style", "Következő stílus alkalmazása"), + ("Text Frame General Options", "Szövegkeret általános beállításai"), + ("New Object Style", "Új objektumstílus"), + ("Object Style Options", "Objektumstílus beállításai"), + ("Object Style Options…", "Objektumstílus beállításai…"), + ("Basic Attributes", "Alapattribútumok"), + ("Stroke & Corner Options", "Körvonal- és sarokbeállítások"), + ("Effects for:", "Hatások ehhez:"), + ("Create New Style", "Új stílus létrehozása"), + ("Clear Overrides", "Felülírások törlése"), + ("Shape:", "Alakzat:"), + ("(no attributes)", "(nincsenek attribútumok)"), + ("Enter a style name.", "Adjon meg egy stílusnevet."), ]; pub(super) const CONTEXT: &[(&str, &str, &str)] = diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index f3b0e7df0..2c5f6d356 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -41,7 +41,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Text Frame General Options", "Opções gerais do quadro de texto"), ("New Object Style", "Novo estilo de objeto"), ("Object Style Options", "Opções de estilo de objeto"), - ("Object Style Options…", "Opções de estilo de objeto…"), ("Basic Attributes", "Atributos básicos"), ("Stroke & Corner Options", "Opções de traçado e canto"), ("Effects for:", "Efeitos para:"), @@ -50,7 +49,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Shape:", "Forma:"), ("(no attributes)", "(sem atributos)"), ("Enter a style name.", "Digite um nome de estilo."), - ("Corner Options", "Opções de canto"), ("Keep In Frame", "Manter no quadro"), ("New Paragraph Style", "Novo estilo de parágrafo"), ("New Character Style", "Novo estilo de caractere"), diff --git a/crates/ui-egui/src/i18n/sv.rs b/crates/ui-egui/src/i18n/sv.rs index 766ba32da..fe85de091 100644 --- a/crates/ui-egui/src/i18n/sv.rs +++ b/crates/ui-egui/src/i18n/sv.rs @@ -2968,6 +2968,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "Links and Images: {found} linked, {modified} changed, {missing} not found", "Länkar och bilder: {found} länkade, {modified} ändrade, {missing} hittades inte", ), + ("Apply Next Style", "Använd nästa stil"), + ("Text Frame General Options", "Allmänna alternativ för textram"), + ("New Object Style", "Ny objektstil"), + ("Object Style Options", "Alternativ för objektstil"), + ("Basic Attributes", "Grundläggande attribut"), + ("Stroke & Corner Options", "Kontur- och hörnalternativ"), + ("Effects for:", "Effekter för:"), + ("Create New Style", "Skapa ny stil"), + ("Clear Overrides", "Rensa lokala ändringar"), + ("Shape:", "Form:"), + ("(no attributes)", "(inga attribut)"), + ("Enter a style name.", "Ange ett stilnamn."), ]; pub(super) const CONTEXT: &[(&str, &str, &str)] = &[ diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs index 5ce0d56a5..bdac19645 100644 --- a/crates/ui-egui/src/object_style_dialog.rs +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -153,10 +153,22 @@ fn frame_options(d: &Dialog) -> Result *side = measure(key).ok_or_else(|| bad("an inset is not a measure"))?; } o.vertical_justification = serde_json::from_value(json!(d.s("verticalJustification"))).unwrap_or(o.vertical_justification); + if o.vertical_justification == designcraft_doc::VerticalJustification::Justify { + o.vj_paragraph_spacing_limit = measure("paragraphSpacingLimit").ok_or_else(|| bad("the paragraph spacing limit is not a measure"))?; + } o.ignore_wrap = d.b("ignoreWrap"); Ok(Some(o)) } +/// The General options of `o` as the `textFrame` parameter of `style.object.create` and `edit`. +fn general_json(o: &designcraft_doc::TextFrameOptions) -> Value { + let mut v = serde_json::to_value(o).unwrap_or_default(); + if let Value::Object(m) = &mut v { + m.retain(|k, _| designcraft_doc::TextFrameOptions::GENERAL_KEYS.contains(&k.as_str())); + } + v +} + /// OK: the new style (applied to the selection when asked), or the edited one. pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { let name = d.s("name").trim().to_string(); @@ -164,7 +176,7 @@ pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { return Err(crate::i18n::tr(&app.ui.language, "Enter a style name.").to_string()); } let mut p = attrs(d); - p.insert("textFrame".into(), frame_options(d)?.map_or(Value::Null, |o| serde_json::to_value(o).unwrap_or_default())); + p.insert("textFrame".into(), frame_options(d)?.map_or(Value::Null, |o| general_json(&o))); if d.b("new") { p.insert("name".into(), json!(name)); p.insert("fromSelection".into(), json!(false)); @@ -694,6 +706,31 @@ mod tests { assert_eq!(os.text_frame.map(|t| t.columns), Some(2), "2.5 columns changed nothing"); } + /// Text Frame General Options › Vertical Justification: Justify with a Paragraph Spacing Limit + /// sets both on the style and its frame, and the frame keeps its auto-size setting. + #[test] + fn the_general_options_page_sets_the_paragraph_spacing_limit() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [36, 36, 336, 336], "content": "text", "caret": false})).unwrap(); + let id = r["id"].as_u64().unwrap(); + app.run("object.textFrameOptions", json!({"ids": [id], "autoSize": "heightOnly"})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + open_new(&mut app); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("name".into(), json!("Justified")); + d.fields.insert("verticalJustification".into(), json!("justify")); + d.fields.insert("paragraphSpacingLimit".into(), json!("6 pt")); + } + crate::dialogs::confirm(&mut app).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let tf = doc.styles.object_style("Justified").and_then(|s| s.text_frame.clone()).unwrap(); + assert_eq!((tf.vertical_justification, tf.vj_paragraph_spacing_limit), (designcraft_doc::VerticalJustification::Justify, 6.0)); + app.run("style.object.apply", json!({"name": "Justified", "ids": [id]})).unwrap(); + let o = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.clone(); + assert_eq!((o.vj_paragraph_spacing_limit, o.auto_size), (6.0, designcraft_doc::AutoSize::HeightOnly)); + } + /// `objectStyle.dialog` with a style the document lacks, or `[None]`, is an error and opens /// nothing; without a name it opens New Object Style. #[test] From 16d60dc03a54695731c770911ac222ce251074d0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:04:28 -0800 Subject: [PATCH 27/36] Object styles: options follow one by one; corner sizes are checked - Editing a style's Text Frame General Options updates each option a frame still shares with the style, so a frame's own inset stays while a new column count reaches it. - style.object.create and edit refuse corner sizes outside 0-10000 pt instead of clamping them. - IDML import: a style that turns Stroke & Corner Options on without corner attributes of its own takes all four corners from the styles it is based on. --- crates/doc/src/item/text_frame.rs | 16 ++++++++++++++++ crates/engine/src/cmd/style/object.rs | 15 +++++++-------- crates/engine/src/tests.rs | 11 +++++++++++ crates/idml/src/import/styles.rs | 23 ++++++++++++++++------- crates/idml/src/tests/object_styles.rs | 12 ++++++++++++ 5 files changed, 62 insertions(+), 15 deletions(-) diff --git a/crates/doc/src/item/text_frame.rs b/crates/doc/src/item/text_frame.rs index 5c01bb21b..cb437e149 100644 --- a/crates/doc/src/item/text_frame.rs +++ b/crates/doc/src/item/text_frame.rs @@ -268,6 +268,22 @@ impl TextFrameOptions { && self.ignore_wrap == other.ignore_wrap } + /// Each General option that is `old`'s here takes `new`'s; the others stay. `true` when one + /// changed. + pub fn follow_general(&mut self, old: &TextFrameOptions, new: &TextFrameOptions) -> bool { + let mut changed = false; + macro_rules! follow { + ($($f:ident),*) => {$( + if self.$f == old.$f && self.$f != new.$f { + self.$f = new.$f; + changed = true; + } + )*}; + } + follow!(columns, columns_kind, gutter, column_width, balance_columns, inset, vertical_justification, vj_paragraph_spacing_limit, ignore_wrap); + changed + } + /// Copies the General options of `from` (see [`Self::GENERAL_KEYS`]). Baseline, auto-size, /// column rule and Type on a Path settings stay as they are. pub fn set_general(&mut self, from: &TextFrameOptions) { diff --git a/crates/engine/src/cmd/style/object.rs b/crates/engine/src/cmd/style/object.rs index 6c7455099..50f38e835 100644 --- a/crates/engine/src/cmd/style/object.rs +++ b/crates/engine/src/cmd/style/object.rs @@ -293,11 +293,9 @@ fn corners_param( let size = sz.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "corner size must be a number"))?; c.corners.iter_mut().for_each(|k| k.size = size); } - for k in &mut c.corners { - if !k.size.is_finite() { - return Err(bad(cmd, "corner sizes must be finite")); - } - k.size = k.size.clamp(0.0, 10_000.0); + // Sizes given in this call must be 0–10000 pt. + if (o.contains_key("corners") || o.contains_key("size")) && !c.corners.iter().all(|k| k.size.is_finite() && (0.0..=10_000.0).contains(&k.size)) { + return Err(bad(cmd, "corner sizes must be 0–10000 pt")); } Ok(Some(Some(c))) } @@ -386,7 +384,7 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { || 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.named_to_path(&it.path)) - || frame.as_ref().is_some_and(|(o, _)| it.text_frame().is_some_and(|t| t.options.general_eq(o))) + || frame.as_ref().is_some_and(|(o, n)| it.text_frame().is_some_and(|t| t.options.clone().follow_general(o, n))) }; let updated = d.update_items( |it| it.object_style == new.name && follows(it), @@ -411,11 +409,12 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { { it.corners = n.named_to_path(&it.path); } + // Each General option follows on its own, so a frame's own inset stays while a + // new column count reaches it. if let Some((o, n)) = &frame && let Some(tf) = it.text_frame_mut() - && tf.options.general_eq(o) { - tf.options.set_general(n); + tf.options.follow_general(o, n); } }, ); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 48cb02446..d559fd7d2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -792,6 +792,12 @@ fn object_styles_carry_corners_and_their_stroke_and_corners_can_be_edited() { assert!(s.execute("style.object.edit", &json!({"name": "Framed", "rename": "[Basic Text Frame]"})).is_err()); assert!(s.execute("style.object.edit", &json!({"name": "[Basic Graphics Frame]", "rename": "Mine"})).is_err()); assert!(s.execute("style.object.edit", &json!({"name": "Framed", "corners": {"size": "big"}})).is_err()); + // A size outside 0–10000 pt is refused, not changed. + for size in [-1.0, 10_001.0] { + assert!(s.execute("style.object.edit", &json!({"name": "Framed", "corners": {"size": size}})).is_err(), "{size}"); + let c = json!({"shape": "rounded", "size": size}); + assert!(s.execute("style.object.create", &json!({"name": "Odd", "corners": {"corners": [c, c, c, c]}})).is_err(), "{size}"); + } // A negative stroke weight is refused when creating a style as when editing one. let styles = s.doc().unwrap().doc.styles.object.len(); for cmd in ["style.object.create", "style.object.edit"] { @@ -921,6 +927,11 @@ fn object_style_text_frame_general_options_leave_the_other_frame_settings() { assert_eq!(r["updated"], 1); let o = options(&s, a); assert_eq!((o.inset, o.ignore_wrap, o.auto_size), ([1.0, 2.0, 3.0, 4.0], true, AutoSize::HeightOnly)); + // Each option follows on its own: the frame's own inset stays and the new column count arrives. + s.execute("object.textFrameOptions", &json!({"ids": [a], "inset": 9})).unwrap(); + s.execute("style.object.edit", &json!({"name": name, "textFrame": {"columns": 3}})).unwrap(); + let o = options(&s, a); + assert_eq!((o.columns, o.inset), (3, [9.0; 4])); // Threaded frames: the story's paragraphs take one Next Style chain across both frames. s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); diff --git a/crates/idml/src/import/styles.rs b/crates/idml/src/import/styles.rs index e82d8fc0b..e2e11e979 100644 --- a/crates/idml/src/import/styles.rs +++ b/crates/idml/src/import/styles.rs @@ -211,13 +211,7 @@ impl<'r> Importer<'r> { None }; let effects = self.style_effects(e); - // 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(|| corners_in(&levels, "")) - .flatten() - .map(|c| CornerOptions { corners: c }); + let corners = style_corners(e, &levels); 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) { @@ -418,6 +412,21 @@ impl<'r> Importer<'r> { } } +/// Object style `e`'s corner options (Stroke & Corner Options), by name, when it sets them. A style +/// that sets some corners, or turns the category on, takes the others from the styles in `levels` +/// (`e` and the styles it is based on) that don't turn the category off. +fn style_corners(e: &El, levels: &[&El]) -> Option { + match e.get("EnableStrokeAndCornerOptions") { + Some("false") => return None, + Some("true") => {} + _ => { + corners(e, "")?; + } + } + let on: Vec<&El> = levels.iter().copied().filter(|l| l.get("EnableStrokeAndCornerOptions") != Some("false")).collect(); + corners_in(&on, "").map(|c| CornerOptions { corners: c }) +} + /// Object style `e`, then the styles it is based on, nearest first (cycle-safe, at most 16). A /// `BasedOn` may name a style by its `Self` id or by its bare name. fn based_on_levels<'e>(e: &'e El, objects_by_self: &HashMap<&str, &'e El>) -> Vec<&'e El> { diff --git a/crates/idml/src/tests/object_styles.rs b/crates/idml/src/tests/object_styles.rs index 52605d7a0..4ff0fa6bf 100644 --- a/crates/idml/src/tests/object_styles.rs +++ b/crates/idml/src/tests/object_styles.rs @@ -314,6 +314,18 @@ fn a_style_setting_one_corner_inherits_the_others_and_names_them() { assert_eq!(it.corners.corners.map(|c| c.shape), [CornerShape::Rounded, CornerShape::Rounded, CornerShape::Rounded, CornerShape::Bevel]); } +/// A style that turns Stroke & Corner Options on without corner attributes of its own takes all +/// four corners from the style it is based on. +#[test] +fn a_style_turning_corners_on_inherits_all_four() { + let child = r#" + ObjectStyle/Round + "#; + let d = fixture(&format!("{ROUND}{child}"), &[]); + let c = d.styles.object.iter().find(|s| s.name == "Plain").and_then(|s| s.corners).expect("inherited corners").corners; + assert_eq!((c.map(|c| c.shape), c.map(|c| c.size)), ([CornerShape::Rounded; 4], [17.0; 4])); +} + /// An object style giving its corners only with the legacy all-corners `CornerOption` and /// `CornerRadius` keeps them, so applying it to another frame rounds it. #[test] From 4973d11569d99ea0b2ad9e6ca7944895218ff4cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:15:03 -0800 Subject: [PATCH 28/36] Object styles: unknown swatches and settings are refused - style.object.create and edit refuse a fill, stroke or gap swatch the document lacks, and a fill or stroke key that is not a setting of it (a misspelled "wieght" no longer succeeds without a change). - fromSelection must be true or false. --- crates/engine/src/cmd/style.rs | 4 +-- crates/engine/src/cmd/style/object.rs | 46 ++++++++++++++++++++++----- crates/engine/src/tests.rs | 15 +++++++++ 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 5b1ac047d..7e55e0529 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -182,7 +182,7 @@ pub fn specs() -> Vec { "New Object Style…", [], None, - "{name, fromSelection?: true (fill, stroke, corners, effects and text frame options of the first selected item), fill?: swatch | {swatch?, tint?}, stroke?: {swatch?, weight?, tint?, type?, …}, corners?: {shape?, size?} | {corners: [{shape, size}] ×4}, paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), 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)", + "{name, fromSelection?: true (fill, stroke, corners, effects and text frame options of the first selected item), fill?: swatch | {swatch?, tint?}, stroke?: {swatch?, weight?, tint?, type?, …} (swatches must exist; unknown fill and stroke keys are refused), corners?: {shape?, size?} | {corners: [{shape, size}] ×4}, paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), 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 ), @@ -191,7 +191,7 @@ pub fn specs() -> Vec { "Object Style Options…", [], None, - "{name, rename?: new name (every use follows), fill?: swatch | {swatch?, tint?} | null, stroke?: {swatch?, weight?, tint?, type?, …} | null, corners?: {shape?: none|rounded|inverseRounded|inset|bevel|fancy, size?} | {corners: [{shape, size}] ×4} | null (null: the style no longer sets it; items keep theirs), paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), 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 (fill, stroke, corners) match the style's (an item's defaults, when the style set none) take the new ones, items with their own keep them; the text of frames still in the old paragraph styles takes the new ones → {name, updated, restyled}", + "{name, rename?: new name (every use follows), fill?: swatch | {swatch?, tint?} | null, stroke?: {swatch?, weight?, tint?, type?, …} | null (swatches must exist; unknown fill and stroke keys are refused), corners?: {shape?: none|rounded|inverseRounded|inset|bevel|fancy, size?} | {corners: [{shape, size}] ×4} | null (null: the style no longer sets it; items keep theirs), paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), 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 (fill, stroke, corners) match the style's (an item's defaults, when the style set none) take the new ones, items with their own keep them; the text of frames still in the old paragraph styles takes the new ones → {name, updated, restyled}", has_doc, edit_object ), diff --git a/crates/engine/src/cmd/style/object.rs b/crates/engine/src/cmd/style/object.rs index 50f38e835..2a8ec7dd3 100644 --- a/crates/engine/src/cmd/style/object.rs +++ b/crates/engine/src/cmd/style/object.rs @@ -131,7 +131,9 @@ fn merge_json(base: &mut Value, top: Value, depth: usize) { pub(super) 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); + const CREATE: &str = "style.object.create"; + check_swatches(CREATE, s, p)?; + let from_sel = bool_param(CREATE, p, "fromSelection")?.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 = ObjectStyle::default(); if let Some(it) = &src { @@ -141,7 +143,6 @@ pub(super) fn create_object(s: &mut Session, p: &Value) -> Result { os.text_frame = it.text_frame().map(|t| t.options.clone()); os.effects = Some(it.effects.clone()); } - const CREATE: &str = "style.object.create"; if let Some(f) = fill_param(CREATE, p, os.fill.as_ref())? { os.fill = f; } @@ -171,6 +172,23 @@ pub(super) fn create_object(s: &mut Session, p: &Value) -> Result { }) } +/// The swatches this call gives (`fill` as a name or `fill.swatch`, `stroke.swatch` and +/// `stroke.gapSwatch`) must be swatches of the document. +fn check_swatches(cmd: &str, s: &Session, p: &Value) -> Result<()> { + let doc = &s.doc()?.doc; + let given = [ + ("fill", p.get("fill").and_then(|f| f.as_str().or_else(|| f.get("swatch").and_then(Value::as_str)))), + ("stroke", p.get("stroke").and_then(|f| f.get("swatch")).and_then(Value::as_str)), + ("stroke gap", p.get("stroke").and_then(|f| f.get("gapSwatch")).and_then(Value::as_str)), + ]; + for (key, name) in given { + if let Some(name) = name { + crate::cmd::object::known_swatch(doc, name, cmd, key)?; + } + } + Ok(()) +} + /// `paragraphStyle`: a paragraph style's name, or null (the object style sets none). fn paragraph_style_param(cmd: &str, s: &Session, p: &Value) -> Result>> { match p.get("paragraphStyle") { @@ -200,7 +218,7 @@ fn text_frame_param( o.insert("inset".into(), json!([n, n, n, n])); } } - let tf = group_param(cmd, &p, "textFrame", base.unwrap_or(&designcraft_doc::TextFrameOptions::default()))?; + let tf = group_param(cmd, &p, "textFrame", base.unwrap_or(&designcraft_doc::TextFrameOptions::default()), &[])?; if let Some(Some(o)) = &tf { // Only the values given in this call are checked. let given = |k: &str| p.get("textFrame").is_some_and(|t| t.get(k).is_some()); @@ -228,7 +246,8 @@ fn bool_param(cmd: &str, p: &Value, key: &str) -> Result> { /// `p.stroke` merged over `base` (as [`group_param`]), refused when the weight isn't a number ≥ 0 /// or the tint isn't 0–1. fn stroke_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Stroke>) -> Result>> { - let st = group_param(cmd, p, "stroke", base.unwrap_or(&designcraft_doc::Stroke::default()))?; + let optional = ["gapOverprint", "gradientAngle", "gradientVector"]; + let st = group_param(cmd, p, "stroke", base.unwrap_or(&designcraft_doc::Stroke::default()), &optional)?; if let Some(Some(st)) = &st { if !(st.weight.is_finite() && st.weight >= 0.0) { return Err(bad(cmd, "stroke weight must be a number ≥ 0")); @@ -241,13 +260,23 @@ fn stroke_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Stroke>) -> } /// `p[key]`, an attribute group of a style, merged field by field over `base`: `None` when not -/// given, `Some(None)` for null (the style no longer sets it). -fn group_param(cmd: &str, p: &Value, key: &str, base: &T) -> Result>> { +/// given, `Some(None)` for null (the style no longer sets it). A field `base` doesn't write and +/// `optional` doesn't name is refused. +fn group_param( + cmd: &str, + p: &Value, + key: &str, + base: &T, + optional: &[&str], +) -> Result>> { match p.get(key) { None => Ok(None), Some(Value::Null) => Ok(Some(None)), - Some(v @ Value::Object(_)) => { + Some(v @ Value::Object(given)) => { let mut cur = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?; + if let Some(k) = given.keys().find(|k| cur.get(k.as_str()).is_none() && !optional.contains(&k.as_str())) { + return Err(bad(cmd, format!("{key}.{k} is not a {key} setting"))); + } merge_json(&mut cur, v.clone(), 0); serde_json::from_value(cur).map(|t| Some(Some(t))).map_err(|e| bad(cmd, format!("{key}: {e}"))) } @@ -260,7 +289,7 @@ fn fill_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Fill>) -> Res if let Some(sw) = p.get("fill").and_then(Value::as_str) { return Ok(Some(Some(designcraft_doc::Fill::swatch(sw)))); } - let fill = group_param(cmd, p, "fill", base.unwrap_or(&designcraft_doc::Fill::default()))?; + let fill = group_param(cmd, p, "fill", base.unwrap_or(&designcraft_doc::Fill::default()), &["gradientAngle", "gradientVector"])?; if let Some(Some(f)) = &fill && !(f.tint.is_finite() && (0.0..=1.0).contains(&f.tint)) { @@ -307,6 +336,7 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { 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}`")))?; + check_swatches(CMD, s, p)?; let mut new = old.clone(); if p.get("effects").is_some_and(Value::is_null) { if EFFECT_KEYS.iter().any(|k| p.get(k).is_some()) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d559fd7d2..526f421a8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -792,6 +792,21 @@ fn object_styles_carry_corners_and_their_stroke_and_corners_can_be_edited() { assert!(s.execute("style.object.edit", &json!({"name": "Framed", "rename": "[Basic Text Frame]"})).is_err()); assert!(s.execute("style.object.edit", &json!({"name": "[Basic Graphics Frame]", "rename": "Mine"})).is_err()); assert!(s.execute("style.object.edit", &json!({"name": "Framed", "corners": {"size": "big"}})).is_err()); + // An unknown swatch, a misspelled setting and a word for a switch are refused. + for bad in [ + json!({"fill": "Nope"}), + json!({"fill": {"swatch": "Nope"}}), + json!({"stroke": {"swatch": "Nope"}}), + json!({"stroke": {"wieght": 5}}), + json!({"fill": {"tnt": 0.5}}), + ] { + let mut p = bad.clone(); + p["name"] = json!("Framed"); + assert!(s.execute("style.object.edit", &p).is_err(), "{bad}"); + p["name"] = json!("Other"); + assert!(s.execute("style.object.create", &p).is_err(), "{bad}"); + } + assert!(s.execute("style.object.create", &json!({"name": "Other", "fromSelection": "yes"})).is_err()); // A size outside 0–10000 pt is refused, not changed. for size in [-1.0, 10_001.0] { assert!(s.execute("style.object.edit", &json!({"name": "Framed", "corners": {"size": size}})).is_err(), "{size}"); From 4282f30cf9b85b888898a5a581b0842efc5ca5c3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:23:48 -0800 Subject: [PATCH 29/36] Object styles: a stroke added to a style reaches shapes with the default stroke When a style that set no stroke gains one, its frames with no stroke and its drawn shapes still carrying the default 1 pt black stroke take it. A text or graphic frame with its own stroke keeps it. --- crates/engine/src/cmd/style/object.rs | 18 ++++++++++++------ crates/engine/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/style/object.rs b/crates/engine/src/cmd/style/object.rs index 2a8ec7dd3..dc5f81643 100644 --- a/crates/engine/src/cmd/style/object.rs +++ b/crates/engine/src/cmd/style/object.rs @@ -402,17 +402,23 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { // most frames). An attribute the style no longer sets stays as it is on the items. let effects = (new.effects != old.effects).then_some(new.effects.clone()).flatten().map(|e| (old.effects.clone().unwrap_or_default(), e)); let fill = (new.fill != old.fill).then_some(new.fill.clone()).flatten().map(|f| (old.fill.clone().unwrap_or_default(), f)); - let stroke = (new.stroke != old.stroke) - .then_some(new.stroke.clone()) - .flatten() - .map(|f| (old.stroke.clone().unwrap_or_else(designcraft_doc::Stroke::none), f)); + let stroke = (new.stroke != old.stroke).then_some(new.stroke.clone()).flatten().map(|f| (old.stroke.clone(), f)); + // With no stroke in the style before, an item with no stroke (a frame's default) or a + // 1 pt black one (a drawn shape's default) follows. + let stroke_follows = |it: &Item, old: &Option| match old { + Some(o) => it.stroke == *o, + None => { + it.stroke == designcraft_doc::Stroke::none() + || (matches!(it.content, designcraft_doc::Content::Unassigned) && it.stroke == designcraft_doc::Stroke::default()) + } + }; let corners = (new.corners != old.corners).then_some(new.corners).flatten().map(|c| (old.corners.unwrap_or_default(), c)); let frame = (new.text_frame != old.text_frame).then_some(new.text_frame.clone()).flatten().map(|t| (old.text_frame.clone().unwrap_or_default(), t)); let follows = |it: &Item| { 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) + || stroke.as_ref().is_some_and(|(o, _)| stroke_follows(it, o)) || corners.as_ref().is_some_and(|(o, _)| it.corners == o.named_to_path(&it.path)) || frame.as_ref().is_some_and(|(o, n)| it.text_frame().is_some_and(|t| t.options.clone().follow_general(o, n))) }; @@ -430,7 +436,7 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { it.fill = n.clone(); } if let Some((o, n)) = &stroke - && it.stroke == *o + && stroke_follows(it, o) { it.stroke = n.clone(); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 526f421a8..a871b70de 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -980,6 +980,28 @@ fn object_style_text_frame_general_options_leave_the_other_frame_settings() { assert!(s.execute("style.object.edit", &json!({"name": name, "textFrame": {"columns": 60}})).is_err()); } +/// A stroke added to a style that set none reaches a drawn shape still carrying its default 1 pt +/// stroke and a frame with none, and leaves a shape or text frame with its own stroke. +#[test] +fn a_stroke_added_to_an_object_style_reaches_default_strokes() { + use designcraft_doc::ItemId; + let mut s = session(); + s.execute("style.object.create", &json!({"name": "Boxed", "fromSelection": false})).unwrap(); + let frame = |s: &mut Session, x: f64, content: &str| { + s.execute("frame.create", &json!({"rect": [x, 36, x + 50.0, 86], "content": content, "caret": false})).unwrap()["id"].as_u64().unwrap() + }; + let (shape, text, own) = (frame(&mut s, 36.0, "unassigned"), frame(&mut s, 100.0, "text"), frame(&mut s, 200.0, "unassigned")); + let stroked_text = frame(&mut s, 300.0, "text"); + s.execute("object.stroke", &json!({"ids": [own], "weight": 9})).unwrap(); + s.execute("object.stroke", &json!({"ids": [stroked_text], "weight": 1})).unwrap(); + s.execute("style.object.apply", &json!({"name": "Boxed", "ids": [shape, text, own, stroked_text]})).unwrap(); + let r = s.execute("style.object.edit", &json!({"name": "Boxed", "stroke": {"weight": 4}})).unwrap(); + assert_eq!(r["updated"], 2, "{r}"); + let weight = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().stroke.weight; + assert_eq!((weight(&s, shape), weight(&s, text), weight(&s, own)), (4.0, 4.0, 9.0)); + assert_eq!(weight(&s, stroked_text), 1.0, "a text frame's own 1 pt stroke stays"); +} + #[test] fn object_style_effects_reach_grouped_and_anchored_items() { use designcraft_doc::ItemId; From 658a6e42dc4bbdcf76ec7c9c1b585d67a68f877a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:33:24 -0800 Subject: [PATCH 30/36] Object styles: edits reach text frames anchored in stories, cells and notes style.object.edit updates the attributes and paragraph styles of text frames anchored in stories, table cells and notes, as IDML import places them, as it does for frames on spreads. --- crates/engine/src/cmd/style/object.rs | 18 +++++------------- crates/engine/src/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/style/object.rs b/crates/engine/src/cmd/style/object.rs index dc5f81643..8a156d654 100644 --- a/crates/engine/src/cmd/style/object.rs +++ b/crates/engine/src/cmd/style/object.rs @@ -422,7 +422,7 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { || corners.as_ref().is_some_and(|(o, _)| it.corners == o.named_to_path(&it.path)) || frame.as_ref().is_some_and(|(o, n)| it.text_frame().is_some_and(|t| t.options.clone().follow_general(o, n))) }; - let updated = d.update_items( + let updated = d.update_placed_items( |it| it.object_style == new.name && follows(it), |it| { if let Some((o, n)) = &effects @@ -458,18 +458,10 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { // is in the old one's; when the style set none before, all of it does. let mut restyled = 0; if new.paragraph_style.is_some() && (new.paragraph_style != old.paragraph_style || new.apply_next_style != old.apply_next_style) { - let mut stories = Vec::new(); - for sp in d.spreads.iter().chain(d.parents.iter()) { - for it in &sp.items { - it.walk(&mut |i| { - if i.object_style == new.name - && let Some(tf) = i.text_frame() - { - stories.push(tf.story); - } - }); - } - } + // Frames on spreads and parents, in groups, and anchored in stories, table cells and + // notes (as IDML import places them). + let mut stories: Vec<_> = + d.placed_items().into_iter().filter(|i| i.object_style == new.name).filter_map(|i| i.text_frame().map(|tf| tf.story)).collect(); stories.sort_unstable(); stories.dedup(); let styles = d.styles.clone(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a871b70de..5824be21c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1002,6 +1002,31 @@ fn a_stroke_added_to_an_object_style_reaches_default_strokes() { assert_eq!(weight(&s, stroked_text), 1.0, "a text frame's own 1 pt stroke stays"); } +/// A text frame anchored in a story (as IDML import places one) takes a new paragraph style of +/// its object style like a frame on the page. +#[test] +fn an_object_style_edit_restyles_an_anchored_text_frame() { + use designcraft_doc::ItemId; + let mut s = session(); + s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Lead"})).unwrap(); + s.execute("style.object.create", &json!({"name": "Sidebar", "fromSelection": false, "paragraphStyle": "Body"})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 136, 86], "content": "text", "text": "Inner", "caret": false})).unwrap(); + let (inner, inner_story) = (ItemId(r["id"].as_u64().unwrap()), designcraft_doc::StoryId(r["story"].as_u64().unwrap())); + s.execute("style.object.apply", &json!({"name": "Sidebar", "ids": [inner.0]})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 200, 400, 400], "content": "text", "text": "Before after.", "caret": false})).unwrap(); + let host = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let it = d.item(inner).unwrap().clone(); + std::sync::Arc::make_mut(&mut d.spreads[0]).items.retain(|i| i.id != inner); + d.story_mut(host).unwrap().insert_object(7, designcraft_doc::AnchoredObject::new(it, Default::default())); + } + let r = s.execute("style.object.edit", &json!({"name": "Sidebar", "paragraphStyle": "Lead"})).unwrap(); + assert_eq!(r["restyled"], 1, "{r}"); + assert_eq!(s.doc().unwrap().doc.story(inner_story).unwrap().paras[0].style, "Lead"); +} + #[test] fn object_style_effects_reach_grouped_and_anchored_items() { use designcraft_doc::ItemId; From 2852a80c3e42f994b74bfe28db3a20644f9a148d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:42:18 -0800 Subject: [PATCH 31/36] Object styles: paragraph styles follow paragraph by paragraph; rename reaches every placement - Editing a style's paragraph style restyles each paragraph still in the old style; a paragraph given another style keeps it. - Renaming a style updates objects anchored in table cells and notes. - corners refuses keys other than shape, size and corners. --- crates/doc/src/styles.rs | 16 ++++++++++++++++ crates/engine/src/cmd/style/object.rs | 19 ++++++++++++++----- crates/engine/src/tests.rs | 22 ++++++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 98923762c..3a5ea58c3 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -115,6 +115,22 @@ impl ObjectStyle { self.paragraph_styles(styles, st.paras.len()).is_none_or(|want| st.paras.iter().zip(&want).all(|(f, w)| f.style == *w)) } + /// After an edit from `old`: the paragraphs of `st` still in the paragraph style `old` gives + /// them (every paragraph, when `old` set none) with the one this style gives each, where that + /// differs. A paragraph in another style is left out. + pub fn restyle_from(&self, old: &ObjectStyle, styles: &Styles, st: &Story) -> Vec<(usize, String)> { + let n = st.paras.len(); + let Some(want) = self.paragraph_styles(styles, n) else { return Vec::new() }; + let was = old.paragraph_styles(styles, n); + st.paras + .iter() + .zip(want) + .enumerate() + .filter(|(i, (f, w))| was.as_ref().is_none_or(|was| f.style == was[*i]) && f.style != *w) + .map(|(i, (_, w))| (i, w)) + .collect() + } + /// Put a story's paragraphs in the paragraph styles this style gives them; `false` when it /// sets none or they already are. pub fn restyle(&self, styles: &Styles, st: &mut Story) -> bool { diff --git a/crates/engine/src/cmd/style/object.rs b/crates/engine/src/cmd/style/object.rs index 8a156d654..69368f82c 100644 --- a/crates/engine/src/cmd/style/object.rs +++ b/crates/engine/src/cmd/style/object.rs @@ -309,6 +309,9 @@ fn corners_param( return Ok(Some(None)); } let o = v.as_object().ok_or_else(|| bad(cmd, "`corners` must be an object or null"))?; + if let Some(k) = o.keys().find(|k| !["shape", "size", "corners"].contains(&k.as_str())) { + return Err(bad(cmd, format!("corners.{k} is not a corner setting (shape, size, corners)"))); + } let mut c = if o.contains_key("corners") { serde_json::from_value::(v.clone()).map_err(|e| bad(cmd, format!("corners: {e}")))? } else { @@ -395,7 +398,7 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { *n = r.clone(); } } - d.update_items(|it| it.object_style == name, |it| it.object_style = r.clone()); + d.update_placed_items(|it| it.object_style == name, |it| it.object_style = r.clone()); } // An item follows each attribute the style sets while it has the style's value. When the // style didn't set it before, items with the default follow (the default styles are on @@ -454,8 +457,8 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { } }, ); - // The text of the frames using the style takes a new paragraph style (or chain) while it - // is in the old one's; when the style set none before, all of it does. + // Each paragraph of the frames using the style takes the new paragraph style (or chain) + // while it is in the old one's; when the style set none before, every paragraph does. let mut restyled = 0; if new.paragraph_style.is_some() && (new.paragraph_style != old.paragraph_style || new.apply_next_style != old.apply_next_style) { // Frames on spreads and parents, in groups, and anchored in stories, table cells and @@ -466,10 +469,16 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { stories.dedup(); let styles = d.styles.clone(); for sid in stories { - if d.story(sid).is_some_and(|st| old.story_follows(&styles, st)) + let changes = d.story(sid).map(|st| new.restyle_from(&old, &styles, st)).unwrap_or_default(); + if !changes.is_empty() && let Some(st) = d.story_mut(sid) - && new.restyle(&styles, st) { + for (i, w) in changes { + if let Some(f) = st.paras.get_mut(i) { + f.style = w; + } + } + st.rev = st.rev.wrapping_add(1); restyled += 1; } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5824be21c..37e8939ae 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -799,6 +799,7 @@ fn object_styles_carry_corners_and_their_stroke_and_corners_can_be_edited() { json!({"stroke": {"swatch": "Nope"}}), json!({"stroke": {"wieght": 5}}), json!({"fill": {"tnt": 0.5}}), + json!({"corners": {"shpae": "rounded"}}), ] { let mut p = bad.clone(); p["name"] = json!("Framed"); @@ -1002,6 +1003,27 @@ fn a_stroke_added_to_an_object_style_reaches_default_strokes() { assert_eq!(weight(&s, stroked_text), 1.0, "a text frame's own 1 pt stroke stays"); } +/// A paragraph-style edit of an object style reaches each paragraph still in the old style, and a +/// paragraph given another style keeps it. +#[test] +fn an_object_style_edit_restyles_paragraph_by_paragraph() { + use designcraft_doc::ItemId; + let mut s = session(); + for n in ["Body", "Lead", "Own"] { + s.execute("style.paragraph.create", &json!({"name": n})).unwrap(); + } + s.execute("style.object.create", &json!({"name": "Boxed", "fromSelection": false, "paragraphStyle": "Body"})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 336, 336], "content": "text", "text": "One\nTwo\nThree", "caret": false})).unwrap(); + let (id, story) = (r["id"].as_u64().unwrap(), r["story"].clone()); + s.execute("style.object.apply", &json!({"name": "Boxed", "ids": [id]})).unwrap(); + s.execute("text.select", &json!({"story": story, "anchor": 4, "focus": 7})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Own"})).unwrap(); + s.execute("style.object.edit", &json!({"name": "Boxed", "paragraphStyle": "Lead"})).unwrap(); + let d = &s.doc().unwrap().doc; + let sid = d.item(ItemId(id)).unwrap().text_frame().unwrap().story; + assert_eq!(d.story(sid).unwrap().paras.iter().map(|p| p.style.as_str()).collect::>(), ["Lead", "Own", "Lead"]); +} + /// A text frame anchored in a story (as IDML import places one) takes a new paragraph style of /// its object style like a frame on the page. #[test] From e2c6d7b36c55ce42a40533633f02fd9e3726d239 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:47:33 -0800 Subject: [PATCH 32/36] Object styles: a null fromSelection or applyNextStyle is refused --- crates/engine/src/cmd/style/object.rs | 2 +- crates/engine/src/tests.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/style/object.rs b/crates/engine/src/cmd/style/object.rs index 69368f82c..b22388372 100644 --- a/crates/engine/src/cmd/style/object.rs +++ b/crates/engine/src/cmd/style/object.rs @@ -237,7 +237,7 @@ fn text_frame_param( /// A true/false parameter, when given. fn bool_param(cmd: &str, p: &Value, key: &str) -> Result> { match p.get(key) { - None | Some(Value::Null) => Ok(None), + None => Ok(None), Some(Value::Bool(b)) => Ok(Some(*b)), Some(_) => Err(bad(cmd, format!("`{key}` must be true or false"))), } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 37e8939ae..5a8fc30e6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -808,6 +808,8 @@ fn object_styles_carry_corners_and_their_stroke_and_corners_can_be_edited() { assert!(s.execute("style.object.create", &p).is_err(), "{bad}"); } assert!(s.execute("style.object.create", &json!({"name": "Other", "fromSelection": "yes"})).is_err()); + assert!(s.execute("style.object.create", &json!({"name": "Other", "fromSelection": null})).is_err()); + assert!(s.execute("style.object.edit", &json!({"name": "Framed", "applyNextStyle": null})).is_err()); // A size outside 0–10000 pt is refused, not changed. for size in [-1.0, 10_001.0] { assert!(s.execute("style.object.edit", &json!({"name": "Framed", "corners": {"size": size}})).is_err(), "{size}"); From ffcd4e215fcc5c26485c536b33ded3539ab171b6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:55:29 -0800 Subject: [PATCH 33/36] Object styles: a rename that is not a name is refused --- crates/engine/src/cmd/style/object.rs | 3 ++ crates/engine/src/tests.rs | 59 ++++++++++++++++----------- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/style/object.rs b/crates/engine/src/cmd/style/object.rs index b22388372..a8572a3fa 100644 --- a/crates/engine/src/cmd/style/object.rs +++ b/crates/engine/src/cmd/style/object.rs @@ -367,6 +367,9 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { if let Some(tf) = text_frame_param(CMD, p, old.text_frame.as_ref())? { new.text_frame = tf; } + if p.get("rename").is_some_and(|r| !r.is_string()) { + return Err(bad(CMD, "`rename` must be a style name")); + } let rename = str_param(p, "rename").map(str::trim).filter(|r| *r != name).map(str::to_string); if let Some(r) = &rename { let builtin = name.starts_with('[') && name.ends_with(']'); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5a8fc30e6..8ffd9fc1e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -752,6 +752,41 @@ fn object_style_effects_follow_the_style_until_overridden() { assert_eq!(s.doc().unwrap().revision, rev); } +/// `style.object.create` and `edit` refuse an unknown swatch, a misspelled setting, a word or null +/// for a switch, a rename that isn't a name and a corner size outside 0–10000 pt. +#[test] +fn object_style_parameters_are_checked() { + let mut s = session(); + s.execute("style.object.create", &json!({"name": "Framed", "fromSelection": false})).unwrap(); + for bad in [ + json!({"fill": "Nope"}), + json!({"fill": {"swatch": "Nope"}}), + json!({"stroke": {"swatch": "Nope"}}), + json!({"stroke": {"wieght": 5}}), + json!({"fill": {"tnt": 0.5}}), + json!({"corners": {"shpae": "rounded"}}), + json!({"corners": {"size": -1}}), + json!({"corners": {"size": 10_001}}), + json!({"applyNextStyle": null}), + ] { + let mut p = bad.clone(); + p["name"] = json!("Framed"); + assert!(s.execute("style.object.edit", &p).is_err(), "{bad}"); + p["name"] = json!("Other"); + assert!(s.execute("style.object.create", &p).is_err(), "{bad}"); + } + let c = json!({"shape": "rounded", "size": -1}); + assert!(s.execute("style.object.create", &json!({"name": "Odd", "corners": {"corners": [c, c, c, c]}})).is_err()); + for from in [json!("yes"), json!(null)] { + assert!(s.execute("style.object.create", &json!({"name": "Other", "fromSelection": from})).is_err(), "{from}"); + } + for rename in [json!(3), json!(null), json!(true)] { + assert!(s.execute("style.object.edit", &json!({"name": "Framed", "rename": rename})).is_err(), "{rename}"); + } + let names: Vec<_> = s.doc().unwrap().doc.styles.object.iter().map(|o| o.name.clone()).collect(); + assert!(!names.iter().any(|n| n == "Other" || n == "Odd"), "{names:?}"); +} + /// The course's "Rounded Corner Image, White Stroke" style: made from a frame with rounded corners /// and a paper stroke, applied to another frame, then edited (a thicker stroke, bigger corners): /// the frames that follow the style change, one with its own stroke keeps it; a rename follows. @@ -792,30 +827,6 @@ fn object_styles_carry_corners_and_their_stroke_and_corners_can_be_edited() { assert!(s.execute("style.object.edit", &json!({"name": "Framed", "rename": "[Basic Text Frame]"})).is_err()); assert!(s.execute("style.object.edit", &json!({"name": "[Basic Graphics Frame]", "rename": "Mine"})).is_err()); assert!(s.execute("style.object.edit", &json!({"name": "Framed", "corners": {"size": "big"}})).is_err()); - // An unknown swatch, a misspelled setting and a word for a switch are refused. - for bad in [ - json!({"fill": "Nope"}), - json!({"fill": {"swatch": "Nope"}}), - json!({"stroke": {"swatch": "Nope"}}), - json!({"stroke": {"wieght": 5}}), - json!({"fill": {"tnt": 0.5}}), - json!({"corners": {"shpae": "rounded"}}), - ] { - let mut p = bad.clone(); - p["name"] = json!("Framed"); - assert!(s.execute("style.object.edit", &p).is_err(), "{bad}"); - p["name"] = json!("Other"); - assert!(s.execute("style.object.create", &p).is_err(), "{bad}"); - } - assert!(s.execute("style.object.create", &json!({"name": "Other", "fromSelection": "yes"})).is_err()); - assert!(s.execute("style.object.create", &json!({"name": "Other", "fromSelection": null})).is_err()); - assert!(s.execute("style.object.edit", &json!({"name": "Framed", "applyNextStyle": null})).is_err()); - // A size outside 0–10000 pt is refused, not changed. - for size in [-1.0, 10_001.0] { - assert!(s.execute("style.object.edit", &json!({"name": "Framed", "corners": {"size": size}})).is_err(), "{size}"); - let c = json!({"shape": "rounded", "size": size}); - assert!(s.execute("style.object.create", &json!({"name": "Odd", "corners": {"corners": [c, c, c, c]}})).is_err(), "{size}"); - } // A negative stroke weight is refused when creating a style as when editing one. let styles = s.doc().unwrap().doc.styles.object.len(); for cmd in ["style.object.create", "style.object.edit"] { From b0f282f607e5cae22116cb85adb0c63bc155a282 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 09:40:38 -0800 Subject: [PATCH 34/36] Object styles: the dialog changes only the document it opened for New Object Style and Object Style Options record the document they open on, with the epoch that File > Revert changes. OK is refused, and the dialog stays open with the reason, when another document is active, the document was closed, or it was reverted. Apply Style to Selection applies the new style to the objects selected when the dialog opened that still exist, not to a selection made while it was open. --- crates/ui-egui/src/object_style_dialog.rs | 121 +++++++++++++++++++++- 1 file changed, 118 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs index bdac19645..4200732a9 100644 --- a/crates/ui-egui/src/object_style_dialog.rs +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -42,10 +42,26 @@ fn text_fields(app: &DesignApp, fields: &mut Value, paragraph: Option<&str>, nex } } +/// The active document as the dialog records it: its uid and the epoch that File › Revert +/// changes. +fn document_key(app: &DesignApp) -> Value { + app.session.active().map_or(Value::Null, |st| json!([st.uid, st.identity_epoch])) +} + +/// The document the dialog was opened on is still the active one and was not reverted since, so +/// OK never gives its settings to another document's style of the same name. +fn same_document(app: &DesignApp, d: &Dialog) -> Result<(), String> { + let key = document_key(app); + if !key.is_null() && d.fields.get("doc") == Some(&key) { Ok(()) } else { Err(format!("the active document changed after {} opened", title(d))) } +} + /// The dialog for a new style from the selected object (its fill, stroke, corners and effects, /// as InDesign's New Object Style), named "Object Style n". pub fn open_new(app: &mut DesignApp) { + let doc = document_key(app); let Some(st) = app.session.active() else { return }; + // Apply Style to Selection applies the style to the objects selected now. + let targets: Vec = st.selection.items.iter().map(|i| i.0).collect(); let it = st.selection.items.first().and_then(|i| st.doc.item(*i)).cloned(); let taken = |n: &str| st.doc.styles.object_style(n).is_some(); let name = (1..).map(|i| format!("Object Style {i}")).find(|n| !taken(n)).unwrap_or_default(); @@ -54,6 +70,8 @@ pub fn open_new(app: &mut DesignApp) { let frame = it.as_ref().and_then(|i| i.text_frame()).map(|t| t.options.clone()); let mut fields = json!({ "new": true, + "doc": doc, + "targets": targets, "name": name, "basedOn": designcraft_doc::NO_OBJECT_STYLE, "fill": it.as_ref().map(|i| i.fill.clone()).unwrap_or_default(), @@ -81,6 +99,7 @@ pub fn open_edit(app: &mut DesignApp, name: &str) -> Result<(), String> { } let mut fields = json!({ "new": false, + "doc": document_key(app), "orig": name, "name": name, "basedOn": os.based_on.as_deref().unwrap_or(designcraft_doc::NO_OBJECT_STYLE), @@ -171,6 +190,7 @@ fn general_json(o: &designcraft_doc::TextFrameOptions) -> Value { /// OK: the new style (applied to the selection when asked), or the edited one. pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { + same_document(app, d)?; let name = d.s("name").trim().to_string(); if name.is_empty() { return Err(crate::i18n::tr(&app.ui.language, "Enter a style name.").to_string()); @@ -183,10 +203,21 @@ pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { let r = app.run("style.object.create", Value::Object(p)).map_err(|e| e.to_string())?; let made = r["name"].as_str().unwrap_or(&name).to_string(); // The style exists now, so OK again mustn't make a second one: a refused apply (locked - // objects) goes to the status line and the dialog closes. + // objects) goes to the status line and the dialog closes. The style goes to the objects + // selected when the dialog opened that still exist, not to a selection made since. + let targets: Vec = app.session.active().map_or_else(Vec::new, |st| { + d.fields + .get("targets") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_u64) + .filter(|id| st.doc.item(designcraft_doc::ItemId(*id)).is_some()) + .collect() + }); if d.b("apply") - && app.session.active().is_some_and(|st| !st.selection.items.is_empty()) - && let Err(e) = app.run("style.object.apply", json!({"name": made})) + && !targets.is_empty() + && let Err(e) = app.run("style.object.apply", json!({"name": made, "ids": targets})) { app.status(e.to_string()); } @@ -771,4 +802,88 @@ mod tests { assert!(e.contains("already named"), "{e}"); assert!(app.ui.dialog.as_ref().is_some_and(|d| d.s("status") == e)); } + + /// Object Style Options and New Object Style act only on the document they opened for. With + /// another document active, after their document closed, or after it was reverted, OK keeps + /// the dialog open with the reason and changes no style. + #[test] + fn the_dialog_acts_only_on_the_document_it_opened_for() { + let name = designcraft_doc::BASIC_GRAPHICS_FRAME; + let weights = |app: &DesignApp| -> Vec { + app.session + .documents() + .iter() + .map(|st| st.doc.styles.object_style(name).and_then(|s| s.stroke.as_ref()).map_or(-1.0, |s| s.weight)) + .collect() + }; + let edit = |app: &mut DesignApp| { + open_edit(app, name).unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + set(d, &["stroke", "weight"], json!(9.0)); + } + }; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"title": "A"})).unwrap(); + app.run("file.new", json!({"title": "B"})).unwrap(); + app.session.set_active(0); + let before = weights(&app); + assert_eq!(before.len(), 2); + + // Options opened on A, OK with B active: B has a style of the same name. + edit(&mut app); + app.session.set_active(1); + let e = crate::dialogs::confirm(&mut app).unwrap_err(); + assert!(e.contains("active document changed"), "{e}"); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.s("status") == e), "the dialog stays open with the reason"); + assert_eq!(weights(&app), before, "neither document's style changed"); + + // New Object Style opened on A makes nothing in B, and OK works once A is active again. + app.ui.dialog = None; + app.session.set_active(0); + open_new(&mut app); + app.session.set_active(1); + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert!(app.session.documents().iter().all(|st| st.doc.styles.object_style("Object Style 1").is_none())); + app.session.set_active(0); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.session.documents()[0].doc.styles.object_style("Object Style 1").is_some()); + assert!(app.session.documents()[1].doc.styles.object_style("Object Style 1").is_none()); + + // Options opened on A, A closed: B is active and keeps its style. + edit(&mut app); + app.session.execute("file.close", &json!({"index": 0})).unwrap(); + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert_eq!(weights(&app), vec![before[1]]); + app.ui.dialog = None; + + // Options opened on B, B reverted: the reverted document keeps its style. + #[cfg(not(target_arch = "wasm32"))] + { + let dir = tempfile::tempdir().unwrap(); + app.run("file.saveAs", json!({"path": dir.path().join("revert.designcraft")})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 144, 144], "content": "graphic"})).unwrap(); + edit(&mut app); + app.run("file.revert", json!({})).unwrap(); + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert_eq!(weights(&app), vec![before[1]]); + } + } + + /// Apply Style to Selection applies the new style to the objects selected when the dialog + /// opened, not to a selection made while it was open. + #[test] + fn apply_style_to_selection_uses_the_selection_the_dialog_opened_with() { + let (mut app, a) = app_with_frame(); + let b = app.run("frame.create", json!({"rect": [200, 72, 300, 172], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [a]})).unwrap(); + open_new(&mut app); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("name".into(), json!("Picked")); + } + app.run("selection.set", json!({"ids": [b]})).unwrap(); + crate::dialogs::confirm(&mut app).unwrap(); + let style = |id: u64| app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().object_style.clone(); + assert_eq!(style(a), "Picked"); + assert_ne!(style(b), "Picked"); + } } From 737fcfec043afabaa32a6bc41e1c0f1cfda08f57 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 09:53:54 -0800 Subject: [PATCH 35/36] IDML: an item without corners keeps none over its object style Object styles now export their corners, and an import gives an item without corner attributes the corners of its style chain and of [None]. An item with no corners whose style, a style it is based on, or [None] sets corners now writes None for each corner, so it keeps no corners after export and import. The lookup runs once per style. --- crates/idml/src/export.rs | 3 +++ crates/idml/src/export/item.rs | 36 +++++++++++++++++++++++-- crates/idml/src/tests/object_styles.rs | 37 ++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 717bb5d6c..0c85e8f8a 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -106,6 +106,8 @@ struct Ex<'a> { /// Index topic path → first serialized sort key; See / See also cross-references. index_topics: BTreeMap, String>, index_xrefs: Vec<(Vec, &'static str, String)>, + /// Object style name → whether its chain or `[None]` sets corners, found once per style. + style_corners: HashMap, } impl<'a> Ex<'a> { @@ -121,6 +123,7 @@ impl<'a> Ex<'a> { link_text_sources: Vec::new(), index_topics: BTreeMap::new(), index_xrefs: Vec::new(), + style_corners: HashMap::new(), }; ex.assign_swatch_ids(); for s in d.stories.values() { diff --git a/crates/idml/src/export/item.rs b/crates/idml/src/export/item.rs index 469053c2f..3c240deca 100644 --- a/crates/idml/src/export/item.rs +++ b/crates/idml/src/export/item.rs @@ -87,8 +87,10 @@ impl<'a> Ex<'a> { el.set("GradientStrokeAngle", num(a)); } self.stroke_attrs(&mut el, &it.stroke); - // Corner options: map path-order corners to the named corners by geometry. - if !it.corners.is_none() { + // Corner options: map path-order corners to the named corners by geometry. An item without + // corners whose object style chain sets some writes None, so an import does not give it + // the style's corners. + if !it.corners.is_none() || self.style_sets_corners(os) { let names_by_index = corner_names(&it.path); for (i, c) in it.corners.corners.iter().enumerate() { let n = names_by_index[i]; @@ -153,6 +155,36 @@ impl<'a> Ex<'a> { el } + /// Whether object style `name`, a style it is based on, or `[None]` (which an import reads + /// after every item's style chain) sets corners other than None. A built-in style the + /// document lacks counts as its default. + fn style_sets_corners(&mut self, name: &str) -> bool { + if let Some(&known) = self.style_corners.get(name) { + return known; + } + let defaults = designcraft_doc::Styles::default(); + let style = |n: &str| self.d.styles.object_style(n).or_else(|| defaults.object_style(n)); + let rounds = |os: &designcraft_doc::ObjectStyle| os.corners.is_some_and(|c| !c.is_none()); + let mut seen: Vec<&str> = Vec::new(); + let mut cur = Some(name); + let mut found = false; + while let Some(n) = cur.take() { + if seen.contains(&n) { + break; + } + let Some(os) = style(n) else { break }; + if rounds(os) { + found = true; + break; + } + seen.push(n); + cur = os.based_on.as_deref(); + } + let found = found || style(designcraft_doc::NO_OBJECT_STYLE).is_some_and(rounds); + self.style_corners.insert(name.to_string(), found); + found + } + fn append_transparency(&self, el: &mut El, it: &Item, os: &str) { // Transparency. // An item whose style sets effects writes the effects it has turned off too, so that on diff --git a/crates/idml/src/tests/object_styles.rs b/crates/idml/src/tests/object_styles.rs index 4ff0fa6bf..6226d4a74 100644 --- a/crates/idml/src/tests/object_styles.rs +++ b/crates/idml/src/tests/object_styles.rs @@ -232,6 +232,43 @@ fn exported_frames_keep_their_own_values_over_their_object_style() { assert_eq!(got.options, want); } +/// Items without corners keep none after export and import, though their object style, the style +/// it is based on, or `[None]` (read after every style chain) sets rounded corners. +#[test] +fn exported_frames_keep_no_corners_over_their_object_style() { + let mut d = small_doc(); + let lid = d.default_layer(); + let id = designcraft_doc::ItemId(d.alloc()); + let rect = designcraft_doc::Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(40.0, 420.0, 200.0, 500.0))); + d.insert_item(SpreadRef::Doc(0), rect, None).unwrap(); + let rounded = Some(designcraft_geom::corners::CornerOptions { + corners: [designcraft_geom::corners::Corner { shape: CornerShape::Rounded, size: 12.0 }; 4], + }); + let styles = std::sync::Arc::make_mut(&mut d.styles); + styles.object.push(designcraft_doc::ObjectStyle { name: "Round".into(), corners: rounded, ..Default::default() }); + styles.object.push(designcraft_doc::ObjectStyle { name: "Child".into(), based_on: Some("Round".into()), ..Default::default() }); + match styles.object.iter_mut().find(|o| o.name == designcraft_doc::NO_OBJECT_STYLE) { + Some(none) => none.corners = rounded, + None => { + styles.object.push(designcraft_doc::ObjectStyle { name: designcraft_doc::NO_OBJECT_STYLE.into(), corners: rounded, ..Default::default() }) + } + } + styles.object.push(designcraft_doc::ObjectStyle { name: "Plain".into(), ..Default::default() }); + let names = ["Round", "Child", designcraft_doc::NO_OBJECT_STYLE, "Plain"]; + let ids: Vec<_> = d.spreads.iter().flat_map(|s| s.items.iter().map(|i| i.id)).take(4).collect(); + assert_eq!(ids.len(), 4); + for (id, style) in ids.iter().zip(names) { + let it = d.item_mut(*id).unwrap(); + it.object_style = style.into(); + assert!(it.corners.is_none()); + } + let back = import_idml(&export_idml(&d)).unwrap(); + for style in names { + let it = back.spreads.iter().flat_map(|s| s.items.iter()).find(|i| i.object_style == style).unwrap(); + assert!(it.corners.is_none(), "{style}: {:?}", it.corners); + } +} + #[test] fn text_wrap_side_and_contour_come_from_the_style_for_frames_and_placed_graphics() { let styles = r#" From f0e63b333dd0ff85f6c498d5fa1c31e127cb223e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 10:01:16 -0800 Subject: [PATCH 36/36] IDML: a style without a category flag takes it from its based-on style An object style that gives neither EnableStrokeAndCornerOptions nor corners of its own takes the category from the style it is based on, and one without EnableTextFrameGeneralOptions does the same for its text frame options. A style based on [None] still sets neither, and [None] keeps the corners it sets itself. --- crates/idml/src/import/styles.rs | 40 +++++++++++++++++--------- crates/idml/src/tests/object_styles.rs | 37 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/crates/idml/src/import/styles.rs b/crates/idml/src/import/styles.rs index e2e11e979..023ab2d40 100644 --- a/crates/idml/src/import/styles.rs +++ b/crates/idml/src/import/styles.rs @@ -199,8 +199,9 @@ impl<'r> Importer<'r> { .flatten(); let levels = based_on_levels(e, &objects_by_self); // Text Frame General Options: each attribute from the nearest style whose - // `TextFramePreference` sets it, among the styles that don't turn the category off. - let text_frame = if e.get("EnableTextFrameGeneralOptions") == Some("true") { + // `TextFramePreference` sets it, among the styles that don't turn the category off. A + // style without the category flag takes it from the style it is based on. + let text_frame = if category_on(&levels, "EnableTextFrameGeneralOptions", |_| false) { let prefs: Vec<&El> = levels .iter() .filter(|l| l.get("EnableTextFrameGeneralOptions") != Some("false")) @@ -211,7 +212,7 @@ impl<'r> Importer<'r> { None }; let effects = self.style_effects(e); - let corners = style_corners(e, &levels); + let corners = style_corners(&levels); 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) { @@ -412,21 +413,34 @@ impl<'r> Importer<'r> { } } -/// Object style `e`'s corner options (Stroke & Corner Options), by name, when it sets them. A style -/// that sets some corners, or turns the category on, takes the others from the styles in `levels` -/// (`e` and the styles it is based on) that don't turn the category off. -fn style_corners(e: &El, levels: &[&El]) -> Option { - match e.get("EnableStrokeAndCornerOptions") { - Some("false") => return None, - Some("true") => {} - _ => { - corners(e, "")?; - } +/// The corner options (Stroke & Corner Options) of the object style that starts `levels` (the +/// style, then the styles it is based on), by name, when it sets them. A style that sets some +/// corners, or turns the category on (itself or through the style it is based on), takes the +/// others from the styles in `levels` that don't turn the category off. +fn style_corners(levels: &[&El]) -> Option { + if !category_on(levels, "EnableStrokeAndCornerOptions", |l| corners(l, "").is_some()) { + return None; } let on: Vec<&El> = levels.iter().copied().filter(|l| l.get("EnableStrokeAndCornerOptions") != Some("false")).collect(); corners_in(&on, "").map(|c| CornerOptions { corners: c }) } +/// Whether the style chain `levels` (a style, then the styles it is based on) turns the category +/// whose flag is `flag` on. The nearest style that gives the flag decides; a style without it +/// turns the category on when `own` finds settings of its own. A `[None]` after the first style +/// ends the chain, as a `BasedOn` of `[None]` names no style. +fn category_on(levels: &[&El], flag: &str, own: impl Fn(&El) -> bool) -> bool { + let none = |l: &&El| l.get("Self") == Some("ObjectStyle/$ID/[None]"); + for l in levels.iter().take(1).chain(levels.iter().skip(1).take_while(|l| !none(l))) { + match l.get(flag) { + Some(v) => return v == "true", + None if own(l) => return true, + None => {} + } + } + false +} + /// Object style `e`, then the styles it is based on, nearest first (cycle-safe, at most 16). A /// `BasedOn` may name a style by its `Self` id or by its bare name. fn based_on_levels<'e>(e: &'e El, objects_by_self: &HashMap<&str, &'e El>) -> Vec<&'e El> { diff --git a/crates/idml/src/tests/object_styles.rs b/crates/idml/src/tests/object_styles.rs index 6226d4a74..52cfd3e20 100644 --- a/crates/idml/src/tests/object_styles.rs +++ b/crates/idml/src/tests/object_styles.rs @@ -363,6 +363,43 @@ fn a_style_turning_corners_on_inherits_all_four() { assert_eq!((c.map(|c| c.shape), c.map(|c| c.size)), ([CornerShape::Rounded; 4], [17.0; 4])); } +/// A style with no Stroke & Corner Options or Text Frame General Options flag and no settings of +/// its own takes each category from the style it is based on: on, off, or (for a style based on +/// `[None]`) not set. +#[test] +fn a_style_without_category_flags_takes_them_from_its_based_on_style() { + let styles = r#" + $ID/[None] + + + + ObjectStyle/Round + + + + ObjectStyle/Round + + + ObjectStyle/Framed + + + ObjectStyle/Off + + + $ID/[None] + "#; + let d = fixture(&format!("{ROUND}{styles}"), &[]); + let style = |n: &str| d.styles.object_style(n).cloned().unwrap(); + let c = style("RoundKid").corners.expect("RoundKid inherits Round's corners").corners; + assert_eq!((c.map(|c| c.shape), c.map(|c| c.size)), ([CornerShape::Rounded; 4], [17.0; 4])); + assert_eq!(style("FramedKid").text_frame.map(|t| t.columns), Some(3), "FramedKid inherits Framed's text frame options"); + for n in ["OffKid", "Bare"] { + assert!(style(n).corners.is_none() && style(n).text_frame.is_none(), "{n}: {:?}", style(n)); + } + assert!(style(designcraft_doc::NO_OBJECT_STYLE).corners.is_some(), "[None] keeps the corners it sets itself"); +} + /// An object style giving its corners only with the legacy all-corners `CornerOption` and /// `CornerRadius` keeps them, so applying it to another frame rounds it. #[test]