diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 634c90a61..d8f6eddb5 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -610,7 +610,7 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, paragraphSpacingLimit? (pt: Justify adds at most this between paragraphs, the rest between lines), columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", + "{columns? (1..40), gutter? (pt, 0..15552), inset?: number|[t,l,b,r] (pt, each 0..15552; a list has four sides, null keeps that side), verticalJustification?: top|center|bottom|justify, paragraphSpacingLimit? (pt: Justify adds at most this between paragraphs, the rest between lines), columnsKind?: fixedNumber|fixedWidth|flexibleWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), @@ -2569,6 +2569,9 @@ fn content_type(s: &mut Session, p: &Value) -> Result { }) } +/// The most columns a text frame takes. +const MAX_FRAME_COLUMNS: u64 = 40; + fn text_frame_options(s: &mut Session, p: &Value) -> Result { const ID: &str = "object.textFrameOptions"; let st = s.doc()?; @@ -2595,6 +2598,35 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { let column_width = ranged("columnWidth", 0.0, 15552.0, "a number of points")?; let first_baseline_min = ranged("firstBaselineMin", 0.0, 15552.0, "a number of points")?; let spacing_limit = ranged("paragraphSpacingLimit", 0.0, 15552.0, "a number of points")?; + let gutter = ranged("gutter", 0.0, 15552.0, "a number of points")?; + let columns = match p.get("columns") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_u64() + .filter(|n| (1..=MAX_FRAME_COLUMNS).contains(n)) + .ok_or_else(|| bad(ID, format!("columns must be a whole number from 1 to {MAX_FRAME_COLUMNS}, not {v}")))? as u32, + ), + }; + let flag = |key: &str| match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(v) => Err(bad(ID, format!("{key} must be true or false, not {v}"))), + }; + let use_min_height = flag("useMinimumHeight")?; + let use_min_width = flag("useMinimumWidth")?; + let no_line_breaks = flag("noLineBreaks")?; + let ignore_wrap = flag("ignoreWrap")?; + let balance_columns = flag("balanceColumns")?; + let column_rule = flag("columnRule")?; + let vertical = flag("vertical")?; + let inset = frame_inset_param(p)?; + let vertical_justification: Option = + frame_choice_param(p, "verticalJustification", "top|center|bottom|justify")?; + let first_baseline: Option = + frame_choice_param(p, "firstBaseline", "ascent|capHeight|leading|xHeight|fixed|emboxHeight")?; + let auto_size: Option = + frame_choice_param(p, "autoSize", "off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally")?; + let columns_kind: Option = frame_choice_param(p, "columnsKind", "fixedNumber|fixedWidth|flexibleWidth")?; let reference = match p.get("autoSizeReference") { None | Some(Value::Null) => None, Some(v) => Some( @@ -2649,55 +2681,49 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { for id in &ids { let Some(tf) = d.item_mut(*id).and_then(Item::text_frame_mut) else { continue }; let o = &mut tf.options; - if let Some(v) = p.get("columns").and_then(Value::as_u64) { - o.columns = v.clamp(1, 40) as u32; + if let Some(v) = columns { + o.columns = v; } - if let Some(v) = p.get("gutter").and_then(Value::as_f64) { - o.gutter = v.max(0.0); + if let Some(v) = gutter { + o.gutter = v; } - match p.get("inset") { - Some(Value::Number(n)) => o.inset = [n.as_f64().unwrap_or(0.0); 4], - Some(v @ Value::Array(_)) => { - if let Ok(a) = serde_json::from_value::<[Option; 4]>(v.clone()) { - for (side, v) in o.inset.iter_mut().zip(a) { - if let Some(v) = v { - *side = v; - } - } - } + for (side, v) in o.inset.iter_mut().zip(inset.unwrap_or_default()) { + if let Some(v) = v { + *side = v; } - _ => {} } - for (k, which) in [("verticalJustification", 0), ("firstBaseline", 1), ("autoSize", 2), ("columnsKind", 3)] { - if let Some(v) = p.get(k).cloned() { - let _ = match which { - 0 => serde_json::from_value(v).map(|x| o.vertical_justification = x), - 1 => serde_json::from_value(v).map(|x| o.first_baseline = x), - 2 => serde_json::from_value(v).map(|x| o.auto_size = x), - _ => serde_json::from_value(v).map(|x| o.columns_kind = x), - }; - } + if let Some(v) = vertical_justification { + o.vertical_justification = v; + } + if let Some(v) = first_baseline { + o.first_baseline = v; + } + if let Some(v) = auto_size { + o.auto_size = v; + } + if let Some(v) = columns_kind { + o.columns_kind = v; } if let Some(v) = reference { o.auto_size_ref = v; } o.vj_paragraph_spacing_limit = spacing_limit.unwrap_or(o.vj_paragraph_spacing_limit); - if let Some(v) = p.get("useMinimumHeight").and_then(Value::as_bool) { + if let Some(v) = use_min_height { o.auto_size_use_min_height = v; } if let Some(v) = min_height { o.auto_size_min_height = v; } - if let Some(v) = p.get("useMinimumWidth").and_then(Value::as_bool) { + if let Some(v) = use_min_width { o.auto_size_use_min_width = v; } if let Some(v) = min_width { o.auto_size_min_width = v; } - if let Some(v) = p.get("noLineBreaks").and_then(Value::as_bool) { + if let Some(v) = no_line_breaks { o.auto_size_no_line_breaks = v; } - if let Some(v) = p.get("ignoreWrap").and_then(Value::as_bool) { + if let Some(v) = ignore_wrap { o.ignore_wrap = v; } if let Some(v) = column_width { @@ -2706,10 +2732,10 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = first_baseline_min { o.first_baseline_min = v; } - if let Some(v) = p.get("balanceColumns").and_then(Value::as_bool) { + if let Some(v) = balance_columns { o.balance_columns = v; } - if let Some(v) = p.get("columnRule").and_then(Value::as_bool) { + if let Some(v) = column_rule { o.column_rule = v; } if let Some(w) = rule_weight { @@ -2744,7 +2770,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { } } // Story direction belongs to the story: every frame of the thread turns. - if let Some(v) = p.get("vertical").and_then(Value::as_bool) { + if let Some(v) = vertical { let stories: Vec = ids.iter().filter_map(|id| d.item(*id).and_then(Item::text_frame).map(|t| t.story)).collect(); super::text::set_story_direction(d, &stories, v); } @@ -2752,6 +2778,40 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { }) } +/// `object.textFrameOptions` Inset Spacing: one number for every side, or [top, left, bottom, +/// right] with null for a side that keeps its value; each side 0 to 15552 pt. +fn frame_inset_param(p: &Value) -> Result; 4]>> { + const ID: &str = "object.textFrameOptions"; + let side = |v: &Value| match v.as_f64() { + Some(x) if x.is_finite() && (0.0..=15552.0).contains(&x) => Ok(x), + _ => Err(bad(ID, format!("inset must be a number of points from 0 to 15552, not {v}"))), + }; + match p.get("inset") { + None | Some(Value::Null) => Ok(None), + Some(v @ Value::Number(_)) => Ok(Some([Some(side(v)?); 4])), + Some(Value::Array(a)) if a.len() == 4 => { + let mut sides = [None; 4]; + for (s, v) in sides.iter_mut().zip(a) { + if !v.is_null() { + *s = Some(side(v)?); + } + } + Ok(Some(sides)) + } + Some(v) => Err(bad(ID, format!("inset must be a number or [top, left, bottom, right], not {v}"))), + } +} + +/// An `object.textFrameOptions` choice: absent or null leaves it, a name outside `names` is an error. +fn frame_choice_param(p: &Value, key: &str, names: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => { + serde_json::from_value(v.clone()).map(Some).map_err(|_| bad("object.textFrameOptions", format!("{key} must be one of {names}, not {v}"))) + } + } +} + fn apply_custom_frame_grid( options: &mut TextFrameOptions, custom: Option, Option, Option)>>, @@ -4889,18 +4949,23 @@ mod column_rule_tests { assert_eq!(opts(&s), before); } - /// A column count past `u32::MAX` was cast to `u32` before the clamp, so - /// 2^32 + 1 wrapped to 1 column instead of clamping to the maximum, 40. + /// A column count outside 1 to 40 is refused, past `u32::MAX` too (2^32 + 1 must not + /// wrap to 1 column), and the frame keeps its columns. #[test] - fn huge_column_count_clamps_to_the_maximum() { + fn column_count_outside_one_to_forty_is_refused() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text"})).unwrap(); let id = ItemId(r["id"].as_u64().unwrap()); let columns = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.columns; - for (given, want) in [(4_294_967_297_u64, 40), (u64::MAX, 40), (0, 1)] { + for given in [4_294_967_297_u64, u64::MAX, 41, 0] { + let e = s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": given})).expect_err("refused").to_string(); + assert!(e.contains("columns must be a whole number from 1 to 40"), "columns {given}: {e}"); + assert_eq!(columns(&s), 1, "columns {given}"); + } + for given in [40, 1] { s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": given})).unwrap(); - assert_eq!(columns(&s), want, "columns {given}"); + assert_eq!(columns(&s), given as u32, "columns {given}"); } } } @@ -6005,6 +6070,69 @@ mod frame_baseline_grid_tests { s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": null})).unwrap(); assert_eq!(grid(&s), None); } + + /// A negative or huge inset or gutter, an inset list that isn't four sides, a column count + /// outside 1 to 40, a switch that isn't true or false, and a vertical justification or first + /// baseline that isn't one of the names were applied, clamped or skipped without a word. They + /// are refused and the frame keeps its options. + #[test] + fn text_frame_options_refuse_insets_and_names_outside_their_ranges() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + let options = |s: &Session| s.doc().unwrap().doc.item(id).and_then(|i| i.text_frame()).map(|t| t.options.clone()).unwrap(); + let before = options(&s); + for (params, says) in [ + (json!({"inset": -1}), "inset must be a number of points from 0 to 15552"), + (json!({"inset": 15553}), "inset must be a number of points from 0 to 15552"), + (json!({"inset": [1, 2, 3]}), "inset must be a number or [top, left, bottom, right]"), + (json!({"inset": [1, 2, 3, -4]}), "inset must be a number of points from 0 to 15552"), + (json!({"inset": [1, "2", 3, 4]}), "inset must be a number of points from 0 to 15552"), + (json!({"inset": "6pt"}), "inset must be a number or [top, left, bottom, right]"), + (json!({"verticalJustification": "middle"}), "verticalJustification must be one of top|center|bottom|justify"), + (json!({"firstBaseline": "baseline"}), "firstBaseline must be one of"), + (json!({"autoSize": "both"}), "autoSize must be one of"), + (json!({"columnsKind": "flexible"}), "columnsKind must be one of"), + (json!({"paragraphSpacingLimit": -1}), "paragraphSpacingLimit must be a number of points from 0 to 15552"), + (json!({"firstBaselineMin": 20000}), "firstBaselineMin must be a number of points from 0 to 15552"), + (json!({"gutter": -5}), "gutter must be a number of points from 0 to 15552"), + (json!({"gutter": 1e300}), "gutter must be a number of points from 0 to 15552"), + (json!({"gutter": "12pt"}), "gutter must be a number of points from 0 to 15552"), + (json!({"columns": 99}), "columns must be a whole number from 1 to 40"), + (json!({"columns": "two"}), "columns must be a whole number from 1 to 40"), + (json!({"columns": 2.5}), "columns must be a whole number from 1 to 40"), + (json!({"balanceColumns": "yes"}), "balanceColumns must be true or false"), + (json!({"ignoreWrap": 1}), "ignoreWrap must be true or false"), + (json!({"columnRule": "on"}), "columnRule must be true or false"), + (json!({"noLineBreaks": 0}), "noLineBreaks must be true or false"), + (json!({"useMinimumHeight": "true"}), "useMinimumHeight must be true or false"), + (json!({"useMinimumWidth": []}), "useMinimumWidth must be true or false"), + (json!({"vertical": "yes"}), "vertical must be true or false"), + ] { + let mut p = params.clone(); + p["ids"] = json!([id.0]); + let e = s.execute("object.textFrameOptions", &p).expect_err("refused").to_string(); + assert!(e.contains(says), "{params}: {e}"); + assert_eq!(options(&s), before, "{params}"); + } + // Values in range apply, the ends included, and null keeps a side. + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "inset": [0, null, 15552, 4.5], "verticalJustification": "bottom"})).unwrap(); + let o = options(&s); + assert_eq!(o.inset, [0.0, before.inset[1], 15552.0, 4.5]); + assert_eq!(o.vertical_justification, designcraft_doc::VerticalJustification::Bottom); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "inset": 6, "firstBaseline": "capHeight"})).unwrap(); + let o = options(&s); + assert_eq!(o.inset, [6.0; 4]); + assert_eq!(o.first_baseline, designcraft_doc::FirstBaseline::CapHeight); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": 3, "gutter": 15552, "balanceColumns": true, "ignoreWrap": true})) + .unwrap(); + let o = options(&s); + assert_eq!((o.columns, o.gutter, o.balance_columns, o.ignore_wrap), (3, 15552.0, true, true)); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "gutter": 0, "balanceColumns": false})).unwrap(); + let o = options(&s); + assert_eq!((o.gutter, o.balance_columns), (0.0, false)); + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/style/paragraph.rs b/crates/engine/src/cmd/style/paragraph.rs index 16671fbd8..e336b0f4f 100644 --- a/crates/engine/src/cmd/style/paragraph.rs +++ b/crates/engine/src/cmd/style/paragraph.rs @@ -241,9 +241,14 @@ pub(super) fn redefine(s: &mut Session, p: &Value, paragraph: bool) -> Result Result { s.edit(|d, _| { let styles = d.styles.clone(); let (style_para, style_chars) = styles.resolve_para_style(&name); + // Minimum ≤ Desired ≤ Maximum holds in each justification row the style changes in a + // paragraph, over the overrides the paragraph keeps. + let mut refused = None; for t in &targets { if let Some(st) = d.text_story_mut(t.story, t.cell) { + let last = if t.range.is_empty() { t.range.start } else { t.range.end.saturating_sub(1) }; + let touched = st.para_at(t.range.start)..=st.para_at(last); + let before: Vec<_> = st.paras.get(touched.clone()).unwrap_or_default().iter().map(|pf| styles.resolve_para(pf).0).collect(); apply_para_in_story(st, &t.range, &name, clear, (&style_para, &style_chars), &styles); + if refused.is_none() { + refused = st.paras.get(touched).unwrap_or_default().iter().zip(&before).find_map(|(pf, old)| { + let new = styles.resolve_para(pf).0; + crate::cmd::text::justification_order_error(&new, crate::cmd::text::justification_rows_changed(old, &new)) + }); + } } } - ok() + refused.map_or_else(ok, |m| Err(bad("style.paragraph.apply", m))) }) } @@ -416,6 +433,50 @@ fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { } } +/// After an edit of paragraph style `name`: Minimum ≤ Desired ≤ Maximum in `rows` for the style, +/// the styles based on it and the story paragraphs that use any of them. +fn check_justification_after_edit(d: &designcraft_doc::Document, name: &str, rows: [bool; 3], command_id: &str) -> Result<()> { + if !rows.contains(&true) { + return Ok(()); + } + let styles = &d.styles; + let affected: Vec<&str> = styles.paragraph.iter().map(|st| st.name.as_str()).filter(|n| styles.para_based_on_cycles(name, n)).collect(); + for n in &affected { + if let Some(m) = crate::cmd::text::justification_order_error(&styles.resolve_para_style(n).0, rows) { + return Err(bad(command_id, if *n == name { m } else { format!("paragraph style {n:?}: {m}") })); + } + } + for st in d.stories.values() { + let mut check = |pf: &designcraft_doc::ParaFormat| { + if affected.contains(&pf.style.as_str()) { crate::cmd::text::justification_order_error(&styles.resolve_para(pf).0, rows) } else { None } + }; + if let Some(m) = first_in_nested_paras(st, &mut check) { + return Err(bad(command_id, format!("a paragraph of story {}: {m}", st.id.0))); + } + } + Ok(()) +} + +/// The first answer `f` gives for a paragraph of `story`, its table cells, footnotes or endnotes, +/// at any depth. The walk keeps its own stack, so a deeply nested document can't exhaust the +/// call stack. +fn first_in_nested_paras(story: &designcraft_doc::Story, f: &mut impl FnMut(&designcraft_doc::ParaFormat) -> Option) -> Option { + let mut pending = vec![story]; + while let Some(story) = pending.pop() { + if let Some(m) = story.paras.iter().find_map(&mut *f) { + return Some(m); + } + pending.extend(story.tables.values().flat_map(|t| t.cells.iter().map(|c| &c.text))); + pending.extend(story.notes.iter().chain(&story.endnotes).map(|n| &n.text)); + } + None +} + +/// The justification rows the `para` object of `p` sets or clears. +fn justification_rows_given(p: &Value) -> [bool; 3] { + crate::cmd::text::justification_rows_named(p.get("para").and_then(Value::as_object).into_iter().flat_map(|o| o.keys().map(String::as_str))) +} + /// The style `p[key]` names: none when absent or null; anything but a name is refused. fn style_name_param(cmd: &str, p: &Value, key: &str) -> Result> { match p.get(key) { @@ -434,6 +495,7 @@ pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { continue_numbering(p.get("para"), &mut para); explicit_default_kinsoku(&mut para, p.get("para")); check_grep_styles("style.paragraph.create", ¶)?; + let mut rows = justification_rows_given(p); let mut chars: CharAttrs = attrs("style.paragraph.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; // Validate caller-supplied fills, not selection fills retained after a swatch is deleted. crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?; @@ -445,6 +507,7 @@ pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { let mut full = ParaProps_to_attrs(&pp); full.merge(¶); para = full; + rows = [true; 3]; } if let Ok(cp) = serde_json::from_value::(cur["chars"].clone()) { let mut full = CharProps_to_attrs(&cp); @@ -452,6 +515,8 @@ pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { chars = full; } } + // The values taken from the selection are checked with the given ones. + crate::cmd::text::validate_para_values(¶, "style.paragraph.create")?; s.edit(|d, _| { let name = Styles::unique_name(|n| d.styles.para(n).is_some(), &base); if let Some(b) = &based_on @@ -467,6 +532,9 @@ pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { return Err(bad("style.paragraph.create", format!("no style `{n}`"))); } d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() }); + // Minimum ≤ Desired ≤ Maximum holds over what the style resolves to. + let (resolved, _) = d.styles.resolve_para_style(&name); + crate::cmd::text::validate_justification_order(&resolved, rows, "style.paragraph.create")?; Ok(json!({"name": name})) }) } @@ -578,9 +646,15 @@ pub(super) fn edit_para(s: &mut Session, p: &Value) -> Result { continue_numbering(p.get("para"), &mut para); explicit_default_kinsoku(&mut para, p.get("para")); check_grep_styles("style.paragraph.edit", ¶)?; + crate::cmd::text::validate_para_values(¶, "style.paragraph.edit")?; let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; st.para.merge(¶); st.chars.merge(&chars); + // Minimum ≤ Desired ≤ Maximum holds over what the style resolves to: in the rows the edit + // names, or in every row when it changes Based On or resets the style. + let rows = if reset || based.is_some() { [true; 3] } else { justification_rows_given(p) }; + check_justification_after_edit(d, &name, rows, "style.paragraph.edit")?; + let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; match next { Some(Value::String(n)) => st.next_style = Some(n), Some(Value::Null) => st.next_style = None, diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 4bcbd6cc4..d1e0fb0f2 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -25,7 +25,8 @@ pub use edit::{PLACEHOLDER, quote_marks}; use edit::{change_case, delete, delete_surrounding, fill_placeholder, insert}; pub(crate) use format::{ Target, TypingFormat, apply_char_attrs, effective_char_format, format_chars, format_paras, format_targets, format_targets_pub, - rename_typing_swatch, validate_char_fill, + justification_order_error, justification_rows_changed, justification_rows_named, rename_typing_swatch, validate_char_fill, + validate_justification_order, validate_para_values, }; use format::{break_link, kern_pair, kern_step, link_style, para_toggle, pick_up_format, step_size, swap_fill_stroke}; use opentype::{open_type, open_type_features, set_features}; @@ -234,7 +235,7 @@ pub fn specs() -> Vec { "Paragraph Formatting", [], None, - "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, opticalMargin?: bool (hang at each character's own size; a story with story.options opticalMargin overrides it), ignoreOpticalMargin?: bool (no hang in the paragraph), tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", + "{attrs: {align?, leftIndent?, rightIndent?, spaceBefore?, spaceAfter?, sameParaStyleSpacing? (pt, each 0..15552), firstLineIndent?, lastLineIndent? (pt, each -15552..15552), wordSpaceMin?/wordSpaceDesired?/wordSpaceMax? (0..10, 1 = 100%), letterSpaceMin?/…Desired?/…Max? (-1..5), glyphScaleMin?/…Desired?/…Max? (0.5..2) (each row Minimum ≤ Desired ≤ Maximum), autoLeading? (0..5 of the type size), singleWordJustify?: left|center|right|fullyJustified, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, opticalMargin?: bool (hang at each character's own size; a story with story.options opticalMargin overrides it), ignoreOpticalMargin?: bool (no hang in the paragraph), tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", has_text_or_frames, |s, p| format_paras(s, p.get("attrs").unwrap_or(p)) ), diff --git a/crates/engine/src/cmd/text/format.rs b/crates/engine/src/cmd/text/format.rs index 64f3c17c7..d7e146c2d 100644 --- a/crates/engine/src/cmd/text/format.rs +++ b/crates/engine/src/cmd/text/format.rs @@ -146,6 +146,118 @@ pub(crate) const MIN_TYPE_SIZE: f64 = 0.1; pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0; +/// Left and right indents, Space Before, Space After and Space Between Paragraphs Using Same +/// Style: 0 to 15552 pt, the limit `object.textFrameOptions` applies to its Paragraph Spacing +/// Limit. First and last line indents may be negative, down to -15552 pt. +pub(crate) const MAX_PARA_SPACE: f64 = 15552.0; + +/// The justification rows as fractions (1.0 = 100%): key stem, label, range. +const JUSTIFICATION_ROWS: [(&str, &str, f64, f64); 3] = + [("wordSpace", "word spacing", 0.0, 10.0), ("letterSpace", "letter spacing", -1.0, 5.0), ("glyphScale", "glyph scaling", 0.5, 2.0)]; + +/// Auto Leading: 0% to 500% of the type size. +const MAX_AUTO_LEADING: f64 = 5.0; + +/// A fraction shown as a percent, without float noise (1.33 is "133"). +fn percent(v: f64) -> String { + let p = (v * 100.0 * 1000.0).round() / 1000.0; + format!("{p}") +} + +/// Checks the paragraph spacing and justification values `attrs` sets: left and right indents, +/// Space Before, Space After and same-style spacing 0 to [`MAX_PARA_SPACE`] pt, first and last +/// line indents -[`MAX_PARA_SPACE`] to [`MAX_PARA_SPACE`] pt; word spacing 0% to 1000%, letter spacing +/// -100% to 500% and glyph scaling 50% to 200%; Auto Leading 0% to 500%; Single Word +/// Justification left, center, right or fullyJustified. [`validate_justification_order`] checks +/// the order of each row once the values are applied. +pub(crate) fn validate_para_values(attrs: &ParaAttrs, command_id: &str) -> Result<()> { + use designcraft_doc::Align; + let spaces = [ + ("leftIndent", attrs.left_indent, 0.0), + ("rightIndent", attrs.right_indent, 0.0), + ("firstLineIndent", attrs.first_line_indent, -MAX_PARA_SPACE), + ("lastLineIndent", attrs.last_line_indent, -MAX_PARA_SPACE), + ("spaceBefore", attrs.space_before, 0.0), + ("spaceAfter", attrs.space_after, 0.0), + ("sameParaStyleSpacing", attrs.same_para_style_spacing.flatten(), 0.0), + ]; + for (key, v, lo) in spaces { + if let Some(v) = v + && !(lo..=MAX_PARA_SPACE).contains(&v) + { + return Err(bad(command_id, format!("{key} {v}: {lo} to {MAX_PARA_SPACE} pt"))); + } + } + let rows = [ + [attrs.word_space_min, attrs.word_space_desired, attrs.word_space_max], + [attrs.letter_space_min, attrs.letter_space_desired, attrs.letter_space_max], + [attrs.glyph_scale_min, attrs.glyph_scale_desired, attrs.glyph_scale_max], + ]; + for ((stem, label, lo, hi), given) in JUSTIFICATION_ROWS.into_iter().zip(rows) { + for (suffix, v) in ["Min", "Desired", "Max"].into_iter().zip(given) { + if let Some(v) = v + && !(lo..=hi).contains(&v) + { + return Err(bad(command_id, format!("{stem}{suffix} {}%: {label} is {}% to {}%", percent(v), percent(lo), percent(hi)))); + } + } + } + if let Some(v) = attrs.auto_leading + && !(0.0..=MAX_AUTO_LEADING).contains(&v) + { + return Err(bad(command_id, format!("autoLeading {}%: 0% to {}%", percent(v), percent(MAX_AUTO_LEADING)))); + } + if let Some(a) = attrs.single_word_justify + && !matches!(a, Align::Left | Align::Center | Align::Right | Align::FullyJustified) + { + return Err(bad(command_id, format!("singleWordJustify {}: left, center, right or fullyJustified", json!(a)))); + } + Ok(()) +} + +/// The justification rows (word spacing, letter spacing, glyph scaling) that attribute names in +/// `keys` set or clear. +pub(crate) fn justification_rows_named<'a>(keys: impl IntoIterator) -> [bool; 3] { + let mut rows = [false; 3]; + for key in keys { + let key = key.replace('_', "").to_ascii_lowercase(); + for (row, (stem, ..)) in rows.iter_mut().zip(JUSTIFICATION_ROWS) { + *row |= key.starts_with(&stem.to_ascii_lowercase()); + } + } + rows +} + +/// Each justification row marked in `rows` needs Minimum ≤ Desired ≤ Maximum in `resolved`, the +/// attributes of the paragraph or style after the change. +pub(crate) fn validate_justification_order(resolved: &designcraft_doc::ParaProps, rows: [bool; 3], command_id: &str) -> Result<()> { + justification_order_error(resolved, rows).map_or(Ok(()), |m| Err(bad(command_id, m))) +} + +/// The justification rows of resolved paragraph attributes: Minimum, Desired, Maximum. +fn justification_values(resolved: &designcraft_doc::ParaProps) -> [[f64; 3]; 3] { + [ + [resolved.word_space_min, resolved.word_space_desired, resolved.word_space_max], + [resolved.letter_space_min, resolved.letter_space_desired, resolved.letter_space_max], + [resolved.glyph_scale_min, resolved.glyph_scale_desired, resolved.glyph_scale_max], + ] +} + +/// The justification rows whose values differ between `before` and `after`. +pub(crate) fn justification_rows_changed(before: &designcraft_doc::ParaProps, after: &designcraft_doc::ParaProps) -> [bool; 3] { + let (b, a) = (justification_values(before), justification_values(after)); + [b[0] != a[0], b[1] != a[1], b[2] != a[2]] +} + +/// What [`validate_justification_order`] refuses, as a message. +pub(crate) fn justification_order_error(resolved: &designcraft_doc::ParaProps, rows: [bool; 3]) -> Option { + let values = justification_values(resolved); + JUSTIFICATION_ROWS.into_iter().zip(values).zip(rows).find_map(|(((_, label, ..), [min, desired, max]), checked)| { + (checked && (min > desired || desired > max)) + .then(|| format!("{label} needs Minimum ≤ Desired ≤ Maximum, not {}%, {}%, {}%", percent(min), percent(desired), percent(max))) + }) +} + /// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that /// line, and so the one a line-affecting attribute like leading can land on right away instead of /// waiting for the next typed character (otherwise an empty line could never get its own leading). @@ -315,11 +427,15 @@ pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result { a.set_json_over(k, v, &designcraft_doc::ParaProps::default()).map_err(|e| bad("type.para", e))?; } crate::cmd::style::check_grep_styles("type.para", &a)?; + validate_para_values(&a, "type.para")?; + let rows = justification_rows_named(obj.keys().map(String::as_str)); // `null` removes the override (back to the style's value). let cleared: Vec = obj.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect(); let targets = format_targets(s); s.edit(|d, _| { let styles = d.styles.clone(); + // Minimum ≤ Desired ≤ Maximum holds over each paragraph's own values once they change. + let mut refused = None; for t in &targets { if let Some(st) = d.text_story_mut(t.story, t.cell) { st.format_paras(t.range.clone(), |p| { @@ -332,10 +448,15 @@ pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result { for k in &cleared { let _ = p.para.set_json(k, &Value::Null); } + if refused.is_none() + && let Err(e) = validate_justification_order(&styles.resolve_para(p).0, rows, "type.para") + { + refused = Some(e); + } }); } } - ok() + refused.map_or_else(ok, Err) }) } @@ -1116,7 +1237,7 @@ mod align_param_tests { #[cfg(test)] mod size_bounds_tests { - use serde_json::json; + use serde_json::{Value, json}; use crate::Session; @@ -1151,6 +1272,212 @@ mod size_bounds_tests { assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}"); } } + + /// `type.para` set any number for paragraph indents, spacing and justification: a negative + /// left indent or Space Before, word spacing of 5000% and a Minimum above Maximum all went into the paragraph, + /// and Single Word Justification took any alignment. Values outside the ranges are refused, + /// nothing changes, and the ends of each range still apply. + #[test] + fn paragraph_spacing_and_justification_outside_their_ranges_are_refused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world\nSecond"})).unwrap(); + let select_all = |s: &mut Session| { + s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 18})).unwrap(); + }; + let para = |s: &mut Session| s.execute("type.selectionAttrs", &json!({})).unwrap()["para"].clone(); + select_all(&mut s); + let before = para(&mut s); + for (attrs, says) in [ + (json!({"spaceBefore": -1}), "spaceBefore -1: 0 to 15552 pt"), + (json!({"spaceAfter": 15553}), "spaceAfter 15553: 0 to 15552 pt"), + (json!({"sameParaStyleSpacing": -2}), "sameParaStyleSpacing -2: 0 to 15552 pt"), + (json!({"leftIndent": -50}), "leftIndent -50: 0 to 15552 pt"), + (json!({"rightIndent": -20}), "rightIndent -20: 0 to 15552 pt"), + (json!({"rightIndent": 1e300}), "rightIndent 1"), + (json!({"firstLineIndent": -15553}), "firstLineIndent -15553: -15552 to 15552 pt"), + (json!({"lastLineIndent": 15553}), "lastLineIndent 15553: -15552 to 15552 pt"), + (json!({"wordSpaceMax": 10.5}), "wordSpaceMax 1050%: word spacing is 0% to 1000%"), + (json!({"wordSpaceMin": -0.1}), "wordSpaceMin -10%: word spacing is 0% to 1000%"), + (json!({"letterSpaceMin": -1.5}), "letterSpaceMin -150%: letter spacing is -100% to 500%"), + (json!({"letterSpaceMax": 5.5}), "letterSpaceMax 550%: letter spacing is -100% to 500%"), + (json!({"glyphScaleMin": 0.4}), "glyphScaleMin 40%: glyph scaling is 50% to 200%"), + (json!({"glyphScaleDesired": 2.1}), "glyphScaleDesired 210%: glyph scaling is 50% to 200%"), + (json!({"autoLeading": 6}), "autoLeading 600%: 0% to 500%"), + (json!({"autoLeading": -0.1}), "autoLeading -10%: 0% to 500%"), + (json!({"singleWordJustify": "leftJustified"}), "singleWordJustify \"leftJustified\": left, center, right or fullyJustified"), + // Desired word spacing is 100%, so a Minimum of 120% alone is out of order. + (json!({"wordSpaceMin": 1.2}), "word spacing needs Minimum ≤ Desired ≤ Maximum, not 120%, 100%, 133%"), + ( + json!({"glyphScaleMin": 0.9, "glyphScaleDesired": 1.1, "glyphScaleMax": 1.0}), + "glyph scaling needs Minimum ≤ Desired ≤ Maximum, not 90%, 110%, 100%", + ), + ] { + select_all(&mut s); + let e = s.execute("type.para", &json!({"attrs": attrs})).expect_err("refused").to_string(); + assert!(e.contains(says), "{attrs}: {e}"); + assert_eq!(para(&mut s), before, "{attrs}"); + } + // The ends of each range apply and read back. + for attrs in [ + json!({"spaceBefore": 0, "spaceAfter": 15552, "sameParaStyleSpacing": 15552}), + json!({"leftIndent": 0, "rightIndent": 15552, "firstLineIndent": -15552, "lastLineIndent": 15552}), + json!({"leftIndent": 18, "rightIndent": 0, "firstLineIndent": -18, "lastLineIndent": 0}), + json!({"wordSpaceMin": 0, "wordSpaceDesired": 0, "wordSpaceMax": 10}), + json!({"letterSpaceMin": -1, "letterSpaceDesired": 0, "letterSpaceMax": 5}), + json!({"glyphScaleMin": 0.5, "glyphScaleDesired": 1, "glyphScaleMax": 2}), + json!({"autoLeading": 5}), + json!({"autoLeading": 0, "singleWordJustify": "center"}), + ] { + select_all(&mut s); + s.execute("type.para", &json!({"attrs": attrs})).unwrap_or_else(|e| panic!("{attrs}: {e}")); + let got = para(&mut s); + let same = |a: &Value, b: &Value| a.as_f64().zip(b.as_f64()).map_or(a == b, |(a, b)| a == b); + for (k, v) in attrs.as_object().unwrap() { + assert!(same(&got[k], v), "{k}: {} for {v}", got[k]); + } + } + // The order holds over each paragraph's own values: with Desired 300% in the second + // paragraph only, a Maximum of 200% for both is refused and neither paragraph changes. + s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 12, "focus": 18})).unwrap(); + s.execute("type.para", &json!({"attrs": {"wordSpaceMax": 5, "wordSpaceDesired": 3}})).unwrap(); + select_all(&mut s); + let before = para(&mut s); + let e = s.execute("type.para", &json!({"attrs": {"wordSpaceMax": 2}})).expect_err("refused").to_string(); + assert!(e.contains("not 0%, 300%, 200%"), "{e}"); + assert_eq!(para(&mut s), before); + // Clearing a value is checked too: with Minimum 250% in the second paragraph, clearing + // its Desired back to the style's 100% is refused. + let second = |s: &mut Session| { + s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 12, "focus": 18})).unwrap(); + }; + second(&mut s); + s.execute("type.para", &json!({"attrs": {"wordSpaceMin": 2.5}})).unwrap(); + second(&mut s); + let e = s.execute("type.para", &json!({"attrs": {"wordSpaceDesired": null}})).expect_err("refused").to_string(); + assert!(e.contains("word spacing needs Minimum ≤ Desired ≤ Maximum, not 250%, 100%, 500%"), "{e}"); + second(&mut s); + assert_eq!(para(&mut s)["wordSpaceDesired"].as_f64(), Some(3.0)); + // Paragraph styles are checked the same way, over what the style resolves to. + let e = s.execute("style.paragraph.create", &json!({"name": "Loose", "para": {"autoLeading": 6}})).expect_err("refused").to_string(); + assert!(e.contains("autoLeading 600%"), "{e}"); + s.execute("style.paragraph.create", &json!({"name": "Body", "para": {"wordSpaceDesired": 1.2}})).unwrap(); + let e = s.execute("style.paragraph.create", &json!({"name": "Tight", "para": {"wordSpaceMax": 0.9}})).expect_err("refused").to_string(); + assert!(e.contains("not 80%, 100%, 90%"), "{e}"); + let e = s + .execute("style.paragraph.create", &json!({"name": "Child", "basedOn": "Body", "para": {"wordSpaceMax": 1.1}})) + .expect_err("refused") + .to_string(); + assert!(e.contains("not 80%, 120%, 110%"), "{e}"); + let e = s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"wordSpaceMin": 1.3}})).expect_err("refused").to_string(); + assert!(e.contains("not 130%, 120%, 133%"), "{e}"); + let e = s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"spaceAfter": -3}})).expect_err("refused").to_string(); + assert!(e.contains("spaceAfter -3: 0 to 15552 pt"), "{e}"); + let style = s.doc().unwrap().doc.styles.para("Body").unwrap().para.clone(); + assert_eq!((style.word_space_min, style.space_after), (None, None)); + for name in ["Loose", "Tight", "Child"] { + assert!(s.doc().unwrap().doc.styles.para(name).is_none(), "{name}"); + } + // A new Based On is checked over every row of what the style then resolves to. + s.execute("style.paragraph.create", &json!({"name": "Wide", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Narrow", "para": {"wordSpaceMax": 1.1}})).unwrap(); + let e = s.execute("style.paragraph.edit", &json!({"name": "Wide", "basedOn": "Narrow"})).expect_err("refused").to_string(); + assert!(e.contains("not 120%, 130%, 110%"), "{e}"); + assert_eq!(s.doc().unwrap().doc.styles.para("Wide").unwrap().based_on, None); + // An edit is checked over the styles based on the edited style and the paragraphs using + // them, too. + s.execute("style.paragraph.create", &json!({"name": "Parent"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Kid", "basedOn": "Parent", "para": {"wordSpaceMax": 1.1}})).unwrap(); + let e = s.execute("style.paragraph.edit", &json!({"name": "Parent", "para": {"wordSpaceDesired": 1.2}})).expect_err("refused").to_string(); + assert!(e.contains("paragraph style \"Kid\": word spacing needs Minimum ≤ Desired ≤ Maximum, not 80%, 120%, 110%"), "{e}"); + // A footnote paragraph with its own Maximum of 10%, under a style edit that sets Desired + // to 20%. + select_all(&mut s); + s.execute("style.paragraph.apply", &json!({"name": "Parent", "clearOverrides": true})).unwrap(); + s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 5, "focus": 5})).unwrap(); + s.execute("footnote.insert", &json!({"text": "Note"})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Parent", "clearOverrides": true})).unwrap(); + s.execute("type.para", &json!({"attrs": {"letterSpaceMax": 0.1}})).unwrap(); + let e = s + .execute("style.paragraph.edit", &json!({"name": "Parent", "para": {"letterSpaceDesired": 0.2, "letterSpaceMax": 0.5}})) + .expect_err("refused") + .to_string(); + assert!( + e.contains(&format!("a paragraph of story {}: letter spacing needs Minimum ≤ Desired ≤ Maximum, not 0%, 20%, 10%", f["story"])), + "{e}" + ); + assert_eq!(s.doc().unwrap().doc.styles.para("Parent").unwrap().para.letter_space_desired, None); + } + + /// Applying or redefining a paragraph style could leave a paragraph's own override and the + /// style's values out of order: a Maximum of 10% kept over a style's Desired of 20%. Both are + /// refused and nothing changes; Clear Overrides still applies the style. + #[test] + fn applying_or_redefining_a_style_keeps_justification_in_order() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world\nSecond"})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + let select = |s: &mut Session, anchor: u64, focus: u64| { + s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": anchor, "focus": focus})).unwrap(); + }; + let styles = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().paras.iter().map(|p| p.style.clone()).collect::>(); + let letter = |s: &mut Session| { + let p = s.execute("type.selectionAttrs", &json!({})).unwrap()["para"].clone(); + [p["letterSpaceMin"].as_f64(), p["letterSpaceDesired"].as_f64(), p["letterSpaceMax"].as_f64()] + }; + s.execute("style.paragraph.create", &json!({"name": "Wide", "para": {"letterSpaceDesired": 0.2, "letterSpaceMax": 0.5}})).unwrap(); + select(&mut s, 0, 18); + s.execute("type.para", &json!({"attrs": {"letterSpaceMax": 0.1}})).unwrap(); + let before = styles(&s); + select(&mut s, 0, 18); + let e = s.execute("style.paragraph.apply", &json!({"name": "Wide"})).expect_err("refused").to_string(); + assert!(e.contains("style.paragraph.apply") && e.contains("letter spacing needs Minimum ≤ Desired ≤ Maximum, not 0%, 20%, 10%"), "{e}"); + assert_eq!(styles(&s), before); + select(&mut s, 0, 18); + assert_eq!(letter(&mut s), [Some(0.0), Some(0.0), Some(0.1)]); + // A style that changes no justification row applies over the same override. + s.execute("style.paragraph.create", &json!({"name": "Airy", "para": {"spaceAfter": 6}})).unwrap(); + select(&mut s, 0, 18); + s.execute("style.paragraph.apply", &json!({"name": "Airy"})).unwrap(); + select(&mut s, 0, 18); + s.execute("style.paragraph.apply", &json!({"name": "Wide", "clearOverrides": true})).unwrap(); + assert_eq!(styles(&s), ["Wide", "Wide"]); + select(&mut s, 0, 18); + assert_eq!(letter(&mut s), [Some(0.0), Some(0.2), Some(0.5)]); + + // Redefine Style from a paragraph whose Desired is 30% while the second paragraph keeps a + // Maximum of 25%. + select(&mut s, 12, 18); + s.execute("type.para", &json!({"attrs": {"letterSpaceMax": 0.25}})).unwrap(); + select(&mut s, 0, 5); + s.execute("type.para", &json!({"attrs": {"letterSpaceDesired": 0.3}})).unwrap(); + select(&mut s, 0, 5); + let e = s.execute("style.paragraph.redefine", &json!({})).expect_err("refused").to_string(); + assert!( + e.contains("style.paragraph.redefine") + && e.contains(&format!("a paragraph of story {}: letter spacing needs Minimum ≤ Desired ≤ Maximum, not 0%, 30%, 25%", sid.0)), + "{e}" + ); + assert_eq!(s.doc().unwrap().doc.styles.para("Wide").unwrap().para.letter_space_desired, Some(0.2)); + + // A new style from a selection whose paragraph holds a value no command sets (as an + // import could leave) is refused the same way as a given value. + s.edit(|d, _| { + for pf in &mut d.story_mut(sid).unwrap().paras { + pf.para.left_indent = Some(-30.0); + } + Ok(()) + }) + .unwrap(); + select(&mut s, 0, 5); + let e = s.execute("style.paragraph.create", &json!({"name": "Captured", "fromSelection": true})).expect_err("refused").to_string(); + assert!(e.contains("leftIndent -30: 0 to 15552 pt"), "{e}"); + assert!(s.doc().unwrap().doc.styles.para("Captured").is_none()); + select(&mut s, 0, 5); + s.execute("style.paragraph.create", &json!({"name": "Captured", "fromSelection": true, "para": {"leftIndent": 0}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.para("Captured").unwrap().para.left_indent, Some(0.0)); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/object_control.rs b/crates/ui-egui/src/object_control.rs index 55ebd5e31..056ea68cf 100644 --- a/crates/ui-egui/src/object_control.rs +++ b/crates/ui-egui/src/object_control.rs @@ -304,7 +304,7 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut Ui) { group(ui, "columns", (caption_w + 2.0).ceil().max(48.0), |ui, rect| { cell(ui, rect, 0.0, 0, rect.width(), true, |ui| widgets::caption(ui, &caption)); if let Some(v) = cell(ui, rect, 0.0, 1, 48.0, true, |ui| { - NumField::number("ccols", Some(columns as f64), "", 0).width(48.0).spinner().range(1.0, 100.0).show(ui) + NumField::number("ccols", Some(columns as f64), "", 0).width(48.0).spinner().range(1.0, 40.0).show(ui) }) { let _ = app.run("object.textFrameOptions", json!({"columns": v as u64})); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 193d3ae9a..a71731243 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -285,6 +285,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. - Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room, or across all the columns of the frame with Span Footnotes Across Columns; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. - Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. +- Paragraph spacing and justification values are checked: `type.para`, `style.paragraph.create` and `style.paragraph.edit` refuse left and right indents, Space Before, Space After and same-style spacing outside 0 to 15552 pt, first and last line indents outside -15552 to 15552 pt, word spacing outside 0% to 1000%, letter spacing outside -100% to 500%, glyph scaling outside 50% to 200%, a Minimum above Desired or a Desired above Maximum, Auto Leading outside 0% to 500%, and a Single Word Justification other than left, center, right or fullyJustified. A style edit or redefinition is also checked over the styles based on it and the paragraphs that use them, and `style.paragraph.apply` refuses a style whose values would put a row out of order with a paragraph's own overrides. `object.textFrameOptions` refuses an inset or gutter outside 0 to 15552 pt, an inset list that isn't four sides, a column count outside 1 to 40, an on/off option that isn't true or false, and a vertical justification, first baseline, auto-size or columns kind that isn't one of its names. IDML import reads these values as before. - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus.