diff --git a/ROADMAP.md b/ROADMAP.md index 020518b92..0c8f1c855 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-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. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b8f93cf00..d9c68a62e 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -307,7 +307,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 + ), ] } @@ -1446,7 +1454,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) }; @@ -1459,8 +1467,31 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result { 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 { + 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() } fn apply_typing_format(cf: &mut designcraft_doc::CharFormat, f: &TypingFormat) { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 923ffc64f..d15f6fdbf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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, "", ¤t); } "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, "", ¤t); + } "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 { } 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, "", ¤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() { @@ -2633,6 +2654,14 @@ fn grep_error(p: &str) -> Option { fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Result { 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 { 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)); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index df094f887..beaad60c5 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -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", "段", "栏"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index d6c1a4819..99f67f850 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1488,6 +1488,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ), ("Utilities", "أدوات مساعدة"), ("Paragraph Rules…", "خطوط الفقرة…"), + ("Justification…", "الضبط…"), ("Paragraph Rules", "خطوط الفقرة"), ("Rule Below", "خط سفلي"), ("Left-aligned tab", "علامة جدولة محاذاة لليسار"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 1f947fd66..a374467cc 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -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:"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index b2e903910..aa59ccd42 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -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). diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 04ced8e95..cd60c449d 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -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"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9e4db9660..8d688dcce 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -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. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3d7073326..cf5d84810 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { + 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(); @@ -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."); } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index ad0cac4d6..c1a6e3eae 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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"), diff --git a/docs/typography-parity.md b/docs/typography-parity.md index d90b315f0..8b97da9db 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-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 |