From 27963d6d055133820ba7d6326d171822cd811dc0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 19:45:30 -0800 Subject: [PATCH 1/2] Justification: the dialog sets Auto Leading and Single Word Justification Type > Justification and Paragraph Style Options > Justification now show Auto Leading (0% to 500% of the type size) and Single Word Justification (Align Left, Align Center, Align Right, Full Justify). Both fields show the selected paragraphs' values, or blank where they differ, and OK writes autoLeading and singleWordJustify only when they were edited. An Auto Leading outside 0% to 500% keeps the dialog open with the reason. --- crates/ui-egui/src/dialogs.rs | 49 ++++++++++++- crates/ui-egui/src/dialogs/tests.rs | 107 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/ar.rs | 3 + crates/ui-egui/src/i18n/cs.rs | 3 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/hu.rs | 3 + crates/ui-egui/src/i18n/it.rs | 3 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 3 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 3 + crates/ui-egui/src/i18n/sv.rs | 2 + crates/ui-egui/src/i18n/table.rs | 5 ++ crates/ui-egui/src/i18n/uk.rs | 3 + docs/typography-parity.md | 2 +- 16 files changed, 191 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a6400054d..6664fc349 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7689,7 +7689,7 @@ fn shown_rule(d: &Dialog, prefix: &str, which: &str, current: &Value) -> Value { } /// The paragraph attributes of the Justification controls. -pub const JUSTIFY_ATTRS: [&str; 12] = [ +pub const JUSTIFY_ATTRS: [&str; 14] = [ "wordSpaceMin", "wordSpaceDesired", "wordSpaceMax", @@ -7699,6 +7699,8 @@ pub const JUSTIFY_ATTRS: [&str; 12] = [ "glyphScaleMin", "glyphScaleDesired", "glyphScaleMax", + "autoLeading", + "singleWordJustify", "composer", "kashidas", "koreanCharBreaks", @@ -7711,9 +7713,29 @@ const JUSTIFY_ROWS: [(&str, &str, (f64, f64)); 3] = [ ("Glyph Scaling", "glyphScale", (50.0, 200.0)), ]; +/// The allowed Auto Leading, in percent of the type size. +const AUTO_LEADING_RANGE: (f64, f64) = (0.0, 500.0); + +/// The Single Word Justification choices: (attribute value, label). +const SINGLE_WORD_CHOICES: [(&str, &str); 4] = + [("left", "Align Left"), ("center", "Align Center"), ("right", "Align Right"), ("fullyJustified", "Full Justify")]; + /// Checks the edited spacing values: each within its range, and minimum ≤ desired ≤ maximum -/// (values left mixed are not compared). +/// (values left mixed are not compared); an edited Auto Leading within its range, and an edited +/// Single Word Justification one of its choices. fn check_justification(d: &Dialog, prefix: &str, current: &Value) -> Result<(), String> { + if let Some(v) = d.fields.get(&format!("{prefix}autoLeading")) { + let (lo, hi) = AUTO_LEADING_RANGE; + let pct = v.as_f64().map(|x| x * 100.0).filter(|x| x.is_finite()).ok_or("Justification: Auto Leading is not a number")?; + if !(lo..=hi).contains(&pct) { + return Err(format!("Justification: Auto Leading must be between {lo}% and {hi}%")); + } + } + if let Some(v) = d.fields.get(&format!("{prefix}singleWordJustify")) + && !SINGLE_WORD_CHOICES.iter().any(|(k, _)| v.as_str() == Some(k)) + { + return Err(format!("Justification: Single Word Justification cannot be {v}")); + } for (label, stem, (lo, hi)) in JUSTIFY_ROWS { let mut edited = false; let mut known = Vec::new(); @@ -7916,6 +7938,29 @@ fn justification(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, cu } ui.end_row(); } + crate::rtl::label(ui, crate::i18n::tr(lang, "Auto Leading:")); + let auto = cur(d, "autoLeading").as_f64().map(|x| x * 100.0); + if let Some(n) = ui.scope(|ui| crate::widgets::number(ui, &format!("ps{prefix}autoLeading"), auto, "%", 60.0, 0)).inner { + d.fields.insert(format!("{prefix}autoLeading"), json!(n / 100.0)); + } + ui.end_row(); + let single_label = crate::rtl::label(ui, crate::i18n::tr(lang, "Single Word Justification:")); + let single = cur(d, "singleWordJustify"); + // Mixed (null) shows blank until a choice is made. + let shown = SINGLE_WORD_CHOICES.iter().find(|(k, _)| single.as_str() == Some(k)).map_or("", |(_, label)| label); + craft_ui::ComboBox::from_id_salt(("singleWordJustify", prefix)) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, shown))) + .width(130.0) + .show_ui(ui, |ui| { + for (v, label) in SINGLE_WORD_CHOICES { + if ui.selectable_label(single.as_str() == Some(v), crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() { + d.fields.insert(format!("{prefix}singleWordJustify"), json!(v)); + } + } + }) + .response + .labelled_by(single_label.id); + 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. diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 272165b08..1343b30d7 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -1130,6 +1130,113 @@ fn justification_dialog_leaves_mixed_fields_alone_and_refuses_bad_values() { } } +/// The Justification dialog shows Auto Leading and Single Word Justification, OK sets them on the +/// selected paragraph as one undo step, bad values keep the dialog open, and both values survive +/// saving and opening the native file and an IDML export. +#[test] +fn justification_dialog_sets_auto_leading_and_single_word_justification() { + use egui_kittest::{Harness, kittest::Queryable}; + let (mut app, story) = two_paragraphs(); + app.run("app.justificationDialog", json!({})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + let current = d.fields["current"].clone(); + assert_eq!((current["autoLeading"].clone(), current["singleWordJustify"].clone()), (json!(1.2), json!("fullyJustified"))); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("justification_single_word_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + assert!(h.query_by_label("Auto Leading:").is_some()); + assert!(h.query_by_label("Single Word Justification:").is_some()); + // The dropdown shows the paragraph's Full Justify; pick Align Center. + h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some("Full Justify")).click(); + h.run_steps(3); + h.get_by_label("Align Center").click(); + h.run_steps(3); + let mut app = std::mem::replace(h.state_mut(), DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())); + drop(h); + let ctx = egui::Context::default(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["singleWordJustify"], "center", "the choice is the edit"); + + // Out of range or not a choice: an error, the dialog stays open, nothing changes. + let d = app.ui.dialog.clone().unwrap(); + let before = undo_steps(&app); + let bad = [ + ("autoLeading", json!(5.004)), + ("autoLeading", json!(-0.004)), + ("autoLeading", json!(f64::NAN)), + ("singleWordJustify", json!("leftJustified")), + ]; + for (key, v) in bad { + app.ui.dialog = Some(d.clone()); + app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), v.clone()); + 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"); + } + assert_eq!(undo_steps(&app), before); + + app.ui.dialog = Some(d); + app.ui.dialog.as_mut().unwrap().fields.insert("autoLeading".into(), json!(1.5)); + draw(&mut app, &ctx); + confirm(&mut app).unwrap(); + assert_eq!(undo_steps(&app), before + 1, "one undo step"); + let both = |app: &mut DesignApp, story: &Value, at| (para_attr(app, story, at, "autoLeading"), para_attr(app, story, at, "singleWordJustify")); + assert_eq!(both(&mut app, &story, 0), (json!(1.5), json!("center"))); + assert_eq!(both(&mut app, &story, 4), (json!(1.2), json!("fullyJustified")), "only the selected paragraph"); + + // Both paragraphs selected: the fields that differ show blank, and OK with no edit adds no undo step. + app.run("text.select", json!({"story": story, "anchor": 0, "focus": 7})).unwrap(); + app.run("app.justificationDialog", json!({})).unwrap(); + draw(&mut app, &ctx); + let current = app.ui.dialog.as_ref().unwrap().fields["current"].clone(); + assert_eq!((current["autoLeading"].clone(), current["singleWordJustify"].clone()), (Value::Null, Value::Null)); + let before = undo_steps(&app); + confirm(&mut app).unwrap(); + assert_eq!(undo_steps(&app), before); + + // Saved and opened again, natively and through IDML. + let native = app.run("file.serialize", json!({})).unwrap(); + app.run("file.openBytes", json!({"name": "justify.designcraft", "base64": native["base64"]})).unwrap(); + assert_eq!(both(&mut app, &story, 0), (json!(1.5), json!("center"))); + let idml = app.run("file.exportIdml", json!({})).unwrap(); + app.run("file.openBytes", json!({"name": "justify.idml", "base64": idml["base64"]})).unwrap(); + let doc = &app.session.doc().unwrap().doc; + let sid = doc.stories.iter().find(|(_, s)| s.text == "One\nTwo").map(|(id, _)| *id).expect("the story comes back"); + let story = serde_json::to_value(sid).unwrap(); + assert_eq!(both(&mut app, &story, 0), (json!(1.5), json!("center"))); + assert_eq!(both(&mut app, &story, 4), (json!(1.2), json!("fullyJustified"))); +} + +/// Paragraph Style Options › Justification sets Auto Leading and Single Word Justification on the +/// style, and refuses an Auto Leading out of range. +#[test] +fn paragraph_style_options_justification_sets_auto_leading_and_single_word_justification() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Headline"})).unwrap(); + let ctx = egui::Context::default(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Headline", "section": "justify"}))); + draw(&mut app, &ctx); + app.ui.dialog.as_mut().unwrap().fields.insert("p.autoLeading".into(), json!(6.0)); + assert!(confirm(&mut app).unwrap_err().contains("Auto Leading")); + let style = |app: &DesignApp| app.session.doc().unwrap().doc.styles.para("Headline").unwrap().para.clone(); + assert_eq!(style(&app).auto_leading, None, "nothing saved"); + app.ui.dialog.as_mut().unwrap().fields.insert("p.autoLeading".into(), json!(1.0)); + app.ui.dialog.as_mut().unwrap().fields.insert("p.singleWordJustify".into(), json!("right")); + draw(&mut app, &ctx); + confirm(&mut app).unwrap(); + let para = style(&app); + assert_eq!((para.auto_leading, para.single_word_justify), (Some(1.0), Some(designcraft_doc::Align::Right))); +} + 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/ar.rs b/crates/ui-egui/src/i18n/ar.rs index adf22f4fb..1a75534a5 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -611,6 +611,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("In justified Arabic text", "في النص العربي المضبوط"), ("Korean Line Breaks:", "فواصل الأسطر الكورية:"), ("Between syllables (not only at spaces)", "بين المقاطع (وليس عند المسافات فقط)"), + ("Auto Leading:", "التباعد التلقائي بين الأسطر:"), + ("Single Word Justification:", "ضبط الكلمة المفردة:"), + ("Full Justify", "ضبط كامل"), ("Minimum Space Before First Footnote:", "أقل مسافة قبل الحاشية الأولى:"), ("Space Between Footnotes:", "المسافة بين الحواشي:"), ("Offset:", "الإزاحة:"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 5310e8d12..31fb1876c 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -1185,6 +1185,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("In justified Arabic text", "V arabském textu zarovnaném do bloku"), ("Korean Line Breaks:", "Zalomení řádků v korejštině:"), ("Between syllables (not only at spaces)", "Mezi slabikami (nejen u mezer)"), + ("Auto Leading:", "Automatický proklad:"), + ("Single Word Justification:", "Zarovnání jednoho slova:"), + ("Full Justify", "Plné zarovnání"), ("Binding:", "Vazba:"), ("Ruby:", "Ruby:"), ("Set over the selected text; empty removes it.", "Umístí se nad vybraný text; prázdné pole ho odstraní."), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 2e9920126..ce333f489 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -1155,6 +1155,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("In justified Arabic text", "Im arabischen Blocksatz"), ("Korean Line Breaks:", "Koreanische Zeilenumbrüche:"), ("Between syllables (not only at spaces)", "Zwischen Silben (nicht nur an Leerzeichen)"), + ("Auto Leading:", "Automatischer Zeilenabstand:"), + ("Single Word Justification:", "Ausrichtung einzelner Wörter:"), ("Binding:", "Bindung:"), ("Ruby:", "Ruby:"), ("Set over the selected text; empty removes it.", "Wird über den ausgewählten Text gesetzt; leer entfernt es."), diff --git a/crates/ui-egui/src/i18n/hu.rs b/crates/ui-egui/src/i18n/hu.rs index ab30bce8d..1a63f3fad 100644 --- a/crates/ui-egui/src/i18n/hu.rs +++ b/crates/ui-egui/src/i18n/hu.rs @@ -156,6 +156,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Before", "Előtte"), ("Before Last:", "Az utolsó előtt:"), ("Between syllables (not only at spaces)", "Szótagok között (nem csak szóközöknél)"), + ("Auto Leading:", "Automatikus sorköz:"), + ("Single Word Justification:", "Egyetlen szó igazítása:"), + ("Full Justify", "Teljes sorkizárás"), ("Bevel", "Levágott sarok"), ("Bevel and Emboss", "Domborítás"), ("Beveled Rectangle", "Levágott sarkú téglalap"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 131875d46..de768d8e9 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1329,6 +1329,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("In justified Arabic text", "Nel testo arabo giustificato"), ("Korean Line Breaks:", "Interruzioni di riga coreane:"), ("Between syllables (not only at spaces)", "Tra le sillabe (non solo agli spazi)"), + ("Auto Leading:", "Interlinea automatica:"), + ("Single Word Justification:", "Giustificazione parola singola:"), + ("Full Justify", "Giustifica tutto"), ("Binding:", "Rilegatura:"), ("Ruby:", "Ruby:"), ("Set over the selected text; empty removes it.", "Applicato sopra il testo selezionato; se vuoto viene rimosso."), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index ae2dd653f..e4f436171 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1351,6 +1351,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("In justified Arabic text", "両端揃えのアラビア語テキストで使用"), ("Korean Line Breaks:", "韓国語の改行:"), ("Between syllables (not only at spaces)", "音節間で改行 (スペース以外も可)"), + ("Auto Leading:", "自動行送り:"), + ("Single Word Justification:", "単語のみの行の揃え:"), ("Binding:", "綴じ方向:"), ("Ruby:", "ルビ:"), ("Set over the selected text; empty removes it.", "選択したテキストの上にルビを付けます。空欄にすると削除します。"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 1ba9a61a9..c9db1eab0 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1254,6 +1254,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("In justified Arabic text", "ორმხრივად სწორებულ არაბულ ტექსტში"), ("Korean Line Breaks:", "კორეული სტრიქონების წყვეტა:"), ("Between syllables (not only at spaces)", "მარცვლებს შორის (არა მხოლოდ ჰარებთან)"), + ("Auto Leading:", "ავტომატური სტრიქონთაშორისი:"), + ("Single Word Justification:", "ერთი სიტყვის სწორება:"), ("Binding:", "აკინძვა:"), ("Ruby:", "რუბი:"), ("Set over the selected text; empty removes it.", "დაისმება მონიშნული ტექსტის თავზე; ცარიელი წაშლის მას."), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 7aac5d74b..e0b10e9cd 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -1198,6 +1198,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("In justified Arabic text", "W justowanym tekście arabskim"), ("Korean Line Breaks:", "Podział wierszy w koreańskim:"), ("Between syllables (not only at spaces)", "Między sylabami (nie tylko na spacjach)"), + ("Auto Leading:", "Interlinia automatyczna:"), + ("Single Word Justification:", "Justowanie pojedynczego słowa:"), + ("Full Justify", "Pełne justowanie"), ("Binding:", "Oprawa:"), ("Ruby:", "Ruby:"), ("Set over the selected text; empty removes it.", "Nad zaznaczonym tekstem; puste usuwa."), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 147947dbd..2bc5fc935 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1962,6 +1962,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Utilities", "Utilitários"), ("Korean Line Breaks:", "Quebras de linha em coreano:"), ("Between syllables (not only at spaces)", "Entre sílabas (não apenas nos espaços)"), + ("Auto Leading:", "Entrelinha automática:"), + ("Single Word Justification:", "Justificação de palavra única:"), ("Show Font Names in English", "Mostrar nomes de fontes em inglês"), ("Missing Glyphs", "Glifos ausentes"), ("Draw Missing Glyphs from Fallback Fonts", "Desenhar glifos ausentes com fontes substitutas"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 178cc9bc3..e6c223d9d 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -1175,6 +1175,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("In justified Arabic text", "В арабском тексте, выровненном по ширине"), ("Korean Line Breaks:", "Переносы корейского текста:"), ("Between syllables (not only at spaces)", "Между слогами (а не только на пробелах)"), + ("Auto Leading:", "Автоинтерлиньяж:"), + ("Single Word Justification:", "Выключка одного слова:"), + ("Full Justify", "Полная выключка"), ("Binding:", "Переплет:"), ("Ruby:", "Руби:"), ("Set over the selected text; empty removes it.", "Размещается над выделенным текстом; пустое значение удаляет подпись."), diff --git a/crates/ui-egui/src/i18n/sv.rs b/crates/ui-egui/src/i18n/sv.rs index 766ba32da..485dffa5f 100644 --- a/crates/ui-egui/src/i18n/sv.rs +++ b/crates/ui-egui/src/i18n/sv.rs @@ -409,6 +409,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("In justified Arabic text", "I marginaljusterad arabisk text"), ("Korean Line Breaks:", "Koreanska radbrytningar:"), ("Between syllables (not only at spaces)", "Mellan stavelser (inte bara vid blanksteg)"), + ("Auto Leading:", "Automatiskt radavstånd:"), + ("Single Word Justification:", "Justering av enstaka ord:"), ("Minimum Space Before First Footnote:", "Minsta avstånd före första fotnoten:"), ("Space Between Footnotes:", "Avstånd mellan fotnoter:"), ("Offset:", "Förskjutning:"), diff --git a/crates/ui-egui/src/i18n/table.rs b/crates/ui-egui/src/i18n/table.rs index ebde2b629..c216d6b97 100644 --- a/crates/ui-egui/src/i18n/table.rs +++ b/crates/ui-egui/src/i18n/table.rs @@ -3480,6 +3480,11 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[ "在音节之间(不仅在空格处)", ], ), + ("Auto Leading:", ["Automatischer Zeilenabstand:", "Interlignage automatique :", "Interlineado automático:", "自動行送り:", "自动行距:"]), + ( + "Single Word Justification:", + ["Ausrichtung einzelner Wörter:", "Justification des mots isolés :", "Justificación de palabra única:", "単語のみの行の揃え:", "单字齐行:"], + ), ("Binding:", ["Binding:", "Reliure :", "Binding:", "Binding:", "装订方向:"]), ("Ruby:", ["Ruby:", "Ruby :", "Ruby:", "Ruby:", "旁注:"]), ( diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 4b2853eae..65dace8a5 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1326,6 +1326,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("In justified Arabic text", "В арабському тексті, вирівняному за шириною"), ("Korean Line Breaks:", "Переноси корейського тексту:"), ("Between syllables (not only at spaces)", "Між складами (а не лише на пробілах)"), + ("Auto Leading:", "Автоінтерліньяж:"), + ("Single Word Justification:", "Вирівнювання одного слова:"), + ("Full Justify", "Повне вирівнювання"), ("Binding:", "Напрямок палітурки:"), ("Ruby:", "Фонетичні підписи:"), ("Set over the selected text; empty removes it.", "Розміщується над виділеним текстом; порожнє значення вилучає підпис."), diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 8e17cd2a9..1064ef823 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -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 justifies after the tab (#284); set per paragraph in Type › Justification… and the Paragraph panel menu (#531) | +| 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); Auto Leading (0% to 500%) and Single Word Justification are set there and in Paragraph Style Options › Justification | | Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no), Danish (hyph-da), Greek (hyph-el-monoton), German 2006 (hyph-de-1996), French (hyph-fr), Italian (hyph-it) and Dutch (Tutelaers); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame; discretionary U+00AD breaks with both composers regardless of automatic hyphenation and replaces pattern points within its word (#1955) | ~ | 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 | From c69db898623c0efebf920a30c33c9f4e98707cb6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:06:28 -0800 Subject: [PATCH 2/2] Justification: Auto Leading stores the shown percent, and the current choice is no edit The Auto Leading field shows whole percents, and it now stores the whole percent it shows, so a typed 500.4% (shown as 500%) is accepted as 500% instead of being refused. Picking the Single Word Justification the paragraphs already have is no longer an edit, so OK adds no undo step for it. --- crates/ui-egui/src/dialogs.rs | 16 ++++++++++++++-- crates/ui-egui/src/dialogs/tests.rs | 24 ++++++++++++++++++++---- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6664fc349..1ad599bbd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7716,6 +7716,12 @@ const JUSTIFY_ROWS: [(&str, &str, (f64, f64)); 3] = [ /// The allowed Auto Leading, in percent of the type size. const AUTO_LEADING_RANGE: (f64, f64) = (0.0, 500.0); +/// The Auto Leading stored for a percent typed into its field: the whole percent the field shows. +fn auto_leading_typed(pct: f64) -> f64 { + // Adding 0.0 turns a rounded -0 into 0. + (pct.round() + 0.0) / 100.0 +} + /// The Single Word Justification choices: (attribute value, label). const SINGLE_WORD_CHOICES: [(&str, &str); 4] = [("left", "Align Left"), ("center", "Align Center"), ("right", "Align Right"), ("fullyJustified", "Full Justify")]; @@ -7941,7 +7947,7 @@ fn justification(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, cu crate::rtl::label(ui, crate::i18n::tr(lang, "Auto Leading:")); let auto = cur(d, "autoLeading").as_f64().map(|x| x * 100.0); if let Some(n) = ui.scope(|ui| crate::widgets::number(ui, &format!("ps{prefix}autoLeading"), auto, "%", 60.0, 0)).inner { - d.fields.insert(format!("{prefix}autoLeading"), json!(n / 100.0)); + d.fields.insert(format!("{prefix}autoLeading"), json!(auto_leading_typed(n))); } ui.end_row(); let single_label = crate::rtl::label(ui, crate::i18n::tr(lang, "Single Word Justification:")); @@ -7954,7 +7960,13 @@ fn justification(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, cu .show_ui(ui, |ui| { for (v, label) in SINGLE_WORD_CHOICES { if ui.selectable_label(single.as_str() == Some(v), crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() { - d.fields.insert(format!("{prefix}singleWordJustify"), json!(v)); + // The paragraphs' own choice is no edit, so OK adds no undo step for it. + let key = format!("{prefix}singleWordJustify"); + if current["singleWordJustify"].as_str() == Some(v) { + d.fields.remove(&key); + } else { + d.fields.insert(key, json!(v)); + } } } }) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 1343b30d7..2e3cd162a 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -1156,8 +1156,15 @@ fn justification_dialog_sets_auto_leading_and_single_word_justification() { h.run_steps(3); assert!(h.query_by_label("Auto Leading:").is_some()); assert!(h.query_by_label("Single Word Justification:").is_some()); - // The dropdown shows the paragraph's Full Justify; pick Align Center. - h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some("Full Justify")).click(); + use egui::accesskit::Role; + // The dropdown shows the paragraph's Full Justify. Picking Full Justify again is no edit. + h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some("Full Justify")).click(); + h.run_steps(3); + h.get_by(|n| n.role() != Role::ComboBox && n.label().as_deref() == Some("Full Justify")).click(); + h.run_steps(3); + assert!(!h.state().ui.dialog.as_ref().unwrap().fields.contains_key("singleWordJustify"), "the paragraph's own choice is no edit"); + // Pick Align Center. + h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some("Full Justify")).click(); h.run_steps(3); h.get_by_label("Align Center").click(); h.run_steps(3); @@ -1170,8 +1177,8 @@ fn justification_dialog_sets_auto_leading_and_single_word_justification() { let d = app.ui.dialog.clone().unwrap(); let before = undo_steps(&app); let bad = [ - ("autoLeading", json!(5.004)), - ("autoLeading", json!(-0.004)), + ("autoLeading", json!(5.01)), + ("autoLeading", json!(-0.01)), ("autoLeading", json!(f64::NAN)), ("singleWordJustify", json!("leftJustified")), ]; @@ -1183,6 +1190,15 @@ fn justification_dialog_sets_auto_leading_and_single_word_justification() { } assert_eq!(undo_steps(&app), before); + // A typed percent is stored as the whole percent the field shows, so 500.4% (shown as 500%) + // and -0.4% (shown as 0%) are accepted. + for (typed, stored) in [(500.4, 5.0), (-0.4, 0.0), (149.6, 1.5)] { + assert_eq!(auto_leading_typed(typed), stored, "{typed}%"); + let mut probe = d.clone(); + probe.fields.insert("autoLeading".into(), json!(auto_leading_typed(typed))); + assert_eq!(check_justification(&probe, "", ¤t), Ok(()), "{typed}%"); + } + app.ui.dialog = Some(d); app.ui.dialog.as_mut().unwrap().fields.insert("autoLeading".into(), json!(1.5)); draw(&mut app, &ctx);