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.