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] 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))