mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
Merge pull request #2650 from dotCooCoo/feat/paragraph-value-checks
Text: paragraph spacing and justification values are checked
This commit is contained in:
6 files changed
+574
-43
No files matched your search
+165
-37
@@ -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)]
|
||||
|
||||
@@ -241,9 +241,14 @@ pub(super) fn redefine(s: &mut Session, p: &Value, paragraph: bool) -> Result<Va
|
||||
let char_changes = inherited.delta(¤t);
|
||||
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(¶_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", ¶)?;
|
||||
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 = 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(¶, "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", ¶)?;
|
||||
crate::cmd::text::validate_para_values(¶, "style.paragraph.edit")?;
|
||||
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
|
||||
st.para.merge(¶);
|
||||
st.chars.merge(&chars);
|
||||
// Minimum ≤ Desired ≤ Maximum holds over what the style resolves to: in the rows the edit
|
||||
// names, or in every row when it changes Based On or resets the style.
|
||||
let rows = if reset || based.is_some() { [true; 3] } else { justification_rows_given(p) };
|
||||
check_justification_after_edit(d, &name, rows, "style.paragraph.edit")?;
|
||||
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
|
||||
match next {
|
||||
Some(Value::String(n)) => st.next_style = Some(n),
|
||||
Some(Value::Null) => st.next_style = None,
|
||||
|
||||
@@ -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))
|
||||
),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user