From bd340e664863d0f5981a971f139f891a888ae7de Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:54:06 +0200 Subject: [PATCH 1/4] Text Frame Options: InDesign's layout, Preview and the Paragraph Spacing Limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog follows InDesign's: General, Column Rules, Baseline Options, Auto-Size; on General, framed Columns (Number and Width, Gutter and Maximum, Balance Columns), Inset Spacing (Top and Bottom, the link, Left and Right) and Vertical Justification (Align, Paragraph Spacing Limit), with spinner fields. With Fixed Number, Width shows each column's width as the number, gutter and insets change. Preview shows the options on the page while the dialog is open (redone as fields change); Cancel puts the frames back, OK sets them once as one undo step. Vertical Justification › Justify honours the Paragraph Spacing Limit: the extra space goes between paragraphs, up to the limit each, and what is left spreads over every line (all of it, with no limit, as before). object.textFrameOptions takes it as paragraphSpacingLimit. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 19 +- crates/compose/src/tests.rs | 46 +++++ crates/engine/src/cmd/object.rs | 6 +- crates/ui-egui/src/dialogs.rs | 293 ++++++++++++++++++++++++------- crates/ui-egui/src/i18n.rs | 11 ++ crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + 14 files changed, 330 insertions(+), 62 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..66b4d8fa7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Text Frame Options is laid out as InDesign's, with Preview: General, Column Rules, Baseline Options, Auto-Size; framed Columns (Number and Width, Gutter and Maximum, Balance Columns), Inset Spacing (Top/Bottom, the link, Left/Right) and Vertical Justification (Align, Paragraph Spacing Limit) with spinner fields; Fixed Number shows each column's width as the fields change. Preview shows the change on the page until OK (one undo step) or Cancel. Justify honours the Paragraph Spacing Limit (`paragraphSpacingLimit` on `object.textFrameOptions`): the space goes between paragraphs up to the limit, the rest between lines. Found in the Learn Skills Daily course, chapter 18. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 4a2c66397..f3680c8dc 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -4099,9 +4099,24 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { .collect(); let n = rows.last().copied().unwrap_or(0); if n > 0 { - let per = space / n as f64; + // Paragraph Spacing Limit: the space goes between paragraphs, up to the limit + // each; what is left spreads over every line (all of it with no limit). + let mut starts = vec![false; n + 1]; for (&i, &k) in idx.iter().zip(&rows) { - ft.lines[i].baseline += per * k as f64; + if k > 0 && ft.lines[i].first_in_para { + starts[k] = true; + } + } + let gaps = starts.iter().filter(|s| **s).count(); + let limit = f.opts.vj_paragraph_spacing_limit.max(0.0); + let para = if gaps > 0 && limit > 0.0 { (space / gaps as f64).min(limit) } else { 0.0 }; + let per = (space - para * gaps as f64) / n as f64; + let mut shift = vec![0.0; n + 1]; + for k in 1..=n { + shift[k] = shift[k - 1] + per + if starts[k] { para } else { 0.0 }; + } + for (&i, &k) in idx.iter().zip(&rows) { + ft.lines[i].baseline += shift[k]; } } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ed4c7c476..398d8f1d5 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -6847,6 +6847,52 @@ fn anchored_object_wraps_its_own_storys_later_lines() { assert!((o.origin.y - top).abs() < 1e-6); } +/// Vertical Justification › Justify with a Paragraph Spacing Limit: the space goes between the +/// paragraphs (up to the limit each), the lines of a paragraph keep their leading; past the limit +/// the rest spreads over every line. Without a limit it spreads over every line. +#[test] +fn justify_paragraph_spacing_limit() { + let text = "One line. +Two +Three"; + let baselines = |limit: f64| { + let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), ParaAttrs::default()); + let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; + o.vertical_justification = designcraft_doc::VerticalJustification::Justify; + o.vj_paragraph_spacing_limit = limit; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + all_lines(&cs).iter().map(|l| l.baseline).collect::>() + }; + let (none, big, small) = (baselines(0.0), baselines(1000.0), baselines(20.0)); + assert_eq!(none.len(), 3); + let gaps = |b: &[f64]| (b[1] - b[0], b[2] - b[1]); + // No limit: equal steps. A big limit: all of it between paragraphs (here every line is one). + assert!((gaps(&none).0 - gaps(&none).1).abs() < 1e-6, "{none:?}"); + assert!((gaps(&big).0 - gaps(&big).1).abs() < 1e-6, "{big:?}"); + // A small limit: each paragraph gap gets at most 20 pt more than the even share's base. + assert!((none[2] - big[2]).abs() < 1e-6 && (none[2] - small[2]).abs() < 1e-6, "the last line stays at the bottom"); + // A paragraph of two lines keeps its leading when the limit absorbs the space. + let (mut d, sid, fid) = doc_with( + &[LOREM, "End."].join( + " +", + ), + Rect::new(0.0, 0.0, 300.0, 600.0), + ParaAttrs::default(), + ); + let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; + o.vertical_justification = designcraft_doc::VerticalJustification::Justify; + o.vj_paragraph_spacing_limit = 10_000.0; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let first: Vec = lines.iter().filter(|l| l.para == 0).map(|l| l.baseline).collect(); + assert!(first.len() > 2, "LOREM runs over several lines"); + let lead = first[1] - first[0]; + assert!(first.windows(2).all(|w| (w[1] - w[0] - lead).abs() < 1e-6), "the paragraph's lines keep their leading: {first:?}"); + let last = lines.iter().find(|l| l.para == 1).unwrap().baseline; + assert!(last - first[first.len() - 1] > lead + 50.0, "the space went between the paragraphs"); +} + #[test] fn anchored_object_without_wrap_leaves_layout_unchanged() { let long = [LOREM; 3].join(" "); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d8..479a11fa6 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -598,7 +598,7 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, 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), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", + "{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), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), @@ -2496,6 +2496,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { let min_width = ranged("minimumWidth", 0.0, crate::autosize::MAX_SIDE, "a number of points")?; 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 reference = match p.get("autoSizeReference") { None | Some(Value::Null) => None, Some(v) => Some( @@ -2547,6 +2548,9 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = reference { o.auto_size_ref = v; } + if let Some(v) = spacing_limit { + o.vj_paragraph_spacing_limit = v; + } if let Some(v) = p.get("useMinimumHeight").and_then(Value::as_bool) { o.auto_size_use_min_height = v; } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..02ce87c49 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -527,6 +527,7 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { "firstBaseline": kind(serde_json::to_value(o.first_baseline).unwrap_or_default()), "firstBaselineMin": fm(o.first_baseline_min), "insetTop": fm(o.inset[0]), "insetLeft": fm(o.inset[1]), "insetBottom": fm(o.inset[2]), "insetRight": fm(o.inset[3]), "verticalJustification": serde_json::to_value(o.vertical_justification).unwrap_or(Value::Null), + "paragraphSpacingLimit": fm(o.vj_paragraph_spacing_limit), "columnRule": o.column_rule, "columnRuleWeight": format_measure(o.column_rule_weight, Unit::Points), "columnRuleColor": o.column_rule_color, "columnRuleTint": percent(o.column_rule_tint), "columnRuleTopInset": fm(o.column_rule_top_inset), "columnRuleBottomInset": fm(o.column_rule_bottom_inset), "columnRuleOffset": fm(o.column_rule_offset), @@ -542,6 +543,10 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { } } d.fields.entry("insetLinked".to_string()).or_insert_with(|| json!(o.inset.iter().all(|v| (v - o.inset[0]).abs() < 1e-9))); + // The frame's width: Fixed Number shows each column's width from it as the fields change. + if let Some(w) = selected_frame_width(app) { + d.fields.insert("_frameWidth".into(), json!(w)); + } d.fields.insert("_shown".into(), shown); } @@ -567,7 +572,7 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) ui, d, false, - &[("general", "General"), ("baseline", "Baseline Options"), ("autosize", "Auto-Size"), ("rules", "Column Rules")], + &[("general", "General"), ("rules", "Column Rules"), ("baseline", "Baseline Options"), ("autosize", "Auto-Size")], ); ui.separator(); ui.vertical(|ui| match d.s("section").as_str() { @@ -592,49 +597,141 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) _ => general(&lang, ui, d), }); }); + ui.add_space(6.0); + check(ui, d, "preview", tr("Preview")); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } } -/// Text Frame Options › General: columns, inset spacing, vertical justification, text wrap. +/// A measure field of the dialog with InDesign's spinner (stored as text in the document's unit). +fn spin_measure(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32, enabled: bool) { + let id = format!("tfo_{key}"); + if let Some(v) = crate::widgets::NumField::measure(&id, d.m(key), d.unit).width(w).spinner().range(0.0, 15552.0).enabled(enabled).show(ui) { + d.fields.insert(key.into(), json!(format_measure(v, d.unit))); + } +} + +/// A titled group in a thin frame, as InDesign's Text Frame Options draws them. +fn framed(ui: &mut egui::Ui, title: Option<&str>, add: impl FnOnce(&mut egui::Ui)) { + let t = crate::theme::Tokens::get(ui.ctx()); + ui.add_space(4.0); + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.divider)).inner_margin(egui::Margin::symmetric(10, 8)).corner_radius(2.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + if let Some(title) = title { + crate::rtl::label(ui, egui::RichText::new(title).font(semibold(12.0))); + } + add(ui); + }); +} + +/// A caption right-aligned in `w` (the dialog's fields line up after their captions). +fn caption_at(ui: &mut egui::Ui, text: &str, w: f32) { + ui.allocate_ui_with_layout(egui::vec2(w, crate::widgets::FIELD_H), egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.set_min_width(w); + ui.add(egui::Label::new(crate::rtl::widget(ui, text)).wrap_mode(egui::TextWrapMode::Extend)); + }); +} + +/// Preview on: the options show on the page (redone when a field changes) without an undo step; +/// off, the page goes back. +fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let params = match text_frame_params(app, d) { + Ok(p) => Value::Object(p), + Err(e) => { + d.fields.insert("status".into(), json!(e)); + return; + } + }; + if shown.as_ref() == Some(¶ms) { + return; + } + // Nothing edited (yet, or any more): the page as it was. + if params.as_object().is_some_and(|m| m.keys().all(|k| k == "ids")) { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + } + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction("Text Frame Options") + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview("object.textFrameOptions", params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(e) => d.fields.insert("status".into(), json!(e.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + +/// Text Frame Options › General, as InDesign's: Columns (kind, number and width, gutter and +/// maximum, Balance Columns), Inset Spacing (top and bottom, the link, left and right), Vertical +/// Justification (align, paragraph spacing limit) and Ignore Text Wrap. fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { let tr = |s: &'static str| crate::i18n::tr(lang, s); - crate::rtl::label(ui, egui::RichText::new(tr("Columns")).font(semibold(12.0))); - egui::Grid::new("tfo_cols").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, tr("Columns:")); - translated_combo(lang, ui, d, "columnsKind", &[("fixedNumber", "Fixed Number"), ("fixedWidth", "Fixed Width")]); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr_context(lang, "Number:", "count")); - text_field(ui, d, "columns", 70.0); - // Fixed Number: the width follows from the frame; Fixed Width: it's set here. - crate::rtl::label(ui, tr("Width:")); - ui.add_enabled_ui(d.s("columnsKind") == "fixedWidth", |ui| text_field(ui, d, "columnWidth", 70.0)); - ui.end_row(); - crate::rtl::label(ui, tr("Gutter:")); - text_field(ui, d, "gutter", 70.0); - ui.end_row(); - }); - check(ui, d, "balanceColumns", tr("Balance Columns")); - ui.add_space(6.0); - crate::rtl::label(ui, egui::RichText::new(tr("Inset Spacing")).font(semibold(12.0))); - // While linked, a side typed in sets all four ("Make all settings the same"). - let sides = ["insetTop", "insetLeft", "insetBottom", "insetRight"]; - let before: Vec = sides.iter().map(|k| d.s(k)).collect(); - // Two rows, Top | Left and Bottom | Right, the link between the columns (fixed-width - // captions; the second column starts where the first row's does). - let mut col2_x: Option = None; - for (r, row) in [[("Top:", "insetTop"), ("Left:", "insetLeft")], [("Bottom:", "insetBottom"), ("Right:", "insetRight")]].into_iter().enumerate() { + let (cap, field, cap2) = (64.0, 110.0, 70.0); + framed(ui, None, |ui| { ui.horizontal(|ui| { - for (k, (label, key)) in row.into_iter().enumerate() { - if k == 1 { - match col2_x { - Some(x) => ui.add_space((x - ui.cursor().min.x).max(0.0)), - None => col2_x = Some(ui.cursor().min.x), - } - } - let x0 = ui.cursor().min.x; - crate::rtl::label(ui, tr(label)); - ui.add_space(((if k == 0 { 56.0 } else { 44.0 }) - (ui.cursor().min.x - x0)).max(0.0)); - text_field(ui, d, key, 70.0); - if k == 0 && r == 0 { + caption_at(ui, tr("Columns:"), cap); + translated_combo(lang, ui, d, "columnsKind", &[("fixedNumber", "Fixed Number"), ("fixedWidth", "Fixed Width")]); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + caption_at(ui, crate::i18n::tr_context(lang, "Number:", "count"), cap); + let n = d.n("columns"); + if let Some(v) = crate::widgets::NumField::number("tfo_columns", n, "", 0).width(field).spinner().range(1.0, 40.0).show(ui) { + d.fields.insert("columns".into(), json!(v.round().to_string())); + } + // Fixed Number: the width follows from the frame; Fixed Width: it's set here. + caption_at(ui, tr("Width:"), cap2); + if d.s("columnsKind") == "fixedWidth" { + spin_measure(ui, d, "columnWidth", field, true); + } else { + // Fixed Number: each column's width, from the frame, the insets and the gutter. + let frame = d.fields.get("_frameWidth").and_then(Value::as_f64); + let n = d.n("columns").unwrap_or(1.0).max(1.0); + let width = frame.map(|w| { + let inner = w - d.m("insetLeft").unwrap_or(0.0) - d.m("insetRight").unwrap_or(0.0); + ((inner - d.m("gutter").unwrap_or(0.0) * (n - 1.0)) / n).max(0.0) + }); + let _ = crate::widgets::NumField::measure("tfo_auto_width", width, d.unit).width(field).spinner().enabled(false).show(ui); + } + }); + ui.horizontal(|ui| { + caption_at(ui, tr("Gutter:"), cap); + spin_measure(ui, d, "gutter", field, true); + // Maximum is for Flexible Width columns, which aren't in the document model yet. + caption_at(ui, tr("Maximum:"), cap2); + ui.add_enabled_ui(false, |ui| { + let _ = crate::widgets::NumField::number("tfo_max", None, "", 0).width(field).spinner().enabled(false).show(ui); + }); + }); + check(ui, d, "balanceColumns", tr("Balance Columns")); + }); + framed(ui, Some(tr("Inset Spacing")), |ui| { + // While linked, a side typed in sets all four ("Make all settings the same"). + let sides = ["insetTop", "insetLeft", "insetBottom", "insetRight"]; + let before: Vec = sides.iter().map(|k| d.s(k)).collect(); + for (r, [(l1, k1), (l2, k2)]) in + [[("Top:", "insetTop"), ("Left:", "insetLeft")], [("Bottom:", "insetBottom"), ("Right:", "insetRight")]].into_iter().enumerate() + { + ui.horizontal(|ui| { + caption_at(ui, tr(l1), cap); + spin_measure(ui, d, k1, field, true); + ui.add_space(8.0); + if r == 0 { let linked = d.b("insetLinked"); let tip = tr("Make all settings the same"); if crate::widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, egui::vec2(22.0, 21.0)) @@ -642,26 +739,40 @@ fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) { { d.fields.insert("insetLinked".into(), json!(!linked)); } + } else { + ui.add_space(22.0 + ui.spacing().item_spacing.x); } + caption_at(ui, tr(l2), cap2 - 30.0); + spin_measure(ui, d, k2, field, true); + }); + } + if d.b("insetLinked") + && let Some(k) = sides.iter().zip(&before).find(|(k, b)| d.s(k) != **b).map(|(k, _)| *k) + { + let v = d.fields.get(k).cloned().unwrap_or_default(); + for side in sides { + d.fields.insert(side.into(), v.clone()); } - }); - } - if d.b("insetLinked") - && let Some(k) = sides.iter().zip(&before).find(|(k, b)| d.s(k) != **b).map(|(k, _)| *k) - { - let v = d.fields.get(k).cloned().unwrap_or_default(); - for side in sides { - d.fields.insert(side.into(), v.clone()); } - } - ui.add_space(6.0); - crate::rtl::label(ui, egui::RichText::new(tr("Vertical Justification")).font(semibold(12.0))); - egui::Grid::new("tfo_vj").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, tr("Align:")); - translated_combo(lang, ui, d, "verticalJustification", &[("top", "Top"), ("center", "Center"), ("bottom", "Bottom"), ("justify", "Justify")]); - ui.end_row(); }); - ui.add_space(6.0); + framed(ui, Some(tr("Vertical Justification")), |ui| { + ui.horizontal(|ui| { + caption_at(ui, tr("Align:"), 170.0); + translated_combo( + lang, + ui, + d, + "verticalJustification", + &[("top", "Top"), ("center", "Center"), ("bottom", "Bottom"), ("justify", "Justify")], + ); + }); + ui.horizontal(|ui| { + caption_at(ui, tr("Paragraph Spacing Limit:"), 170.0); + let justify = d.s("verticalJustification") == "justify"; + spin_measure(ui, d, "paragraphSpacingLimit", field, justify); + }); + }); + ui.add_space(4.0); check(ui, d, "ignoreWrap", tr("Ignore Text Wrap")); } @@ -769,7 +880,19 @@ fn edited(d: &Dialog, key: &str) -> bool { fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result { seed_text_frame_options(app, d); - let d = &*d; + // The preview goes; the options are set once, as one undo step. + if d.fields.remove(PREVIEWED).is_some() { + app.session.cancel_interaction(); + } + let p = text_frame_params(app, d)?; + if p.keys().all(|k| k == "ids") { + return Ok(Value::Null); + } + app.run("object.textFrameOptions", Value::Object(p)) +} + +/// The `object.textFrameOptions` params of what the dialog changed. +fn text_frame_params(app: &DesignApp, d: &Dialog) -> Result, String> { let mut p = Map::new(); let bad = |what: &str| format!("Text Frame Options: {what}"); if edited(d, "columns") { @@ -850,8 +973,11 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result= 0.0).ok_or_else(|| bad(what))?)); } } - if p.is_empty() { - return Ok(Value::Null); + if edited(d, "paragraphSpacingLimit") { + p.insert( + "paragraphSpacingLimit".into(), + json!(d.m("paragraphSpacingLimit").filter(|v| *v >= 0.0).ok_or_else(|| bad("the paragraph spacing limit is not a measure"))?), + ); } // Typing in a frame: the options apply to that frame. if let Some(st) = app.session.active() @@ -860,7 +986,7 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result { seed_text_frame_options(app, &mut d); text_frame_options_dialog(app, ui, &mut d); + text_frame_preview(app, &mut d); } "colorTheme" => color_theme(app, ui, &mut d), "documentSetup" => { @@ -7921,6 +8048,54 @@ mod tests { assert_eq!((opts(&app).columns_kind, opts(&app).column_width), (designcraft_doc::ColumnsKind::FixedNumber, 120.0)); } + /// The course's chapter 18: Text Frame Options with Preview on shows the columns and the + /// inset on the page while the dialog is open; Cancel puts the frame back, OK sets them as one + /// undo step. The Paragraph Spacing Limit is set from the dialog too. + #[test] + fn text_frame_options_preview_shows_the_change_until_ok_or_cancel() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "content": "text", "text": "Hello"})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + app.run("selection.set", json!({"ids": [id.0]})).unwrap(); + let opts = |app: &DesignApp| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.clone(); + let open = |app: &mut DesignApp| { + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(app, &mut d); + d + }; + let mut d = open(&mut app); + d.fields.insert("columns".into(), json!("3")); + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 3, "previewed on the page"); + d.fields.insert("insetTop".into(), json!("1p0")); + text_frame_preview(&mut app, &mut d); + assert_eq!((opts(&app).columns, opts(&app).inset[0]), (3, 12.0), "redone when a field changes"); + d.fields.insert("preview".into(), json!(false)); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 1, "Preview off: the page as it was"); + // Cancel after a preview: back as it was. + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + app.ui.dialog = Some(d); + cancel(&mut app); + assert_eq!(opts(&app).columns, 1); + // OK after a preview: set once, one undo step. + let mut d = open(&mut app); + d.fields.insert("columns".into(), json!("2")); + d.fields.insert("verticalJustification".into(), json!("justify")); + d.fields.insert("paragraphSpacingLimit".into(), json!("2p0")); + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + let o = opts(&app); + assert_eq!((o.columns, o.vj_paragraph_spacing_limit), (2, 24.0)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(opts(&app).columns, 1, "one undo step"); + } + #[test] fn text_frame_options_change_only_what_was_edited() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..de3cb22ab 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -36,6 +36,17 @@ pub const LANGUAGES: &[(&str, &str)] = &[ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ("Maximum:", ["Maximum:", "Maximum :", "Máximo:", "最大 :", "最大值:"]), + ( + "Paragraph Spacing Limit:", + [ + "Abstandsgrenze für Absätze:", + "Limite d'espacement des paragraphes :", + "Límite de espaciado de párrafo:", + "段落スペース最大値 :", + "段落间距限制:", + ], + ), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..4d56dfb82 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Maximum:", "الحد الأقصى:"), + ("Paragraph Spacing Limit:", "حد تباعد الفقرات:"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..8194bed74 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Maximum:", "Maximum:"), + ("Paragraph Spacing Limit:", "Limit mezer mezi odstavci:"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..f95fa3cfd 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Maximum:", "Massimo:"), + ("Paragraph Spacing Limit:", "Limite spaziatura paragrafo:"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..b1aaaa303 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Maximum:", "მაქსიმუმი:"), + ("Paragraph Spacing Limit:", "აბზაცებს შორის ინტერვალის ზღვარი:"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..aa7c887d9 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Maximum:", "Maksimum:"), + ("Paragraph Spacing Limit:", "Limit odstępu akapitów:"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..74181eb54 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Maximum:", "Máximo:"), + ("Paragraph Spacing Limit:", "Limite de espaçamento de parágrafo:"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..17f41424d 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Maximum:", "Максимум:"), + ("Paragraph Spacing Limit:", "Предел интервала абзацев:"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..b06b9090e 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Maximum:", "Максимум:"), + ("Paragraph Spacing Limit:", "Межа інтервалу абзаців:"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), From 3d3bed65b80d15b0ff51e0358142a4497721f0f3 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:26:56 +0200 Subject: [PATCH 2/4] Text Frame Options: an invalid value takes the preview off the page; parity entry Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 16 ++++++++++++++++ docs/target-app-parity.md | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 02ce87c49..ffd6499c3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -648,6 +648,11 @@ fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) { let params = match text_frame_params(app, d) { Ok(p) => Value::Object(p), Err(e) => { + // Values OK would refuse don't stay on the page: it goes back to how it was. + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + } d.fields.insert("status".into(), json!(e)); return; } @@ -8075,6 +8080,17 @@ mod tests { d.fields.insert("preview".into(), json!(false)); text_frame_preview(&mut app, &mut d); assert_eq!(opts(&app).columns, 1, "Preview off: the page as it was"); + // A value OK would refuse: the page goes back while the status says why. + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 3); + let gutter = d.fields.get("gutter").cloned().unwrap_or_default(); + d.fields.insert("gutter".into(), json!("wide")); + text_frame_preview(&mut app, &mut d); + assert_eq!(opts(&app).columns, 1, "the page as it was"); + assert!(!d.fields.contains_key(PREVIEWED) && !d.s("status").is_empty()); + d.fields.insert("gutter".into(), gutter); + d.fields.remove("status"); // Cancel after a preview: back as it was. d.fields.insert("preview".into(), json!(true)); text_frame_preview(&mut app, &mut d); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdd6da689..c96143ff7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Text Frame Options layout, Preview and Paragraph Spacing Limit; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -343,6 +343,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. - Auto-size (Text Frame Options › Auto-Size, `object.textFrameOptions {autoSize: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference, useMinimumHeight, minimumHeight, useMinimumWidth, minimumWidth, noLineBreaks}`): the frame fits its text exactly after every edit (typing, pasting, style changes, option changes) in the same undo step (opening a file keeps the frame sizes it gives); it grows and shrinks around the reference point, which stays put in spread space for rotated frames too, never below the minimum height/width and never past 10,000 pt. Width Only and No Line Breaks set each paragraph on one line; Height and Width without No Line Breaks keeps the width; Keep Proportions scales both sides until the text fits. IDML keeps all the options (`AutoSizing…`, `…ForAutoSizing` attributes). Not covered: frames of a thread other than the last, vertical frames, type on a path, and multi-column balancing in Keep Proportions. - Balance Columns (Text Frame Options › Balance Columns, `object.textFrameOptions {balanceColumns}`, IDML `VerticalBalanceColumns`): in the frame the story ends in, the columns after the last explicit column break fill to about the same depth (the last may be shorter by a line per column); keep options hold, spanning and split paragraphs keep their own balancing, vertical justification then applies to each column as usual, and vertical frames balance their bands. A frame the text runs on from, or that is overset, fills normally. +- Text Frame Options dialog, laid out as InDesign's (General, Column Rules, Baseline Options, Auto-Size; framed Columns, Inset Spacing and Vertical Justification groups with spinner fields; Fixed Number shows each column's width as the fields change). Preview shows the edits on the page until OK (one undo step) or Cancel, and values OK would refuse put the page back. Vertical Justification › Paragraph Spacing Limit (`object.textFrameOptions {paragraphSpacingLimit}`, IDML `VerticalThreshold`): with Justify, the extra space goes between paragraphs up to the limit, the rest between lines. - Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). - Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. - Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. Clipping paths saved in IDML (Detect Edges, Alpha Channel, Photoshop Path, User-Modified Path; Invert) hide the image outside the path on screen and in PDF and survive IDML export; `object.clippingPathOptions` reports, inverts or removes one (Object ▸ Clipping Path ▸ None). From cd53ae04be3eff279bb15f537c277f201bac52a7 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:50:38 +0200 Subject: [PATCH 3/4] Parity: the Text Frame Options Preview with an invalid value, spelt out Co-Authored-By: Claude Opus 5.5 --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c96143ff7..c08ef6e89 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -343,7 +343,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. - Auto-size (Text Frame Options › Auto-Size, `object.textFrameOptions {autoSize: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference, useMinimumHeight, minimumHeight, useMinimumWidth, minimumWidth, noLineBreaks}`): the frame fits its text exactly after every edit (typing, pasting, style changes, option changes) in the same undo step (opening a file keeps the frame sizes it gives); it grows and shrinks around the reference point, which stays put in spread space for rotated frames too, never below the minimum height/width and never past 10,000 pt. Width Only and No Line Breaks set each paragraph on one line; Height and Width without No Line Breaks keeps the width; Keep Proportions scales both sides until the text fits. IDML keeps all the options (`AutoSizing…`, `…ForAutoSizing` attributes). Not covered: frames of a thread other than the last, vertical frames, type on a path, and multi-column balancing in Keep Proportions. - Balance Columns (Text Frame Options › Balance Columns, `object.textFrameOptions {balanceColumns}`, IDML `VerticalBalanceColumns`): in the frame the story ends in, the columns after the last explicit column break fill to about the same depth (the last may be shorter by a line per column); keep options hold, spanning and split paragraphs keep their own balancing, vertical justification then applies to each column as usual, and vertical frames balance their bands. A frame the text runs on from, or that is overset, fills normally. -- Text Frame Options dialog, laid out as InDesign's (General, Column Rules, Baseline Options, Auto-Size; framed Columns, Inset Spacing and Vertical Justification groups with spinner fields; Fixed Number shows each column's width as the fields change). Preview shows the edits on the page until OK (one undo step) or Cancel, and values OK would refuse put the page back. Vertical Justification › Paragraph Spacing Limit (`object.textFrameOptions {paragraphSpacingLimit}`, IDML `VerticalThreshold`): with Justify, the extra space goes between paragraphs up to the limit, the rest between lines. +- Text Frame Options dialog, laid out as InDesign's (General, Column Rules, Baseline Options, Auto-Size; framed Columns, Inset Spacing and Vertical Justification groups with spinner fields; Fixed Number shows each column's width as the fields change). Preview shows the edits on the page until OK (one undo step) or Cancel. An invalid value (such as a gutter that isn't a measure) takes the preview off the page, so the frame shows as it was, and the dialog's status line gives the reason; OK refuses the value too. Preview stays checked and shows the edits again once the value is valid. Vertical Justification › Paragraph Spacing Limit (`object.textFrameOptions {paragraphSpacingLimit}`, IDML `VerticalThreshold`): with Justify, the extra space goes between paragraphs up to the limit, the rest between lines. - Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). - Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. - Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. Clipping paths saved in IDML (Detect Edges, Alpha Channel, Photoshop Path, User-Modified Path; Invert) hide the image outside the path on screen and in PDF and survive IDML export; `object.clippingPathOptions` reports, inverts or removes one (Object ▸ Clipping Path ▸ None). From 963ac447faaf63334d09965e053e30dccaa42220 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 16:26:34 +0100 Subject: [PATCH 4/4] Text Frame Options: the paragraph spacing limit set without another branch in text_frame_options Keeps the function within clippy's cognitive complexity limit. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index c5c3ce307..474d22ca8 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2592,9 +2592,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = reference { o.auto_size_ref = v; } - if let Some(v) = spacing_limit { - o.vj_paragraph_spacing_limit = 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) { o.auto_size_use_min_height = v; }