From e90517be4512195cf54e27293d5f4330e598c7bf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 17:50:38 -0800 Subject: [PATCH 1/2] Text: paragraph spacing and justification values are checked type.para, style.paragraph.create and style.paragraph.edit accepted any number for paragraph spacing and justification. They now refuse Space Before, Space After and same-style spacing outside 0 to 15552 pt, word spacing outside 0% to 1000%, letter spacing outside -100% to 500%, glyph scaling outside 50% to 200%, Auto Leading outside 0% to 500%, and a Single Word Justification other than left, center, right or fullyJustified. Each justification row needs Minimum <= Desired <= Maximum over the values the paragraph or style resolves to after the change, including the styles based on an edited style and the paragraphs, table cells and notes that use them. object.textFrameOptions refuses an inset outside 0 to 15552 pt or a list that is not four sides, and a verticalJustification, firstBaseline, autoSize or columnsKind that is not one of its names. These were applied or skipped without an error before. IDML import reads these values as before. --- crates/engine/src/cmd/object.rs | 118 ++++++++++-- crates/engine/src/cmd/style/paragraph.rs | 56 ++++++ crates/engine/src/cmd/text.rs | 5 +- crates/engine/src/cmd/text/format.rs | 234 ++++++++++++++++++++++- docs/target-app-parity.md | 1 + 5 files changed, 390 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 634c90a61..69e2d2b04 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2595,6 +2595,14 @@ 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 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( @@ -2655,28 +2663,22 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("gutter").and_then(Value::as_f64) { o.gutter = v.max(0.0); } - 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; @@ -2752,6 +2754,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)>>, @@ -6005,6 +6041,48 @@ 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, an inset list that isn't four sides, and a vertical + /// justification or first baseline that isn't one of the names were applied 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"), + ] { + 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); + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/style/paragraph.rs b/crates/engine/src/cmd/style/paragraph.rs index 16671fbd8..04a3f6a8d 100644 --- a/crates/engine/src/cmd/style/paragraph.rs +++ b/crates/engine/src/cmd/style/paragraph.rs @@ -416,6 +416,51 @@ 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]) -> Result<()> { + const ID: &str = "style.paragraph.edit"; + 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(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(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 +479,8 @@ 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", ¶)?; + crate::cmd::text::validate_para_values(¶, "style.paragraph.create")?; + let 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")?; @@ -467,6 +514,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 +628,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)?; + 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 bee08c167..421df09bc 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_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?, firstLineIndent?, spaceBefore?, spaceAfter?, sameParaStyleSpacing? (pt, each 0..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 d915da91d..23d84bed6 100644 --- a/crates/engine/src/cmd/text/format.rs +++ b/crates/engine/src/cmd/text/format.rs @@ -146,6 +146,98 @@ pub(crate) const MIN_TYPE_SIZE: f64 = 0.1; pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0; +/// 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. +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: Space Before, Space After +/// and same-style spacing 0 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 = + [("spaceBefore", attrs.space_before), ("spaceAfter", attrs.space_after), ("sameParaStyleSpacing", attrs.same_para_style_spacing.flatten())]; + for (key, v) in spaces { + if let Some(v) = v + && !(0.0..=MAX_PARA_SPACE).contains(&v) + { + return Err(bad(command_id, format!("{key} {v}: 0 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))) +} + +/// What [`validate_justification_order`] refuses, as a message. +pub(crate) fn justification_order_error(resolved: &designcraft_doc::ParaProps, rows: [bool; 3]) -> Option { + let values = [ + [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], + ]; + 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 +407,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 +428,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) }) } @@ -1076,7 +1177,7 @@ mod pick_up_format_tests { #[cfg(test)] mod size_bounds_tests { - use serde_json::json; + use serde_json::{Value, json}; use crate::Session; @@ -1111,6 +1212,135 @@ mod size_bounds_tests { assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}"); } } + + /// `type.para` set any number for paragraph spacing and justification: a negative 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!({"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!({"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); + } } #[cfg(test)] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 193d3ae9a..63da03254 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 Space Before, Space After and same-style spacing outside 0 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 is also checked over the styles based on it and the paragraphs that use them. `object.textFrameOptions` refuses an inset outside 0 to 15552 pt or a list that isn't four sides, 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. From 7809f5e9da01c84672333a4f0840233567b6ba83 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 18:34:25 -0800 Subject: [PATCH 2/2] Text: frame columns, gutter, switches and indents are checked too object.textFrameOptions refuses a gutter outside 0 to 15552 pt, a column count outside 1 to 40 (it was clamped) and an on/off option that is not true or false (it was skipped). The Control bar's Columns field stops at 40, as the other column fields do. The params text gives the ranges and lists flexibleWidth. type.para and paragraph style create and edit refuse left and right indents outside 0 to 15552 pt and first and last line indents outside -15552 to 15552 pt. A style made from the selection is checked with the values it takes from the selection. style.paragraph.apply and style.paragraph.redefine check that Minimum, Desired and Maximum stay in order in each justification row they change, over the overrides paragraphs keep, and refuse the change otherwise. --- crates/engine/src/cmd/object.rs | 90 ++++++++++++---- crates/engine/src/cmd/style/paragraph.rs | 34 ++++-- crates/engine/src/cmd/text.rs | 6 +- crates/engine/src/cmd/text/format.rs | 127 ++++++++++++++++++++--- crates/ui-egui/src/object_control.rs | 2 +- docs/target-app-parity.md | 2 +- 6 files changed, 213 insertions(+), 48 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 69e2d2b04..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,27 @@ 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")?; @@ -2657,11 +2681,11 @@ 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; } for (side, v) in o.inset.iter_mut().zip(inset.unwrap_or_default()) { if let Some(v) = v { @@ -2684,22 +2708,22 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { 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 { @@ -2708,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 { @@ -2746,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); } @@ -4925,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}"); } } } @@ -6042,9 +6071,10 @@ mod frame_baseline_grid_tests { assert_eq!(grid(&s), None); } - /// A negative or huge inset, an inset list that isn't four sides, and a vertical - /// justification or first baseline that isn't one of the names were applied or skipped - /// without a word. They are refused and the frame keeps its options. + /// 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(); @@ -6066,6 +6096,19 @@ mod frame_baseline_grid_tests { (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]); @@ -6082,6 +6125,13 @@ mod frame_baseline_grid_tests { 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)); } } diff --git a/crates/engine/src/cmd/style/paragraph.rs b/crates/engine/src/cmd/style/paragraph.rs index 04a3f6a8d..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))) }) } @@ -418,8 +435,7 @@ 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]) -> Result<()> { - const ID: &str = "style.paragraph.edit"; +fn check_justification_after_edit(d: &designcraft_doc::Document, name: &str, rows: [bool; 3], command_id: &str) -> Result<()> { if !rows.contains(&true) { return Ok(()); } @@ -427,7 +443,7 @@ fn check_justification_after_edit(d: &designcraft_doc::Document, name: &str, row 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(ID, if *n == name { m } else { format!("paragraph style {n:?}: {m}") })); + return Err(bad(command_id, if *n == name { m } else { format!("paragraph style {n:?}: {m}") })); } } for st in d.stories.values() { @@ -435,7 +451,7 @@ fn check_justification_after_edit(d: &designcraft_doc::Document, name: &str, row 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(ID, format!("a paragraph of story {}: {m}", st.id.0))); + return Err(bad(command_id, format!("a paragraph of story {}: {m}", st.id.0))); } } Ok(()) @@ -479,8 +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", ¶)?; - crate::cmd::text::validate_para_values(¶, "style.paragraph.create")?; - let rows = justification_rows_given(p); + 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")?; @@ -492,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); @@ -499,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 @@ -635,7 +653,7 @@ pub(super) fn edit_para(s: &mut Session, p: &Value) -> Result { // 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)?; + 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), diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 421df09bc..ae016d4f9 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -25,8 +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, - justification_order_error, justification_rows_named, rename_typing_swatch, validate_char_fill, validate_justification_order, - validate_para_values, + 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}; @@ -235,7 +235,7 @@ pub fn specs() -> Vec { "Paragraph Formatting", [], None, - "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, sameParaStyleSpacing? (pt, each 0..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), …}}", + "{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 23d84bed6..25a0c87ba 100644 --- a/crates/engine/src/cmd/text/format.rs +++ b/crates/engine/src/cmd/text/format.rs @@ -146,8 +146,9 @@ pub(crate) const MIN_TYPE_SIZE: f64 = 0.1; pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0; -/// 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. +/// 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. @@ -163,20 +164,28 @@ fn percent(v: f64) -> String { format!("{p}") } -/// Checks the paragraph spacing and justification values `attrs` sets: Space Before, Space After -/// and same-style spacing 0 to [`MAX_PARA_SPACE`] pt; word spacing 0% to 1000%, letter spacing +/// 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 = - [("spaceBefore", attrs.space_before), ("spaceAfter", attrs.space_after), ("sameParaStyleSpacing", attrs.same_para_style_spacing.flatten())]; - for (key, v) in spaces { + 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 - && !(0.0..=MAX_PARA_SPACE).contains(&v) + && !(lo..=MAX_PARA_SPACE).contains(&v) { - return Err(bad(command_id, format!("{key} {v}: 0 to {MAX_PARA_SPACE} pt"))); + return Err(bad(command_id, format!("{key} {v}: {lo} to {MAX_PARA_SPACE} pt"))); } } let rows = [ @@ -225,13 +234,24 @@ pub(crate) fn validate_justification_order(resolved: &designcraft_doc::ParaProps justification_order_error(resolved, rows).map_or(Ok(()), |m| Err(bad(command_id, m))) } -/// What [`validate_justification_order`] refuses, as a message. -pub(crate) fn justification_order_error(resolved: &designcraft_doc::ParaProps, rows: [bool; 3]) -> Option { - let values = [ +/// 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))) @@ -1213,8 +1233,8 @@ mod size_bounds_tests { } } - /// `type.para` set any number for paragraph spacing and justification: a negative Space - /// Before, word spacing of 5000% and a Minimum above Maximum all went into the paragraph, + /// `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] @@ -1232,6 +1252,11 @@ mod size_bounds_tests { (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%"), @@ -1256,6 +1281,8 @@ mod size_bounds_tests { // 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}), @@ -1341,6 +1368,76 @@ mod size_bounds_tests { ); 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 63da03254..a71731243 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -285,7 +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 Space Before, Space After and same-style spacing outside 0 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 is also checked over the styles based on it and the paragraphs that use them. `object.textFrameOptions` refuses an inset outside 0 to 15552 pt or a list that isn't four sides, 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. +- 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.