Text: paragraph spacing and justification values are checked

type.para, style.paragraph.create and style.paragraph.edit accepted any
number for paragraph spacing and justification. They now refuse Space
Before, Space After and same-style spacing outside 0 to 15552 pt, word
spacing outside 0% to 1000%, letter spacing outside -100% to 500%, glyph
scaling outside 50% to 200%, Auto Leading outside 0% to 500%, and a
Single Word Justification other than left, center, right or
fullyJustified. Each justification row needs Minimum <= Desired <=
Maximum over the values the paragraph or style resolves to after the
change, including the styles based on an edited style and the
paragraphs, table cells and notes that use them.

object.textFrameOptions refuses an inset outside 0 to 15552 pt or a list
that is not four sides, and a verticalJustification, firstBaseline,
autoSize or columnsKind that is not one of its names. These were applied
or skipped without an error before. IDML import reads these values as
before.
This commit is contained in:
dotCooCoo committed 2026-11-11 17:51:19 -08:00
1 parent 27d8eabb10
commit e90517be45
5 files changed
+390 -24

No files matched your search

+98 -20
View File
@@ -2595,6 +2595,14 @@ 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 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(
@@ -2655,28 +2663,22 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = p.get("gutter").and_then(Value::as_f64) {
o.gutter = v.max(0.0);
}
match p.get("inset") {
Some(Value::Number(n)) => o.inset = [n.as_f64().unwrap_or(0.0); 4],
Some(v @ Value::Array(_)) => {
if let Ok(a) = serde_json::from_value::<[Option<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;
@@ -2752,6 +2754,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>)>>,
@@ -6005,6 +6041,48 @@ mod frame_baseline_grid_tests {
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": null})).unwrap();
assert_eq!(grid(&s), None);
}
/// A negative or huge inset, an inset list that isn't four sides, and a vertical
/// justification or first baseline that isn't one of the names were applied or skipped
/// without a word. They are refused and the frame keeps its options.
#[test]
fn text_frame_options_refuse_insets_and_names_outside_their_ranges() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let options = |s: &Session| s.doc().unwrap().doc.item(id).and_then(|i| i.text_frame()).map(|t| t.options.clone()).unwrap();
let before = options(&s);
for (params, says) in [
(json!({"inset": -1}), "inset must be a number of points from 0 to 15552"),
(json!({"inset": 15553}), "inset must be a number of points from 0 to 15552"),
(json!({"inset": [1, 2, 3]}), "inset must be a number or [top, left, bottom, right]"),
(json!({"inset": [1, 2, 3, -4]}), "inset must be a number of points from 0 to 15552"),
(json!({"inset": [1, "2", 3, 4]}), "inset must be a number of points from 0 to 15552"),
(json!({"inset": "6pt"}), "inset must be a number or [top, left, bottom, right]"),
(json!({"verticalJustification": "middle"}), "verticalJustification must be one of top|center|bottom|justify"),
(json!({"firstBaseline": "baseline"}), "firstBaseline must be one of"),
(json!({"autoSize": "both"}), "autoSize must be one of"),
(json!({"columnsKind": "flexible"}), "columnsKind must be one of"),
(json!({"paragraphSpacingLimit": -1}), "paragraphSpacingLimit must be a number of points from 0 to 15552"),
(json!({"firstBaselineMin": 20000}), "firstBaselineMin must be a number of points from 0 to 15552"),
] {
let mut p = params.clone();
p["ids"] = json!([id.0]);
let e = s.execute("object.textFrameOptions", &p).expect_err("refused").to_string();
assert!(e.contains(says), "{params}: {e}");
assert_eq!(options(&s), before, "{params}");
}
// Values in range apply, the ends included, and null keeps a side.
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "inset": [0, null, 15552, 4.5], "verticalJustification": "bottom"})).unwrap();
let o = options(&s);
assert_eq!(o.inset, [0.0, before.inset[1], 15552.0, 4.5]);
assert_eq!(o.vertical_justification, designcraft_doc::VerticalJustification::Bottom);
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "inset": 6, "firstBaseline": "capHeight"})).unwrap();
let o = options(&s);
assert_eq!(o.inset, [6.0; 4]);
assert_eq!(o.first_baseline, designcraft_doc::FirstBaseline::CapHeight);
}
}
#[cfg(test)]
+56
View File
@@ -416,6 +416,51 @@ fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) {
}
}
/// After an edit of paragraph style `name`: Minimum ≤ Desired ≤ Maximum in `rows` for the style,
/// the styles based on it and the story paragraphs that use any of them.
fn check_justification_after_edit(d: &designcraft_doc::Document, name: &str, rows: [bool; 3]) -> Result<()> {
const ID: &str = "style.paragraph.edit";
if !rows.contains(&true) {
return Ok(());
}
let styles = &d.styles;
let affected: Vec<&str> = styles.paragraph.iter().map(|st| st.name.as_str()).filter(|n| styles.para_based_on_cycles(name, n)).collect();
for n in &affected {
if let Some(m) = crate::cmd::text::justification_order_error(&styles.resolve_para_style(n).0, rows) {
return Err(bad(ID, if *n == name { m } else { format!("paragraph style {n:?}: {m}") }));
}
}
for st in d.stories.values() {
let mut check = |pf: &designcraft_doc::ParaFormat| {
if affected.contains(&pf.style.as_str()) { crate::cmd::text::justification_order_error(&styles.resolve_para(pf).0, rows) } else { None }
};
if let Some(m) = first_in_nested_paras(st, &mut check) {
return Err(bad(ID, format!("a paragraph of story {}: {m}", st.id.0)));
}
}
Ok(())
}
/// The first answer `f` gives for a paragraph of `story`, its table cells, footnotes or endnotes,
/// at any depth. The walk keeps its own stack, so a deeply nested document can't exhaust the
/// call stack.
fn first_in_nested_paras(story: &designcraft_doc::Story, f: &mut impl FnMut(&designcraft_doc::ParaFormat) -> Option<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 +479,8 @@ 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)?;
crate::cmd::text::validate_para_values(&para, "style.paragraph.create")?;
let rows = justification_rows_given(p);
let mut chars: CharAttrs = attrs("style.paragraph.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
// Validate caller-supplied fills, not selection fills retained after a swatch is deleted.
crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?;
@@ -467,6 +514,9 @@ pub(super) fn create_para(s: &mut Session, p: &Value) -> Result<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 +628,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)?;
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_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?, firstLineIndent?, spaceBefore?, spaceAfter?, sameParaStyleSpacing? (pt, each 0..15552), wordSpaceMin?/wordSpaceDesired?/wordSpaceMax? (0..10, 1 = 100%), letterSpaceMin?/…Desired?/…Max? (-1..5), glyphScaleMin?/…Desired?/…Max? (0.5..2) (each row Minimum ≤ Desired ≤ Maximum), autoLeading? (0..5 of the type size), singleWordJustify?: left|center|right|fullyJustified, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, opticalMargin?: bool (hang at each character's own size; a story with story.options opticalMargin overrides it), ignoreOpticalMargin?: bool (no hang in the paragraph), tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
has_text_or_frames,
|s, p| format_paras(s, p.get("attrs").unwrap_or(p))
),
+232 -2
View File
@@ -146,6 +146,98 @@ pub(crate) const MIN_TYPE_SIZE: f64 = 0.1;
pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0;
/// Space Before, Space After and Space Between Paragraphs Using Same Style: 0 to 15552 pt, the
/// limit `object.textFrameOptions` applies to its Paragraph Spacing Limit.
pub(crate) const MAX_PARA_SPACE: f64 = 15552.0;
/// The justification rows as fractions (1.0 = 100%): key stem, label, range.
const JUSTIFICATION_ROWS: [(&str, &str, f64, f64); 3] =
[("wordSpace", "word spacing", 0.0, 10.0), ("letterSpace", "letter spacing", -1.0, 5.0), ("glyphScale", "glyph scaling", 0.5, 2.0)];
/// Auto Leading: 0% to 500% of the type size.
const MAX_AUTO_LEADING: f64 = 5.0;
/// A fraction shown as a percent, without float noise (1.33 is "133").
fn percent(v: f64) -> String {
let p = (v * 100.0 * 1000.0).round() / 1000.0;
format!("{p}")
}
/// Checks the paragraph spacing and justification values `attrs` sets: Space Before, Space After
/// and same-style spacing 0 to [`MAX_PARA_SPACE`] pt; word spacing 0% to 1000%, letter spacing
/// -100% to 500% and glyph scaling 50% to 200%; Auto Leading 0% to 500%; Single Word
/// Justification left, center, right or fullyJustified. [`validate_justification_order`] checks
/// the order of each row once the values are applied.
pub(crate) fn validate_para_values(attrs: &ParaAttrs, command_id: &str) -> Result<()> {
use designcraft_doc::Align;
let spaces =
[("spaceBefore", attrs.space_before), ("spaceAfter", attrs.space_after), ("sameParaStyleSpacing", attrs.same_para_style_spacing.flatten())];
for (key, v) in spaces {
if let Some(v) = v
&& !(0.0..=MAX_PARA_SPACE).contains(&v)
{
return Err(bad(command_id, format!("{key} {v}: 0 to {MAX_PARA_SPACE} pt")));
}
}
let rows = [
[attrs.word_space_min, attrs.word_space_desired, attrs.word_space_max],
[attrs.letter_space_min, attrs.letter_space_desired, attrs.letter_space_max],
[attrs.glyph_scale_min, attrs.glyph_scale_desired, attrs.glyph_scale_max],
];
for ((stem, label, lo, hi), given) in JUSTIFICATION_ROWS.into_iter().zip(rows) {
for (suffix, v) in ["Min", "Desired", "Max"].into_iter().zip(given) {
if let Some(v) = v
&& !(lo..=hi).contains(&v)
{
return Err(bad(command_id, format!("{stem}{suffix} {}%: {label} is {}% to {}%", percent(v), percent(lo), percent(hi))));
}
}
}
if let Some(v) = attrs.auto_leading
&& !(0.0..=MAX_AUTO_LEADING).contains(&v)
{
return Err(bad(command_id, format!("autoLeading {}%: 0% to {}%", percent(v), percent(MAX_AUTO_LEADING))));
}
if let Some(a) = attrs.single_word_justify
&& !matches!(a, Align::Left | Align::Center | Align::Right | Align::FullyJustified)
{
return Err(bad(command_id, format!("singleWordJustify {}: left, center, right or fullyJustified", json!(a))));
}
Ok(())
}
/// The justification rows (word spacing, letter spacing, glyph scaling) that attribute names in
/// `keys` set or clear.
pub(crate) fn justification_rows_named<'a>(keys: impl IntoIterator<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)))
}
/// 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 = [
[resolved.word_space_min, resolved.word_space_desired, resolved.word_space_max],
[resolved.letter_space_min, resolved.letter_space_desired, resolved.letter_space_max],
[resolved.glyph_scale_min, resolved.glyph_scale_desired, resolved.glyph_scale_max],
];
JUSTIFICATION_ROWS.into_iter().zip(values).zip(rows).find_map(|(((_, label, ..), [min, desired, max]), checked)| {
(checked && (min > desired || desired > max))
.then(|| format!("{label} needs Minimum ≤ Desired ≤ Maximum, not {}%, {}%, {}%", percent(min), percent(desired), percent(max)))
})
}
/// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that
/// line, and so the one a line-affecting attribute like leading can land on right away instead of
/// waiting for the next typed character (otherwise an empty line could never get its own leading).
@@ -315,11 +407,15 @@ pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result<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 +428,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)
})
}
@@ -1076,7 +1177,7 @@ mod pick_up_format_tests {
#[cfg(test)]
mod size_bounds_tests {
use serde_json::json;
use serde_json::{Value, json};
use crate::Session;
@@ -1111,6 +1212,135 @@ mod size_bounds_tests {
assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}");
}
}
/// `type.para` set any number for paragraph spacing and justification: a negative Space
/// Before, word spacing of 5000% and a Minimum above Maximum all went into the paragraph,
/// and Single Word Justification took any alignment. Values outside the ranges are refused,
/// nothing changes, and the ends of each range still apply.
#[test]
fn paragraph_spacing_and_justification_outside_their_ranges_are_refused() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world\nSecond"})).unwrap();
let select_all = |s: &mut Session| {
s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 18})).unwrap();
};
let para = |s: &mut Session| s.execute("type.selectionAttrs", &json!({})).unwrap()["para"].clone();
select_all(&mut s);
let before = para(&mut s);
for (attrs, says) in [
(json!({"spaceBefore": -1}), "spaceBefore -1: 0 to 15552 pt"),
(json!({"spaceAfter": 15553}), "spaceAfter 15553: 0 to 15552 pt"),
(json!({"sameParaStyleSpacing": -2}), "sameParaStyleSpacing -2: 0 to 15552 pt"),
(json!({"wordSpaceMax": 10.5}), "wordSpaceMax 1050%: word spacing is 0% to 1000%"),
(json!({"wordSpaceMin": -0.1}), "wordSpaceMin -10%: word spacing is 0% to 1000%"),
(json!({"letterSpaceMin": -1.5}), "letterSpaceMin -150%: letter spacing is -100% to 500%"),
(json!({"letterSpaceMax": 5.5}), "letterSpaceMax 550%: letter spacing is -100% to 500%"),
(json!({"glyphScaleMin": 0.4}), "glyphScaleMin 40%: glyph scaling is 50% to 200%"),
(json!({"glyphScaleDesired": 2.1}), "glyphScaleDesired 210%: glyph scaling is 50% to 200%"),
(json!({"autoLeading": 6}), "autoLeading 600%: 0% to 500%"),
(json!({"autoLeading": -0.1}), "autoLeading -10%: 0% to 500%"),
(json!({"singleWordJustify": "leftJustified"}), "singleWordJustify \"leftJustified\": left, center, right or fullyJustified"),
// Desired word spacing is 100%, so a Minimum of 120% alone is out of order.
(json!({"wordSpaceMin": 1.2}), "word spacing needs Minimum ≤ Desired ≤ Maximum, not 120%, 100%, 133%"),
(
json!({"glyphScaleMin": 0.9, "glyphScaleDesired": 1.1, "glyphScaleMax": 1.0}),
"glyph scaling needs Minimum ≤ Desired ≤ Maximum, not 90%, 110%, 100%",
),
] {
select_all(&mut s);
let e = s.execute("type.para", &json!({"attrs": attrs})).expect_err("refused").to_string();
assert!(e.contains(says), "{attrs}: {e}");
assert_eq!(para(&mut s), before, "{attrs}");
}
// The ends of each range apply and read back.
for attrs in [
json!({"spaceBefore": 0, "spaceAfter": 15552, "sameParaStyleSpacing": 15552}),
json!({"wordSpaceMin": 0, "wordSpaceDesired": 0, "wordSpaceMax": 10}),
json!({"letterSpaceMin": -1, "letterSpaceDesired": 0, "letterSpaceMax": 5}),
json!({"glyphScaleMin": 0.5, "glyphScaleDesired": 1, "glyphScaleMax": 2}),
json!({"autoLeading": 5}),
json!({"autoLeading": 0, "singleWordJustify": "center"}),
] {
select_all(&mut s);
s.execute("type.para", &json!({"attrs": attrs})).unwrap_or_else(|e| panic!("{attrs}: {e}"));
let got = para(&mut s);
let same = |a: &Value, b: &Value| a.as_f64().zip(b.as_f64()).map_or(a == b, |(a, b)| a == b);
for (k, v) in attrs.as_object().unwrap() {
assert!(same(&got[k], v), "{k}: {} for {v}", got[k]);
}
}
// The order holds over each paragraph's own values: with Desired 300% in the second
// paragraph only, a Maximum of 200% for both is refused and neither paragraph changes.
s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 12, "focus": 18})).unwrap();
s.execute("type.para", &json!({"attrs": {"wordSpaceMax": 5, "wordSpaceDesired": 3}})).unwrap();
select_all(&mut s);
let before = para(&mut s);
let e = s.execute("type.para", &json!({"attrs": {"wordSpaceMax": 2}})).expect_err("refused").to_string();
assert!(e.contains("not 0%, 300%, 200%"), "{e}");
assert_eq!(para(&mut s), before);
// Clearing a value is checked too: with Minimum 250% in the second paragraph, clearing
// its Desired back to the style's 100% is refused.
let second = |s: &mut Session| {
s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 12, "focus": 18})).unwrap();
};
second(&mut s);
s.execute("type.para", &json!({"attrs": {"wordSpaceMin": 2.5}})).unwrap();
second(&mut s);
let e = s.execute("type.para", &json!({"attrs": {"wordSpaceDesired": null}})).expect_err("refused").to_string();
assert!(e.contains("word spacing needs Minimum ≤ Desired ≤ Maximum, not 250%, 100%, 500%"), "{e}");
second(&mut s);
assert_eq!(para(&mut s)["wordSpaceDesired"].as_f64(), Some(3.0));
// Paragraph styles are checked the same way, over what the style resolves to.
let e = s.execute("style.paragraph.create", &json!({"name": "Loose", "para": {"autoLeading": 6}})).expect_err("refused").to_string();
assert!(e.contains("autoLeading 600%"), "{e}");
s.execute("style.paragraph.create", &json!({"name": "Body", "para": {"wordSpaceDesired": 1.2}})).unwrap();
let e = s.execute("style.paragraph.create", &json!({"name": "Tight", "para": {"wordSpaceMax": 0.9}})).expect_err("refused").to_string();
assert!(e.contains("not 80%, 100%, 90%"), "{e}");
let e = s
.execute("style.paragraph.create", &json!({"name": "Child", "basedOn": "Body", "para": {"wordSpaceMax": 1.1}}))
.expect_err("refused")
.to_string();
assert!(e.contains("not 80%, 120%, 110%"), "{e}");
let e = s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"wordSpaceMin": 1.3}})).expect_err("refused").to_string();
assert!(e.contains("not 130%, 120%, 133%"), "{e}");
let e = s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"spaceAfter": -3}})).expect_err("refused").to_string();
assert!(e.contains("spaceAfter -3: 0 to 15552 pt"), "{e}");
let style = s.doc().unwrap().doc.styles.para("Body").unwrap().para.clone();
assert_eq!((style.word_space_min, style.space_after), (None, None));
for name in ["Loose", "Tight", "Child"] {
assert!(s.doc().unwrap().doc.styles.para(name).is_none(), "{name}");
}
// A new Based On is checked over every row of what the style then resolves to.
s.execute("style.paragraph.create", &json!({"name": "Wide", "para": {"wordSpaceMin": 1.2, "wordSpaceDesired": 1.3}})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Narrow", "para": {"wordSpaceMax": 1.1}})).unwrap();
let e = s.execute("style.paragraph.edit", &json!({"name": "Wide", "basedOn": "Narrow"})).expect_err("refused").to_string();
assert!(e.contains("not 120%, 130%, 110%"), "{e}");
assert_eq!(s.doc().unwrap().doc.styles.para("Wide").unwrap().based_on, None);
// An edit is checked over the styles based on the edited style and the paragraphs using
// them, too.
s.execute("style.paragraph.create", &json!({"name": "Parent"})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Kid", "basedOn": "Parent", "para": {"wordSpaceMax": 1.1}})).unwrap();
let e = s.execute("style.paragraph.edit", &json!({"name": "Parent", "para": {"wordSpaceDesired": 1.2}})).expect_err("refused").to_string();
assert!(e.contains("paragraph style \"Kid\": word spacing needs Minimum ≤ Desired ≤ Maximum, not 80%, 120%, 110%"), "{e}");
// A footnote paragraph with its own Maximum of 10%, under a style edit that sets Desired
// to 20%.
select_all(&mut s);
s.execute("style.paragraph.apply", &json!({"name": "Parent", "clearOverrides": true})).unwrap();
s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 5, "focus": 5})).unwrap();
s.execute("footnote.insert", &json!({"text": "Note"})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Parent", "clearOverrides": true})).unwrap();
s.execute("type.para", &json!({"attrs": {"letterSpaceMax": 0.1}})).unwrap();
let e = s
.execute("style.paragraph.edit", &json!({"name": "Parent", "para": {"letterSpaceDesired": 0.2, "letterSpaceMax": 0.5}}))
.expect_err("refused")
.to_string();
assert!(
e.contains(&format!("a paragraph of story {}: letter spacing needs Minimum ≤ Desired ≤ Maximum, not 0%, 20%, 10%", f["story"])),
"{e}"
);
assert_eq!(s.doc().unwrap().doc.styles.para("Parent").unwrap().para.letter_space_desired, None);
}
}
#[cfg(test)]
+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 Space Before, Space After and same-style spacing outside 0 to 15552 pt, word spacing outside 0% to 1000%, letter spacing outside -100% to 500%, glyph scaling outside 50% to 200%, a Minimum above Desired or a Desired above Maximum, Auto Leading outside 0% to 500%, and a Single Word Justification other than left, center, right or fullyJustified. A style edit is also checked over the styles based on it and the paragraphs that use them. `object.textFrameOptions` refuses an inset outside 0 to 15552 pt or a list that isn't four sides, and a vertical justification, first baseline, auto-size or columns kind that isn't one of its names. IDML import reads these values as before.
- Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands.
- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands.
- Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus.