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]