Merge pull request #2650 from dotCooCoo/feat/paragraph-value-checks

Text: paragraph spacing and justification values are checked
This commit is contained in:
FredApps authored and GitHub committed 2026-11-12 17:57:35 +01:00
commit 6e50ee3974
6 files changed
+574 -43

No files matched your search

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