From 3f752312d6229acc6e9f36346bb69b2c77a00bf3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 20:46:38 +0000 Subject: [PATCH] Type: Justification dialog for the selected paragraphs (#531) Type > Justification... and the Paragraph panel menu (Cmd+Alt+Shift+J) open the Justification controls of Paragraph Style Options for the selected paragraphs: word and letter spacing, glyph scaling, composer, kashidas and Korean line breaks. Values that differ across the selection show empty, and OK applies only the edited fields through type.para as one undo step. Spacing values outside their range or out of minimum/desired/maximum order keep the dialog open with the reason, in both dialogs. type.selectionAttrs takes {mixed: true} and lists the paragraph attributes that differ across the selected paragraphs as paraMixed. --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 39 +++- crates/ui-egui/src/dialogs.rs | 283 ++++++++++++++++++++---- crates/ui-egui/src/i18n.rs | 3 +- crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/menus.rs | 22 ++ crates/ui-egui/src/panels/properties.rs | 4 + docs/typography-parity.md | 4 +- 12 files changed, 310 insertions(+), 51 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9a0f71e70..fcc8d78d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change. - **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI smoke-tested under QEMU). diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 4950d82b9..52b829cd7 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -253,7 +253,15 @@ pub fn specs() -> Vec { 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 + ), ] } @@ -957,7 +965,7 @@ fn open_type(s: &mut Session, p: &Value) -> Result { Ok(json!({"features": list, "figures": otf::figures(&list)})) } -fn selection_attrs(s: &mut Session, _p: &Value) -> Result { +fn selection_attrs(s: &mut Session, p: &Value) -> Result { 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) }; @@ -967,8 +975,31 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result { let (pp, base) = st.doc.styles.resolve_para(pf); let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) }; 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 { + let mut first: Option> = 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() } /// Our own filler text (not Adobe's). diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2ab7a3bad..4d2933de9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1132,6 +1132,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "characterStyleOptions" => crate::i18n::tr(&app.ui.language, "Character Style Options"), "footnoteOptions" => crate::i18n::tr(&app.ui.language, "Footnote Options"), "paragraphRules" => crate::i18n::tr(&app.ui.language, "Paragraph Rules"), + "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"), @@ -1393,6 +1394,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let current = d.fields.get("current").cloned().unwrap_or_default(); paragraph_rules(app, ui, &mut d, "", ¤t); } + "justification" => { + let current = d.fields.get("current").cloned().unwrap_or_default(); + justification(&app.ui.language, ui, &mut d, "", ¤t); + } "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), "colorPicker" => color_picker(ui, &mut d), @@ -1808,6 +1813,17 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "paragraphStyleOptions" => { 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) { + app.status(e.clone()); + let mut d = d; + d.fields.insert("justifyError".into(), json!(e)); + d.fields.insert("section".into(), json!("justify")); + app.ui.dialog = Some(d); + return Err(e); + } let mut para = serde_json::Map::new(); let mut chars = serde_json::Map::new(); for (k, v) in &d.fields { @@ -1855,6 +1871,22 @@ pub fn confirm(app: &mut DesignApp) -> Result { } Ok(r) } + "justification" => { + let current = d.fields.get("current").cloned().unwrap_or_default(); + if let Err(e) = check_justification(&d, "", ¤t) { + // 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 = 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() { @@ -2557,50 +2589,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 = cur(d, "p.composer", &pv["composer"]).as_str() == Some("singleLine"); - 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!("singleLine")); - } - 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), _ => { egui::Grid::new("psg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { @@ -3189,6 +3178,114 @@ 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"); + if ui.selectable_label(composer.as_str() == Some("paragraph"), crate::rtl::widget(ui, crate::i18n::tr(lang, "Paragraph Composer"))).clicked() + { + d.fields.insert(format!("{prefix}composer"), json!("paragraph")); + } + if ui + .selectable_label(composer.as_str() == Some("singleLine"), crate::rtl::widget(ui, crate::i18n::tr(lang, "Single-line Composer"))) + .clicked() + { + d.fields.insert(format!("{prefix}composer"), json!("singleLine")); + } + 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 { let mut out = Map::new(); @@ -4070,6 +4167,104 @@ mod tests { assert_eq!(resolved(&app)["ruleBelow"], rule); } + /// 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)); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d08..847f4adfa 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1894,7 +1894,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", [ @@ -2808,6 +2808,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", "段", "栏"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 10e393cd2..dff8a9294 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1467,6 +1467,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ), ("Utilities", "أدوات مساعدة"), ("Paragraph Rules…", "خطوط الفقرة…"), + ("Justification…", "الضبط…"), ("Paragraph Rules", "خطوط الفقرة"), ("Rule Below", "خط سفلي"), ]; diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c91afd6ba..6a982d8d2 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1489,6 +1489,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:"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index be6e2c4da..b4f6d1e2d 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1670,6 +1670,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Vertical:", "垂直方向:"), ("Working CMYK", "作業用 CMYK"), ("Yellow", "イエロー"), + ("Justification…", "両端揃え…"), ]; #[cfg(test)] diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index d25717dc6..6bf8f1575 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1071,6 +1071,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"), ]; diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9a9fb89ba..0f368430f 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1455,6 +1455,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Selection", "Виділення"), ("All Documents", "Усі документи"), ("On", "Увімкнено"), + ("Justification…", "Вирівнювання за шириною…"), ]; /// Contexts whose Ukrainian noun differs from the shared action/text-layout label. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d123ec4eb..ec6b1b65e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -68,6 +68,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{rule?: ruleAbove|ruleBelow} — Rule Above / Rule Below 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"), @@ -392,6 +398,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:type.alignRight", "cmd:type.justify", "ui:app.paragraphRulesDialog", + "ui:app.justificationDialog", "-", "cmd:type.bold", "cmd:type.italic", @@ -951,6 +958,18 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + 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 = 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(); @@ -1808,6 +1827,9 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { /// Enablement for menu display (UI commands need a document unless they're app/window-level). pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { + 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."); } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..b71c3130a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1416,6 +1416,10 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("app.paragraphRulesDialog", json!({})); ui.close(); } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Justification…"))).clicked() { + let _ = app.run("app.justificationDialog", json!({})); + ui.close(); + } }); }); }); diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 0339faf1e..86c41c598 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -1,6 +1,6 @@ # Typography and composition parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version, from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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, manual, none | ~ | **Optical treated as Metrics** (#181): imported display text sets wider and can overset |