Merge justification-dialog: a Justification dialog for the selected paragraphs (#531)

Conflict: ROADMAP.md: both progress-log entries, #531's above next's 2026-10-11 lines.
Conflict: docs/typography-parity.md: status line lists next's changes and the Justification dialog.
Conflict: crates/engine/src/cmd/text.rs: selection_attrs keeps next's typing-format chars and adds paraMixed for {mixed: true}; mixed_para_attrs sits beside next's apply_typing_format and link_style.
Conflict: crates/ui-egui/src/dialogs.rs: titles, draw and confirm arms for both paragraphLocal and justification; the style dialog's justify arm calls the shared justification(), which uses next's composer_choice (Japanese composers) and selects neither composer when mixed; the style dialog's justification check moved into next's confirm_paragraph_style_options (section set to justify, reason in next's status line); tests from both sides.
Conflict: crates/ui-egui/src/i18n/ja.rs: union of rows.
Conflict: crates/ui-egui/src/i18n/uk.rs: union of rows.
Conflict: crates/ui-egui/src/menus.rs: UI_COMMANDS, run_ui arms and Type menu keep next's Paragraph Borders and Shading, Span Columns and Bullets and Numbering plus Justification… (Cmd+Alt+Shift+J, free on next) right after Paragraph Rules…; menu_enabled keeps next's IME check before the type.para check.
Auto-merged crates/ui-egui/src/panels/properties.rs: Justification… joins the Paragraph panel menu list after Paragraph Rules….
This commit is contained in:
aiongg
2026-10-11 20:49:57 +00:00
12 changed files with 303 additions and 52 deletions
+1
View File
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-11:** Type › Justification… (Cmd+Alt+Shift+J, also in the Paragraph panel menu) sets word and letter spacing, glyph scaling and the composer on the selected paragraphs, sharing the Paragraph Style Options section (#531). No readiness percentage change.
- **2026-10-11** · Properties gains InDesign's Pathfinder section (Add, Subtract, Intersect, Exclude Overlap, Minus Back) with several objects selected; section titles and icon buttons have accessible names. No readiness percentage change.
- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change.
- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change.
+35 -4
View File
@@ -307,7 +307,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_text_or_frames,
break_link
),
cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection", has_doc, selection_attrs),
cmd!(
query "type.selectionAttrs",
"Selection Attributes",
[],
None,
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs",
has_doc,
selection_attrs
),
]
}
@@ -1446,7 +1454,7 @@ fn open_type(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"features": list, "figures": otf::figures(&list)}))
}
fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> {
fn selection_attrs(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let targets = format_targets(s);
let Some(Target { story: sid, cell, range: r }) = targets.first().cloned() else { return Ok(Value::Null) };
@@ -1459,8 +1467,31 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> {
apply_typing_format(&mut cf, f);
}
let cp = st.doc.styles.resolve_char(&base, &cf);
Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp,
"paraOverrides": pf.para.count(), "charOverrides": cf.over.count()}))
let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp,
"paraOverrides": pf.para.count(), "charOverrides": cf.over.count()});
if bool_or(p, "mixed", false) {
out["paraMixed"] = json!(mixed_para_attrs(&st.doc, &targets));
}
Ok(out)
}
/// The paragraph attributes whose resolved values differ across the paragraphs of `targets`.
fn mixed_para_attrs(doc: &designcraft_doc::Document, targets: &[Target]) -> Vec<String> {
let mut first: Option<serde_json::Map<String, Value>> = None;
let mut mixed = std::collections::BTreeSet::new();
for t in targets {
let Some(story) = doc.text_story(t.story, t.cell) else { continue };
let a = story.para_at(t.range.start);
let b = story.para_at(t.range.end.max(t.range.start));
for pf in story.paras.get(a..=b).unwrap_or(&[]) {
let Ok(Value::Object(v)) = serde_json::to_value(doc.styles.resolve_para(pf).0) else { continue };
match &first {
None => first = Some(v),
Some(f) => mixed.extend(v.into_iter().filter(|(k, x)| f.get(k) != Some(x)).map(|(k, _)| k)),
}
}
}
mixed.into_iter().collect()
}
fn apply_typing_format(cf: &mut designcraft_doc::CharFormat, f: &TypingFormat) {
+235 -45
View File
@@ -1416,6 +1416,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
_ => "Paragraph Borders and Shading",
},
),
"justification" => crate::i18n::tr(&app.ui.language, "Justification"),
"insertXref" => crate::i18n::tr(&app.ui.language, "New Cross-Reference"),
"findFont" => crate::i18n::tr(&app.ui.language, "Find/Replace Font"),
"polygonSettings" => crate::i18n::tr(&app.ui.language, "Polygon Settings"),
@@ -1644,6 +1645,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
paragraph_rules(app, ui, &mut d, "", &current);
}
"paragraphLocal" => paragraph_local(app, ui, &mut d),
"justification" => {
let current = d.fields.get("current").cloned().unwrap_or_default();
justification(&app.ui.language, ui, &mut d, "", &current);
}
"insertXref" => insert_xref(app, ui, &mut d),
"findFont" => find_font(app, ui, &mut d),
"colorPicker" => color_picker(ui, &mut d),
@@ -2246,6 +2251,22 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
}
app.run("type.para", json!({"attrs": attrs}))
}
"justification" => {
let current = d.fields.get("current").cloned().unwrap_or_default();
if let Err(e) = check_justification(&d, "", &current) {
// The dialog stays open to be corrected.
app.status(e.clone());
let mut d = d;
d.fields.insert("justifyError".into(), json!(e));
app.ui.dialog = Some(d);
return Err(e);
}
let attrs: Map<String, Value> = JUSTIFY_ATTRS.iter().filter_map(|k| d.fields.get(*k).map(|v| (k.to_string(), v.clone()))).collect();
if attrs.is_empty() {
return Ok(Value::Null);
}
app.run("type.para", json!({"attrs": attrs}))
}
"paragraphRules" => {
let attrs = rule_edits(&d, "");
if attrs.is_empty() {
@@ -2633,6 +2654,14 @@ fn grep_error(p: &str) -> Option<String> {
fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Result<Value, String> {
let name = d.s("name");
// What the Justification section showed: the style's own values, or a new style's Based On.
let shown = if d.b("new") { d.s("basedOn") } else { name.clone() };
let base = app.session.active().map(|st| serde_json::to_value(st.doc.styles.resolve_para_style(&shown).0).unwrap_or_default()).unwrap_or_default();
if let Err(e) = check_justification(d, "p.", &base) {
// The caller shows the reason in the dialog's status line.
d.fields.insert("section".into(), json!("justify"));
return Err(e);
}
let mut para = serde_json::Map::new();
let mut chars = serde_json::Map::new();
for (k, v) in &d.fields {
@@ -3098,51 +3127,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
}
});
}
"justify" => {
egui::Grid::new("psj").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label("");
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Minimum"));
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Desired"));
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Maximum"));
ui.end_row();
for (label, base) in [("Word Spacing:", "wordSpace"), ("Letter Spacing:", "letterSpace"), ("Glyph Scaling:", "glyphScale")] {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
for suffix in ["Min", "Desired", "Max"] {
let key = format!("{base}{suffix}");
let v = cur(d, &format!("p.{key}"), &pv[key.as_str()]).as_f64().map(|x| x * 100.0);
if let Some(n) = ui.scope(|ui| crate::widgets::number(ui, &format!("ps{key}"), v, "%", 60.0, 0)).inner {
d.fields.insert(format!("p.{key}"), json!(n / 100.0));
}
}
ui.end_row();
}
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Composer:"));
let (single, [paragraph, single_line]) =
crate::panels::properties::composer_choice(cur(d, "p.composer", &pv["composer"]).as_str());
if ui.selectable_label(!single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Paragraph Composer"))).clicked() {
d.fields.insert("p.composer".into(), json!(paragraph));
}
if ui.selectable_label(single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Single-line Composer"))).clicked() {
d.fields.insert("p.composer".into(), json!(single_line));
}
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Insert Kashidas:"));
let mut k = cur(d, "p.kashidas", &pv["kashidas"]).as_bool().unwrap_or(true);
if ui.checkbox(&mut k, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "In justified Arabic text"))).changed() {
d.fields.insert("p.kashidas".into(), json!(k));
}
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Korean Line Breaks:"));
let mut kb = cur(d, "p.koreanCharBreaks", &pv["koreanCharBreaks"]).as_bool().unwrap_or(false);
if ui
.checkbox(&mut kb, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Between syllables (not only at spaces)")))
.changed()
{
d.fields.insert("p.koreanCharBreaks".into(), json!(kb));
}
ui.end_row();
});
}
"justify" => justification(&app.ui.language, ui, d, "p.", &pv),
"color" => character_color(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc),
"advanced" => advanced_character_formats(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }),
"openType" => open_type_features(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }),
@@ -4617,6 +4602,113 @@ fn shown_rule(d: &Dialog, prefix: &str, which: &str, current: &Value) -> Value {
Value::Object(r)
}
/// The paragraph attributes of the Justification controls.
pub const JUSTIFY_ATTRS: [&str; 12] = [
"wordSpaceMin",
"wordSpaceDesired",
"wordSpaceMax",
"letterSpaceMin",
"letterSpaceDesired",
"letterSpaceMax",
"glyphScaleMin",
"glyphScaleDesired",
"glyphScaleMax",
"composer",
"kashidas",
"koreanCharBreaks",
];
/// The spacing rows of the Justification controls: (label, attribute stem, allowed percent range).
const JUSTIFY_ROWS: [(&str, &str, (f64, f64)); 3] = [
("Word Spacing", "wordSpace", (0.0, 1000.0)),
("Letter Spacing", "letterSpace", (-100.0, 500.0)),
("Glyph Scaling", "glyphScale", (50.0, 200.0)),
];
/// Checks the edited spacing values: each within its range, and minimum ≤ desired ≤ maximum
/// (values left mixed are not compared).
fn check_justification(d: &Dialog, prefix: &str, current: &Value) -> Result<(), String> {
for (label, stem, (lo, hi)) in JUSTIFY_ROWS {
let mut edited = false;
let mut known = Vec::new();
for suffix in ["Min", "Desired", "Max"] {
let key = format!("{stem}{suffix}");
let v = match d.fields.get(&format!("{prefix}{key}")) {
Some(v) => {
edited = true;
let pct =
v.as_f64().map(|x| x * 100.0).filter(|x| x.is_finite()).ok_or_else(|| format!("Justification: {label} is not a number"))?;
// The fields show whole percents; compare what was shown.
if pct.round() < lo || pct.round() > hi {
return Err(format!("Justification: {label} must be between {lo}% and {hi}%"));
}
Some(pct)
}
None => current[key.as_str()].as_f64().map(|x| x * 100.0),
};
known.extend(v);
}
if edited && known.windows(2).any(|w| w[0] > w[1] + 1e-9) {
return Err(format!("Justification: {label} needs Minimum ≤ Desired ≤ Maximum"));
}
}
Ok(())
}
/// The Justification controls (the Justification dialog, and the Paragraph Style Options section).
/// `current` holds the resolved paragraph attributes, `null` where the selected paragraphs differ
/// (the field shows empty); each edit is stored as `{prefix}{attr}`, so OK changes only the fields
/// edited.
fn justification(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, current: &Value) {
let cur = |d: &Dialog, k: &str| d.fields.get(&format!("{prefix}{k}")).cloned().unwrap_or_else(|| current[k].clone());
egui::Grid::new("psj").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label("");
crate::rtl::label(ui, crate::i18n::tr(lang, "Minimum"));
crate::rtl::label(ui, crate::i18n::tr(lang, "Desired"));
crate::rtl::label(ui, crate::i18n::tr(lang, "Maximum"));
ui.end_row();
for (label, base, _) in JUSTIFY_ROWS {
crate::rtl::label(ui, crate::i18n::tr(lang, &format!("{label}:")));
for suffix in ["Min", "Desired", "Max"] {
let key = format!("{base}{suffix}");
let v = cur(d, &key).as_f64().map(|x| x * 100.0);
if let Some(n) = ui.scope(|ui| crate::widgets::number(ui, &format!("ps{key}"), v, "%", 60.0, 0)).inner {
d.fields.insert(format!("{prefix}{key}"), json!(n / 100.0));
}
}
ui.end_row();
}
crate::rtl::label(ui, crate::i18n::tr(lang, "Composer:"));
let composer = cur(d, "composer");
// Mixed (null) selects neither; a Japanese composer keeps its kind.
let (single, [paragraph, single_line]) = crate::panels::properties::composer_choice(composer.as_str());
let known = !composer.is_null();
if ui.selectable_label(known && !single, crate::rtl::widget(ui, crate::i18n::tr(lang, "Paragraph Composer"))).clicked() {
d.fields.insert(format!("{prefix}composer"), json!(paragraph));
}
if ui.selectable_label(known && single, crate::rtl::widget(ui, crate::i18n::tr(lang, "Single-line Composer"))).clicked() {
d.fields.insert(format!("{prefix}composer"), json!(single_line));
}
ui.end_row();
for (label, key, text, default) in [
("Insert Kashidas:", "kashidas", "In justified Arabic text", true),
("Korean Line Breaks:", "koreanCharBreaks", "Between syllables (not only at spaces)", false),
] {
crate::rtl::label(ui, crate::i18n::tr(lang, label));
let v = cur(d, key);
let mut on = v.as_bool().unwrap_or(default);
if ui.add(egui::Checkbox::new(&mut on, crate::rtl::widget(ui, crate::i18n::tr(lang, text))).indeterminate(v.is_null())).changed() {
d.fields.insert(format!("{prefix}{key}"), json!(on));
}
ui.end_row();
}
});
if let Some(e) = d.fields.get("justifyError").and_then(Value::as_str) {
ui.add_space(6.0);
crate::rtl::label(ui, egui::RichText::new(e).color(ui.visuals().error_fg_color));
}
}
/// The rule fields edited in the dialog, as `type.para` attrs: `{ruleAbove?: {…}, ruleBelow?: {…}}`.
fn rule_edits(d: &Dialog, prefix: &str) -> Map<String, Value> {
let mut out = Map::new();
@@ -5545,6 +5637,104 @@ mod tests {
draw(&mut app, &ctx);
}
/// A frame with two paragraphs of [Basic Paragraph]; the first has a local Word Spacing minimum.
fn two_paragraphs() -> (DesignApp, Value) {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let r = app.run("frame.create", json!({"rect": [72, 72, 300, 400], "content": "text", "text": "One\nTwo"})).unwrap();
app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
app.run("type.para", json!({"attrs": {"wordSpaceMin": 0.7}})).unwrap();
(app, r["story"].clone())
}
/// The resolved paragraph attribute `key` of the paragraph at byte `at`.
fn para_attr(app: &mut DesignApp, story: &Value, at: usize, key: &str) -> Value {
app.run("text.select", json!({"story": story, "anchor": at, "focus": at})).unwrap();
app.run("type.selectionAttrs", json!({})).unwrap()["para"][key].clone()
}
#[test]
fn justification_dialog_applies_to_a_local_paragraph_and_cancel_changes_nothing() {
let (mut app, story) = two_paragraphs();
let ctx = egui::Context::default();
assert!(crate::menus::menu_enabled(&app, "app.justificationDialog"));
app.run("app.justificationDialog", json!({})).unwrap();
draw(&mut app, &ctx);
let d = app.ui.dialog.clone().unwrap();
assert_eq!(d.id, "justification");
assert_eq!(d.fields["current"]["wordSpaceMin"], 0.7);
assert_eq!(d.fields["current"]["wordSpaceDesired"], 1.0);
app.ui.dialog.as_mut().unwrap().fields.insert("wordSpaceDesired".into(), json!(1.1));
draw(&mut app, &ctx);
let before = undo_steps(&app);
confirm(&mut app).unwrap();
assert_eq!(undo_steps(&app), before + 1, "one undo step");
assert_eq!(para_attr(&mut app, &story, 0, "wordSpaceDesired"), 1.1);
assert_eq!(para_attr(&mut app, &story, 0, "wordSpaceMin"), 0.7, "unedited fields stay");
assert_eq!(para_attr(&mut app, &story, 4, "wordSpaceDesired"), 1.0, "only the selected paragraph");
let style = app.session.doc().unwrap().doc.styles.resolve_para_style(designcraft_doc::story::BASIC_PARAGRAPH).0.word_space_desired;
assert_eq!(style, 1.0, "the paragraph style keeps its value");
// Cancel (Escape) leaves the paragraph alone.
app.run("text.select", json!({"story": story, "anchor": 0, "focus": 0})).unwrap();
app.run("app.justificationDialog", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("wordSpaceDesired".into(), json!(1.2));
let before = undo_steps(&app);
let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
events: vec![escape],
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
assert!(app.ui.dialog.is_none(), "cancelled");
assert_eq!(undo_steps(&app), before);
assert_eq!(para_attr(&mut app, &story, 0, "wordSpaceDesired"), 1.1);
app.run("edit.deselectAll", json!({})).unwrap();
assert!(!crate::menus::menu_enabled(&app, "app.justificationDialog"), "nothing to justify");
assert!(app.run("app.justificationDialog", json!({})).is_err());
}
#[test]
fn justification_dialog_leaves_mixed_fields_alone_and_refuses_bad_values() {
let (mut app, story) = two_paragraphs();
let ctx = egui::Context::default();
app.run("text.select", json!({"story": story, "anchor": 0, "focus": 7})).unwrap();
app.run("app.justificationDialog", json!({})).unwrap();
draw(&mut app, &ctx);
let d = app.ui.dialog.clone().unwrap();
assert_eq!(d.fields["current"]["wordSpaceMin"], Value::Null, "mixed shows empty");
assert_eq!(d.fields["current"]["wordSpaceMax"], 1.33);
// Out of range, and minimum above desired: an error, the dialog stays open, nothing changes.
let before = undo_steps(&app);
for (key, v) in [("wordSpaceMax", 20.0), ("glyphScaleMin", 0.2), ("letterSpaceDesired", f64::NAN)] {
app.ui.dialog = Some(d.clone());
app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), json!(v));
assert!(confirm(&mut app).is_err(), "{key} = {v}");
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.fields.contains_key("justifyError")), "{key}: the dialog shows why");
}
app.ui.dialog = Some(d.clone());
app.ui.dialog.as_mut().unwrap().fields.insert("letterSpaceMin".into(), json!(0.1));
assert!(confirm(&mut app).is_err(), "letter spacing minimum above desired");
draw(&mut app, &ctx);
assert_eq!(undo_steps(&app), before);
app.ui.dialog = Some(d);
app.ui.dialog.as_mut().unwrap().fields.insert("wordSpaceMax".into(), json!(1.5));
confirm(&mut app).unwrap();
assert_eq!(undo_steps(&app), before + 1);
for (at, min) in [(0, 0.7), (4, 0.8)] {
assert_eq!(para_attr(&mut app, &story, at, "wordSpaceMax"), 1.5);
assert_eq!(para_attr(&mut app, &story, at, "wordSpaceMin"), min, "each paragraph keeps its own minimum");
}
}
fn command_app(id: &str, fields: Value) -> DesignApp {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.ui.dialog = Some(Dialog::new(&format!("cmd:{id}"), fields));
+2 -1
View File
@@ -2221,7 +2221,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
("Indents and Spacing", ["Indents and Spacing", "Indents and Spacing", "Indents and Spacing", "Indents and Spacing", "缩进和间距"]),
("Hyphenation", ["Hyphenation", "Hyphenation", "Hyphenation", "Hyphenation", "断字"]),
("Justification", ["Justification", "Justification", "Justification", "Justification", "两端对齐"]),
("Justification", ["Blocksatzeinstellungen", "Justification", "Justificación", "両端揃え", "两端对齐"]),
(
"Drop Caps and Nested Styles",
[
@@ -3164,6 +3164,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
// Paragraph Rules.
("Paragraph Rules…", ["Absatzlinien …", "Filets de paragraphe…", "Filetes de párrafo…", "段落境界線…", "段落线…"]),
("Paragraph Rules", ["Absatzlinien", "Filets de paragraphe", "Filetes de párrafo", "段落境界線", "段落线"]),
("Justification…", ["Blocksatzeinstellungen …", "Justification…", "Justificación…", "両端揃え…", "两端对齐…"]),
("Rule Below", ["Linie unterhalb", "Filet après", "Filete después", "後境界線", "下方横线"]),
("Tint:", ["Farbton:", "Teinte:", "Matiz:", "濃淡:", "色调:"]),
("Column", ["Spalte", "Colonne", "Columna", "段", "栏"]),
+1
View File
@@ -1488,6 +1488,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
),
("Utilities", "أدوات مساعدة"),
("Paragraph Rules…", "خطوط الفقرة…"),
("Justification…", "الضبط…"),
("Paragraph Rules", "خطوط الفقرة"),
("Rule Below", "خط سفلي"),
("Left-aligned tab", "علامة جدولة محاذاة لليسار"),
+1
View File
@@ -1501,6 +1501,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Strikethrough On", "Barrato attivo"),
("Edit \"{name}\"…", "Modifica \"{name}\"…"),
("Paragraph Rules…", "Filetti paragrafo…"),
("Justification…", "Giustificazione…"),
("Paragraph Rules", "Filetti paragrafo"),
("Rule Below", "Filetto sotto"),
("Tint:", "Tinta:"),
+1
View File
@@ -1941,6 +1941,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Lock Layer", "レイヤーをロック"),
("Print Layer", "レイヤーをプリント"),
("Suppress Text Wrap When Layer is Hidden", "非表示レイヤーのテキストの回り込みを無視"),
("Justification…", "両端揃え…"),
];
/// Captions whose Japanese depends on where they appear: (English, context, Japanese).
+1
View File
@@ -1083,6 +1083,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Page (1–{count}):", "Página (1–{count}):"),
("Fonts in Document: {count} Missing: {missing}", "Fontes no documento: {count} Ausentes: {missing}"),
("Paragraph Rules…", "Linhas de parágrafo…"),
("Justification…", "Justificação…"),
("Paragraph Rules", "Linhas de parágrafo"),
("Rule Below", "Linha abaixo"),
("Left-aligned tab", "Tabulação alinhada à esquerda"),
+1
View File
@@ -1634,6 +1634,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Lock Layer", "Заблокувати шар"),
("Print Layer", "Друкувати шар"),
("Suppress Text Wrap When Layer is Hidden", "Вимикати обтікання текстом, коли шар приховано"),
("Justification…", "Вирівнювання за шириною…"),
];
/// Contexts whose Ukrainian noun differs from the shared action/text-layout label.
+22
View File
@@ -92,6 +92,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
),
("app.spanColumnsDialog", "Span Columns…", None, "{} — span or split columns of the selected paragraphs (type.para)"),
("app.bulletsNumberingDialog", "Bullets and Numbering…", None, "{} — bullets or numbers of the selected paragraphs (type.para)"),
(
"app.justificationDialog",
"Justification…",
Some("Cmd+Alt+Shift+J"),
"{} — word and letter spacing, glyph scaling and composer of the selected paragraphs (type.para)",
),
("app.findFontDialog", "Find/Replace Font…", None, "{} — fonts used (missing ones flagged) and replacing them"),
("app.insertXrefDialog", "Insert Cross-Reference…", None, "{} — New Cross-Reference dialog (paragraph or text anchor, format)"),
("app.deleteAllGuides", "Delete All Guides on Spread", None, "{} — the spread in view"),
@@ -427,6 +433,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"cmd:type.alignRight",
"cmd:type.justify",
"ui:app.paragraphRulesDialog",
"ui:app.justificationDialog",
"ui:app.paragraphBordersDialog",
"ui:app.spanColumnsDialog",
"ui:app.bulletsNumberingDialog",
@@ -1027,6 +1034,18 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphLocal", fields));
Ok(Value::Null)
}
"app.justificationDialog" => {
let Some(a) = app.session.execute("type.selectionAttrs", &json!({"mixed": true})).ok().filter(|a| !a.is_null()) else {
return Some(Err("select text or a text frame".into()));
};
let mixed: Vec<&str> = a["paraMixed"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
let current: serde_json::Map<String, Value> = crate::dialogs::JUSTIFY_ATTRS
.iter()
.map(|k| (k.to_string(), if mixed.contains(k) { Value::Null } else { a["para"][*k].clone() }))
.collect();
app.ui.dialog = Some(crate::dialogs::Dialog::new("justification", json!({"current": current})));
Ok(Value::Null)
}
"app.footnoteOptionsDialog" => {
let Some(st) = app.session.active() else { return Some(Err("no document open".into())) };
let o = serde_json::to_value(&st.doc.footnote_options).unwrap_or_default();
@@ -2023,6 +2042,9 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
if waits_for_ime(app, id) {
return false;
}
if id == "app.justificationDialog" {
return enabled(app, "type.para");
}
if ui_label(id).is_some() {
return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help.");
}
+1
View File
@@ -1471,6 +1471,7 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
crate::menus::menu_button(ui, "☰", |ui| {
for (label, id) in [
("Paragraph Rules…", "app.paragraphRulesDialog"),
("Justification…", "app.justificationDialog"),
("Paragraph Borders and Shading…", "app.paragraphBordersDialog"),
("Span Columns…", "app.spanColumnsDialog"),
("Bullets and Numbering…", "app.bulletsNumberingDialog"),
+2 -2
View File
@@ -1,6 +1,6 @@
# Typography and composition parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate) · **Target:** Adobe InDesign 2026 (21.6)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; Justification dialog for the selected paragraphs) · **Target:** Adobe InDesign 2026 (21.6)
Typesetting is what InDesign is bought for. A feature can exist and still set text differently
from InDesign: different line breaks, different page count, overset where InDesign fits. This
@@ -35,7 +35,7 @@ Fidelity: **unmeasured** unless stated.
| Paragraph Composer | Adobe's multi-line composer | Knuth–Plass total fit, fitness classes, hyphen penalties | ~ | same family of algorithm, Adobe's weights unknown; breaks will differ on some paragraphs |
| Single-line Composer | greedy | greedy with the same ranges | ✓ | |
| World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested |
| Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab now justifies after the tab (#284) |
| Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab justifies after the tab (#284); set per paragraph in Type › Justification… and the Paragraph panel menu (#531) |
| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list) and Spanish (Bezos); zone, limits, exceptions in the user dictionary | ~ | other languages don't hyphenate (#87 reported "doesn't work") |
| Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | |
| Kerning | Metrics, Optical, manual, none | Metrics, Optical (outline-based: pairs pulled towards the font's stem gap, `crates/compose/src/optical.rs`), manual, none | ~ | Optical is an approximation of Adobe's algorithm (#181), not a match: imported display text can still break differently |