Merge PR #1461: Text Frame Options

crates/ui-egui/src/dialogs.rs: both dialog tests kept
crates/ui-egui/src/i18n/*.rs: union of keys
docs/target-app-parity.md: main's change line plus the Text Frame Options entry
This commit is contained in:
aiongg
2026-10-30 23:16:09 +00:00
15 changed files with 344 additions and 63 deletions
+1
View File
@@ -492,6 +492,7 @@ Newest first.
- **2026-10-20** · Drawing a frame or shape shows its width and height in the document's ruler units, to two decimals and on two lines ("W: 6.08 cm", "H: 6.08 cm"), as InDesign does; it was always whole points. No readiness percentage change.
- **2026-10-20** · Type tool: a drag that starts in an empty frame draws a new text frame over it, as in InDesign; a click still turns the frame into a text frame. No readiness percentage change.
- **2026-10-20** · Measures in centimetres show four decimals, as InDesign's fields do (a 1p gutter reads 0.4233 cm, not 0.423 cm). No readiness percentage change.
- **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-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
- **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
+17 -2
View File
@@ -5424,9 +5424,24 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &de
.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];
}
}
}
+46
View File
@@ -7481,6 +7481,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::<Vec<_>>()
};
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<f64> = 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");
}
/// Each object's wrap pushes the anchors after it down, so where each object lands depends on the
/// wraps of all those before it; every object still ends up wrapping the lines it hangs over.
#[test]
+3 -1
View File
@@ -598,7 +598,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, 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?, 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?}",
has_selection,
text_frame_options
),
@@ -2535,6 +2535,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
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(
@@ -2621,6 +2622,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
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) {
o.auto_size_use_min_height = v;
}
+250 -59
View File
@@ -322,6 +322,65 @@ mod color_theme_tests {
assert_eq!(parent(h.state(), 3), None);
}
/// 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");
// 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);
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 color_theme_cancel_is_read_only_and_confirm_adds_the_preview() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -672,6 +731,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),
@@ -689,6 +749,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);
}
@@ -714,7 +778,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() {
@@ -761,49 +825,146 @@ 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) => {
// 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;
}
};
if shown.as_ref() == Some(&params) {
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<String> = 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<f32> = 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<String> = 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))
@@ -811,26 +972,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"));
}
@@ -965,7 +1140,19 @@ fn text_frame_baseline_grid(d: &Dialog, bad: &dyn Fn(&str) -> String) -> Result<
fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result<Value, String> {
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<Map<String, Value>, String> {
let mut p = Map::new();
let bad = |what: &str| format!("Text Frame Options: {what}");
if edited(d, "columns") {
@@ -1051,8 +1238,11 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result<Val
if let Some(grid) = text_frame_baseline_grid(d, &bad)? {
p.insert("baselineGrid".into(), grid);
}
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()
@@ -1061,7 +1251,7 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result<Val
{
p.insert("ids".into(), json!([f.0]));
}
app.run("object.textFrameOptions", Value::Object(p))
Ok(p)
}
/// File › Document Setup, filled from the document.
@@ -3232,6 +3422,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"textFrameOptions" => {
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" => {
+2
View File
@@ -74,6 +74,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Section", "قسم جديد"),
("Clear Frame Fitting Options", "مسح خيارات ملاءمة الإطار"),
("Cannot be edited", "غير قابل للتحرير"),
("Maximum:", "الحد الأقصى:"),
("Paragraph Spacing Limit:", "حد تباعد الفقرات:"),
("Restart Numbering", "إعادة الترقيم"),
("Continue Numbering", "متابعة الترقيم"),
("Keep Options…", "خيارات الإبقاء…"),
+2
View File
@@ -79,6 +79,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Section", "Nový oddíl"),
("Clear Frame Fitting Options", "Vymazat volby přizpůsobení rámečku"),
("Cannot be edited", "Nelze upravit"),
("Maximum:", "Maximum:"),
("Paragraph Spacing Limit:", "Limit mezer mezi odstavci:"),
("Restart Numbering", "Začít číslování znovu"),
("Continue Numbering", "Pokračovat v číslování"),
("Keep Options…", "Volby zachování…"),
+2
View File
@@ -73,6 +73,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Section", "Nuova sezione"),
("Clear Frame Fitting Options", "Cancella opzioni di adattamento cornice"),
("Cannot be edited", "Non modificabile"),
("Maximum:", "Massimo:"),
("Paragraph Spacing Limit:", "Limite spaziatura paragrafo:"),
("Restart Numbering", "Riavvia numerazione"),
("Continue Numbering", "Continua numerazione"),
("Keep Options…", "Opzioni di separazione…"),
+2
View File
@@ -73,6 +73,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Section", "ახალი სექცია"),
("Clear Frame Fitting Options", "ჩარჩოს მორგების პარამეტრების გასუფთავება"),
("Cannot be edited", "რედაქტირება შეუძლებელია"),
("Maximum:", "მაქსიმუმი:"),
("Paragraph Spacing Limit:", "აბზაცებს შორის ინტერვალის ზღვარი:"),
("Restart Numbering", "ნუმერაციის თავიდან დაწყება"),
("Continue Numbering", "ნუმერაციის გაგრძელება"),
("Keep Options…", "შენარჩუნების პარამეტრები…"),
+2
View File
@@ -77,6 +77,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Section", "Nowa sekcja"),
("Clear Frame Fitting Options", "Wyczyść opcje dopasowania ramki"),
("Cannot be edited", "Nie można edytować"),
("Maximum:", "Maksimum:"),
("Paragraph Spacing Limit:", "Limit odstępu akapitów:"),
("Restart Numbering", "Zacznij numerowanie od nowa"),
("Continue Numbering", "Kontynuuj numerowanie"),
("Keep Options…", "Opcje utrzymywania…"),
+2
View File
@@ -76,6 +76,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Section", "Nova seção"),
("Clear Frame Fitting Options", "Limpar opções de ajuste do quadro"),
("Cannot be edited", "Não editável"),
("Maximum:", "Máximo:"),
("Paragraph Spacing Limit:", "Limite de espaçamento de parágrafo:"),
("Restart Numbering", "Reiniciar numeração"),
("Continue Numbering", "Continuar numeração"),
("Keep Options…", "Opções de manutenção…"),
+2
View File
@@ -81,6 +81,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Section", "Новый раздел"),
("Clear Frame Fitting Options", "Очистить параметры подгонки фрейма"),
("Cannot be edited", "Нельзя изменить"),
("Maximum:", "Максимум:"),
("Paragraph Spacing Limit:", "Предел интервала абзацев:"),
("Restart Numbering", "Начать нумерацию заново"),
("Continue Numbering", "Продолжить нумерацию"),
("Keep Options…", "Параметры удержания…"),
+9
View File
@@ -24,6 +24,15 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[
"Borrar opciones de encaje de marco",
"フレーム調整オプションを消去",
"清除框架适合选项",
("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:",
"段落スペース最大値 :",
"段落间距限制:",
],
),
(
+2
View File
@@ -74,6 +74,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("New Section", "Новий розділ"),
("Clear Frame Fitting Options", "Очистити параметри припасування фрейму"),
("Cannot be edited", "Не можна змінити"),
("Maximum:", "Максимум:"),
("Paragraph Spacing Limit:", "Межа інтервалу абзаців:"),
("Restart Numbering", "Почати нумерацію знову"),
("Continue Numbering", "Продовжити нумерацію"),
("Keep Options…", "Параметри утримання…"),
+2 -1
View File
@@ -1,6 +1,6 @@
# DesignCraft parity with Adobe InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Swatches rows show InDesign's type columns; Keyboard Shortcuts on craft-keymap's shared dialog, with shortcut sets; Convert Variable to Text; list buttons with the default hanging indent; custom anchored objects honour Relative to Spine; PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; 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-24 · **Change:** minor (Text Frame Options layout, Preview and Paragraph Spacing Limit; Swatches rows show InDesign's type columns; Keyboard Shortcuts on craft-keymap's shared dialog, with shortcut sets; Convert Variable to Text; list buttons with the default hanging indent; custom anchored objects honour Relative to Spine; PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; 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
@@ -351,6 +351,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 from the top line's ascent to the bottom line's descent, 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. 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).