Merge pull request #2659 from dotCooCoo/feat/justification-auto-leading-single-word

Justification: the dialog sets Auto Leading and Single Word Justification
This commit is contained in:
FredApps authored and GitHub committed 2026-11-12 17:58:12 +01:00
commit b7321d31b8
16 files changed
+219 -3

No files matched your search

+59 -2
View File
@@ -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,35 @@ 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 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")];
/// 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 +7944,35 @@ 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!(auto_leading_typed(n)));
}
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() {
// 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));
}
}
}
})
.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.
+123
View File
@@ -1130,6 +1130,129 @@ 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::<bool>(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());
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);
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.01)),
("autoLeading", json!(-0.01)),
("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);
// 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, "", &current), Ok(()), "{typed}%");
}
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));
+3
View File
@@ -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:", "الإزاحة:"),
+3
View File
@@ -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í."),
+2
View File
@@ -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."),
+3
View File
@@ -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"),
+3
View File
@@ -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."),
+2
View File
@@ -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.", "選択したテキストの上にルビを付けます。空欄にすると削除します。"),
+2
View File
@@ -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.", "დაისმება მონიშნული ტექსტის თავზე; ცარიელი წაშლის მას."),
+3
View File
@@ -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."),
+2
View File
@@ -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"),
+3
View File
@@ -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.", "Размещается над выделенным текстом; пустое значение удаляет подпись."),
+2
View File
@@ -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:"),
+5
View File
@@ -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:", "旁注:"]),
(
+3
View File
@@ -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.", "Розміщується над виділеним текстом; порожнє значення вилучає підпис."),
+1 -1
View File
@@ -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 |