mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 11:46:51 -06:00
Merge justification-dialog: a Justification dialog for the selected paragraphs (#531)
Conflict: ROADMAP.md: both progress-log entries, #531's above next's 2026-10-11 lines. Conflict: docs/typography-parity.md: status line lists next's changes and the Justification dialog. Conflict: crates/engine/src/cmd/text.rs: selection_attrs keeps next's typing-format chars and adds paraMixed for {mixed: true}; mixed_para_attrs sits beside next's apply_typing_format and link_style. Conflict: crates/ui-egui/src/dialogs.rs: titles, draw and confirm arms for both paragraphLocal and justification; the style dialog's justify arm calls the shared justification(), which uses next's composer_choice (Japanese composers) and selects neither composer when mixed; the style dialog's justification check moved into next's confirm_paragraph_style_options (section set to justify, reason in next's status line); tests from both sides. Conflict: crates/ui-egui/src/i18n/ja.rs: union of rows. Conflict: crates/ui-egui/src/i18n/uk.rs: union of rows. Conflict: crates/ui-egui/src/menus.rs: UI_COMMANDS, run_ui arms and Type menu keep next's Paragraph Borders and Shading, Span Columns and Bullets and Numbering plus Justification… (Cmd+Alt+Shift+J, free on next) right after Paragraph Rules…; menu_enabled keeps next's IME check before the type.para check. Auto-merged crates/ui-egui/src/panels/properties.rs: Justification… joins the Paragraph panel menu list after Paragraph Rules….
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -307,7 +307,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_text_or_frames,
|
||||
break_link
|
||||
),
|
||||
cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection", has_doc, selection_attrs),
|
||||
cmd!(
|
||||
query "type.selectionAttrs",
|
||||
"Selection Attributes",
|
||||
[],
|
||||
None,
|
||||
"{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs",
|
||||
has_doc,
|
||||
selection_attrs
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1446,7 +1454,7 @@ fn open_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"features": list, "figures": otf::figures(&list)}))
|
||||
}
|
||||
|
||||
fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn selection_attrs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let targets = format_targets(s);
|
||||
let Some(Target { story: sid, cell, range: r }) = targets.first().cloned() else { return Ok(Value::Null) };
|
||||
@@ -1459,8 +1467,31 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
apply_typing_format(&mut cf, f);
|
||||
}
|
||||
let cp = st.doc.styles.resolve_char(&base, &cf);
|
||||
Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp,
|
||||
"paraOverrides": pf.para.count(), "charOverrides": cf.over.count()}))
|
||||
let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp,
|
||||
"paraOverrides": pf.para.count(), "charOverrides": cf.over.count()});
|
||||
if bool_or(p, "mixed", false) {
|
||||
out["paraMixed"] = json!(mixed_para_attrs(&st.doc, &targets));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The paragraph attributes whose resolved values differ across the paragraphs of `targets`.
|
||||
fn mixed_para_attrs(doc: &designcraft_doc::Document, targets: &[Target]) -> Vec<String> {
|
||||
let mut first: Option<serde_json::Map<String, Value>> = None;
|
||||
let mut mixed = std::collections::BTreeSet::new();
|
||||
for t in targets {
|
||||
let Some(story) = doc.text_story(t.story, t.cell) else { continue };
|
||||
let a = story.para_at(t.range.start);
|
||||
let b = story.para_at(t.range.end.max(t.range.start));
|
||||
for pf in story.paras.get(a..=b).unwrap_or(&[]) {
|
||||
let Ok(Value::Object(v)) = serde_json::to_value(doc.styles.resolve_para(pf).0) else { continue };
|
||||
match &first {
|
||||
None => first = Some(v),
|
||||
Some(f) => mixed.extend(v.into_iter().filter(|(k, x)| f.get(k) != Some(x)).map(|(k, _)| k)),
|
||||
}
|
||||
}
|
||||
}
|
||||
mixed.into_iter().collect()
|
||||
}
|
||||
|
||||
fn apply_typing_format(cf: &mut designcraft_doc::CharFormat, f: &TypingFormat) {
|
||||
|
||||
+235
-45
@@ -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<Value, String> {
|
||||
}
|
||||
app.run("type.para", json!({"attrs": attrs}))
|
||||
}
|
||||
"justification" => {
|
||||
let current = d.fields.get("current").cloned().unwrap_or_default();
|
||||
if let Err(e) = check_justification(&d, "", ¤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<String, Value> = JUSTIFY_ATTRS.iter().filter_map(|k| d.fields.get(*k).map(|v| (k.to_string(), v.clone()))).collect();
|
||||
if attrs.is_empty() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
app.run("type.para", json!({"attrs": attrs}))
|
||||
}
|
||||
"paragraphRules" => {
|
||||
let attrs = rule_edits(&d, "");
|
||||
if attrs.is_empty() {
|
||||
@@ -2633,6 +2654,14 @@ fn grep_error(p: &str) -> Option<String> {
|
||||
|
||||
fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Result<Value, String> {
|
||||
let name = d.s("name");
|
||||
// What the Justification section showed: the style's own values, or a new style's Based On.
|
||||
let shown = if d.b("new") { d.s("basedOn") } else { name.clone() };
|
||||
let base = app.session.active().map(|st| serde_json::to_value(st.doc.styles.resolve_para_style(&shown).0).unwrap_or_default()).unwrap_or_default();
|
||||
if let Err(e) = check_justification(d, "p.", &base) {
|
||||
// The caller shows the reason in the dialog's status line.
|
||||
d.fields.insert("section".into(), json!("justify"));
|
||||
return Err(e);
|
||||
}
|
||||
let mut para = serde_json::Map::new();
|
||||
let mut chars = serde_json::Map::new();
|
||||
for (k, v) in &d.fields {
|
||||
@@ -3098,51 +3127,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
|
||||
}
|
||||
});
|
||||
}
|
||||
"justify" => {
|
||||
egui::Grid::new("psj").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||||
ui.label("");
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Minimum"));
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Desired"));
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Maximum"));
|
||||
ui.end_row();
|
||||
for (label, base) in [("Word Spacing:", "wordSpace"), ("Letter Spacing:", "letterSpace"), ("Glyph Scaling:", "glyphScale")] {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label));
|
||||
for suffix in ["Min", "Desired", "Max"] {
|
||||
let key = format!("{base}{suffix}");
|
||||
let v = cur(d, &format!("p.{key}"), &pv[key.as_str()]).as_f64().map(|x| x * 100.0);
|
||||
if let Some(n) = ui.scope(|ui| crate::widgets::number(ui, &format!("ps{key}"), v, "%", 60.0, 0)).inner {
|
||||
d.fields.insert(format!("p.{key}"), json!(n / 100.0));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Composer:"));
|
||||
let (single, [paragraph, single_line]) =
|
||||
crate::panels::properties::composer_choice(cur(d, "p.composer", &pv["composer"]).as_str());
|
||||
if ui.selectable_label(!single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Paragraph Composer"))).clicked() {
|
||||
d.fields.insert("p.composer".into(), json!(paragraph));
|
||||
}
|
||||
if ui.selectable_label(single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Single-line Composer"))).clicked() {
|
||||
d.fields.insert("p.composer".into(), json!(single_line));
|
||||
}
|
||||
ui.end_row();
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Insert Kashidas:"));
|
||||
let mut k = cur(d, "p.kashidas", &pv["kashidas"]).as_bool().unwrap_or(true);
|
||||
if ui.checkbox(&mut k, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "In justified Arabic text"))).changed() {
|
||||
d.fields.insert("p.kashidas".into(), json!(k));
|
||||
}
|
||||
ui.end_row();
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Korean Line Breaks:"));
|
||||
let mut kb = cur(d, "p.koreanCharBreaks", &pv["koreanCharBreaks"]).as_bool().unwrap_or(false);
|
||||
if ui
|
||||
.checkbox(&mut kb, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Between syllables (not only at spaces)")))
|
||||
.changed()
|
||||
{
|
||||
d.fields.insert("p.koreanCharBreaks".into(), json!(kb));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
"justify" => justification(&app.ui.language, ui, d, "p.", &pv),
|
||||
"color" => character_color(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc),
|
||||
"advanced" => advanced_character_formats(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }),
|
||||
"openType" => open_type_features(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }),
|
||||
@@ -4617,6 +4602,113 @@ fn shown_rule(d: &Dialog, prefix: &str, which: &str, current: &Value) -> Value {
|
||||
Value::Object(r)
|
||||
}
|
||||
|
||||
/// The paragraph attributes of the Justification controls.
|
||||
pub const JUSTIFY_ATTRS: [&str; 12] = [
|
||||
"wordSpaceMin",
|
||||
"wordSpaceDesired",
|
||||
"wordSpaceMax",
|
||||
"letterSpaceMin",
|
||||
"letterSpaceDesired",
|
||||
"letterSpaceMax",
|
||||
"glyphScaleMin",
|
||||
"glyphScaleDesired",
|
||||
"glyphScaleMax",
|
||||
"composer",
|
||||
"kashidas",
|
||||
"koreanCharBreaks",
|
||||
];
|
||||
|
||||
/// The spacing rows of the Justification controls: (label, attribute stem, allowed percent range).
|
||||
const JUSTIFY_ROWS: [(&str, &str, (f64, f64)); 3] = [
|
||||
("Word Spacing", "wordSpace", (0.0, 1000.0)),
|
||||
("Letter Spacing", "letterSpace", (-100.0, 500.0)),
|
||||
("Glyph Scaling", "glyphScale", (50.0, 200.0)),
|
||||
];
|
||||
|
||||
/// Checks the edited spacing values: each within its range, and minimum ≤ desired ≤ maximum
|
||||
/// (values left mixed are not compared).
|
||||
fn check_justification(d: &Dialog, prefix: &str, current: &Value) -> Result<(), String> {
|
||||
for (label, stem, (lo, hi)) in JUSTIFY_ROWS {
|
||||
let mut edited = false;
|
||||
let mut known = Vec::new();
|
||||
for suffix in ["Min", "Desired", "Max"] {
|
||||
let key = format!("{stem}{suffix}");
|
||||
let v = match d.fields.get(&format!("{prefix}{key}")) {
|
||||
Some(v) => {
|
||||
edited = true;
|
||||
let pct =
|
||||
v.as_f64().map(|x| x * 100.0).filter(|x| x.is_finite()).ok_or_else(|| format!("Justification: {label} is not a number"))?;
|
||||
// The fields show whole percents; compare what was shown.
|
||||
if pct.round() < lo || pct.round() > hi {
|
||||
return Err(format!("Justification: {label} must be between {lo}% and {hi}%"));
|
||||
}
|
||||
Some(pct)
|
||||
}
|
||||
None => current[key.as_str()].as_f64().map(|x| x * 100.0),
|
||||
};
|
||||
known.extend(v);
|
||||
}
|
||||
if edited && known.windows(2).any(|w| w[0] > w[1] + 1e-9) {
|
||||
return Err(format!("Justification: {label} needs Minimum ≤ Desired ≤ Maximum"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Justification controls (the Justification dialog, and the Paragraph Style Options section).
|
||||
/// `current` holds the resolved paragraph attributes, `null` where the selected paragraphs differ
|
||||
/// (the field shows empty); each edit is stored as `{prefix}{attr}`, so OK changes only the fields
|
||||
/// edited.
|
||||
fn justification(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, current: &Value) {
|
||||
let cur = |d: &Dialog, k: &str| d.fields.get(&format!("{prefix}{k}")).cloned().unwrap_or_else(|| current[k].clone());
|
||||
egui::Grid::new("psj").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||||
ui.label("");
|
||||
crate::rtl::label(ui, crate::i18n::tr(lang, "Minimum"));
|
||||
crate::rtl::label(ui, crate::i18n::tr(lang, "Desired"));
|
||||
crate::rtl::label(ui, crate::i18n::tr(lang, "Maximum"));
|
||||
ui.end_row();
|
||||
for (label, base, _) in JUSTIFY_ROWS {
|
||||
crate::rtl::label(ui, crate::i18n::tr(lang, &format!("{label}:")));
|
||||
for suffix in ["Min", "Desired", "Max"] {
|
||||
let key = format!("{base}{suffix}");
|
||||
let v = cur(d, &key).as_f64().map(|x| x * 100.0);
|
||||
if let Some(n) = ui.scope(|ui| crate::widgets::number(ui, &format!("ps{key}"), v, "%", 60.0, 0)).inner {
|
||||
d.fields.insert(format!("{prefix}{key}"), json!(n / 100.0));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
crate::rtl::label(ui, crate::i18n::tr(lang, "Composer:"));
|
||||
let composer = cur(d, "composer");
|
||||
// Mixed (null) selects neither; a Japanese composer keeps its kind.
|
||||
let (single, [paragraph, single_line]) = crate::panels::properties::composer_choice(composer.as_str());
|
||||
let known = !composer.is_null();
|
||||
if ui.selectable_label(known && !single, crate::rtl::widget(ui, crate::i18n::tr(lang, "Paragraph Composer"))).clicked() {
|
||||
d.fields.insert(format!("{prefix}composer"), json!(paragraph));
|
||||
}
|
||||
if ui.selectable_label(known && single, crate::rtl::widget(ui, crate::i18n::tr(lang, "Single-line Composer"))).clicked() {
|
||||
d.fields.insert(format!("{prefix}composer"), json!(single_line));
|
||||
}
|
||||
ui.end_row();
|
||||
for (label, key, text, default) in [
|
||||
("Insert Kashidas:", "kashidas", "In justified Arabic text", true),
|
||||
("Korean Line Breaks:", "koreanCharBreaks", "Between syllables (not only at spaces)", false),
|
||||
] {
|
||||
crate::rtl::label(ui, crate::i18n::tr(lang, label));
|
||||
let v = cur(d, key);
|
||||
let mut on = v.as_bool().unwrap_or(default);
|
||||
if ui.add(egui::Checkbox::new(&mut on, crate::rtl::widget(ui, crate::i18n::tr(lang, text))).indeterminate(v.is_null())).changed() {
|
||||
d.fields.insert(format!("{prefix}{key}"), json!(on));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
if let Some(e) = d.fields.get("justifyError").and_then(Value::as_str) {
|
||||
ui.add_space(6.0);
|
||||
crate::rtl::label(ui, egui::RichText::new(e).color(ui.visuals().error_fg_color));
|
||||
}
|
||||
}
|
||||
|
||||
/// The rule fields edited in the dialog, as `type.para` attrs: `{ruleAbove?: {…}, ruleBelow?: {…}}`.
|
||||
fn rule_edits(d: &Dialog, prefix: &str) -> Map<String, Value> {
|
||||
let mut out = Map::new();
|
||||
@@ -5545,6 +5637,104 @@ mod tests {
|
||||
draw(&mut app, &ctx);
|
||||
}
|
||||
|
||||
/// A frame with two paragraphs of [Basic Paragraph]; the first has a local Word Spacing minimum.
|
||||
fn two_paragraphs() -> (DesignApp, Value) {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let r = app.run("frame.create", json!({"rect": [72, 72, 300, 400], "content": "text", "text": "One\nTwo"})).unwrap();
|
||||
app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
app.run("type.para", json!({"attrs": {"wordSpaceMin": 0.7}})).unwrap();
|
||||
(app, r["story"].clone())
|
||||
}
|
||||
|
||||
/// The resolved paragraph attribute `key` of the paragraph at byte `at`.
|
||||
fn para_attr(app: &mut DesignApp, story: &Value, at: usize, key: &str) -> Value {
|
||||
app.run("text.select", json!({"story": story, "anchor": at, "focus": at})).unwrap();
|
||||
app.run("type.selectionAttrs", json!({})).unwrap()["para"][key].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justification_dialog_applies_to_a_local_paragraph_and_cancel_changes_nothing() {
|
||||
let (mut app, story) = two_paragraphs();
|
||||
let ctx = egui::Context::default();
|
||||
assert!(crate::menus::menu_enabled(&app, "app.justificationDialog"));
|
||||
app.run("app.justificationDialog", json!({})).unwrap();
|
||||
draw(&mut app, &ctx);
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
assert_eq!(d.id, "justification");
|
||||
assert_eq!(d.fields["current"]["wordSpaceMin"], 0.7);
|
||||
assert_eq!(d.fields["current"]["wordSpaceDesired"], 1.0);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("wordSpaceDesired".into(), json!(1.1));
|
||||
draw(&mut app, &ctx);
|
||||
let before = undo_steps(&app);
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(undo_steps(&app), before + 1, "one undo step");
|
||||
assert_eq!(para_attr(&mut app, &story, 0, "wordSpaceDesired"), 1.1);
|
||||
assert_eq!(para_attr(&mut app, &story, 0, "wordSpaceMin"), 0.7, "unedited fields stay");
|
||||
assert_eq!(para_attr(&mut app, &story, 4, "wordSpaceDesired"), 1.0, "only the selected paragraph");
|
||||
let style = app.session.doc().unwrap().doc.styles.resolve_para_style(designcraft_doc::story::BASIC_PARAGRAPH).0.word_space_desired;
|
||||
assert_eq!(style, 1.0, "the paragraph style keeps its value");
|
||||
|
||||
// Cancel (Escape) leaves the paragraph alone.
|
||||
app.run("text.select", json!({"story": story, "anchor": 0, "focus": 0})).unwrap();
|
||||
app.run("app.justificationDialog", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("wordSpaceDesired".into(), json!(1.2));
|
||||
let before = undo_steps(&app);
|
||||
let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
|
||||
events: vec![escape],
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert!(app.ui.dialog.is_none(), "cancelled");
|
||||
assert_eq!(undo_steps(&app), before);
|
||||
assert_eq!(para_attr(&mut app, &story, 0, "wordSpaceDesired"), 1.1);
|
||||
|
||||
app.run("edit.deselectAll", json!({})).unwrap();
|
||||
assert!(!crate::menus::menu_enabled(&app, "app.justificationDialog"), "nothing to justify");
|
||||
assert!(app.run("app.justificationDialog", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justification_dialog_leaves_mixed_fields_alone_and_refuses_bad_values() {
|
||||
let (mut app, story) = two_paragraphs();
|
||||
let ctx = egui::Context::default();
|
||||
app.run("text.select", json!({"story": story, "anchor": 0, "focus": 7})).unwrap();
|
||||
app.run("app.justificationDialog", json!({})).unwrap();
|
||||
draw(&mut app, &ctx);
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
assert_eq!(d.fields["current"]["wordSpaceMin"], Value::Null, "mixed shows empty");
|
||||
assert_eq!(d.fields["current"]["wordSpaceMax"], 1.33);
|
||||
|
||||
// Out of range, and minimum above desired: an error, the dialog stays open, nothing changes.
|
||||
let before = undo_steps(&app);
|
||||
for (key, v) in [("wordSpaceMax", 20.0), ("glyphScaleMin", 0.2), ("letterSpaceDesired", f64::NAN)] {
|
||||
app.ui.dialog = Some(d.clone());
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), json!(v));
|
||||
assert!(confirm(&mut app).is_err(), "{key} = {v}");
|
||||
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.fields.contains_key("justifyError")), "{key}: the dialog shows why");
|
||||
}
|
||||
app.ui.dialog = Some(d.clone());
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("letterSpaceMin".into(), json!(0.1));
|
||||
assert!(confirm(&mut app).is_err(), "letter spacing minimum above desired");
|
||||
draw(&mut app, &ctx);
|
||||
assert_eq!(undo_steps(&app), before);
|
||||
|
||||
app.ui.dialog = Some(d);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("wordSpaceMax".into(), json!(1.5));
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(undo_steps(&app), before + 1);
|
||||
for (at, min) in [(0, 0.7), (4, 0.8)] {
|
||||
assert_eq!(para_attr(&mut app, &story, at, "wordSpaceMax"), 1.5);
|
||||
assert_eq!(para_attr(&mut app, &story, at, "wordSpaceMin"), min, "each paragraph keeps its own minimum");
|
||||
}
|
||||
}
|
||||
|
||||
fn command_app(id: &str, fields: Value) -> DesignApp {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.dialog = Some(Dialog::new(&format!("cmd:{id}"), fields));
|
||||
|
||||
@@ -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", "段", "栏"]),
|
||||
|
||||
@@ -1488,6 +1488,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
),
|
||||
("Utilities", "أدوات مساعدة"),
|
||||
("Paragraph Rules…", "خطوط الفقرة…"),
|
||||
("Justification…", "الضبط…"),
|
||||
("Paragraph Rules", "خطوط الفقرة"),
|
||||
("Rule Below", "خط سفلي"),
|
||||
("Left-aligned tab", "علامة جدولة محاذاة لليسار"),
|
||||
|
||||
@@ -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:"),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -92,6 +92,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
),
|
||||
("app.spanColumnsDialog", "Span Columns…", None, "{} — span or split columns of the selected paragraphs (type.para)"),
|
||||
("app.bulletsNumberingDialog", "Bullets and Numbering…", None, "{} — bullets or numbers of the selected paragraphs (type.para)"),
|
||||
(
|
||||
"app.justificationDialog",
|
||||
"Justification…",
|
||||
Some("Cmd+Alt+Shift+J"),
|
||||
"{} — word and letter spacing, glyph scaling and composer of the selected paragraphs (type.para)",
|
||||
),
|
||||
("app.findFontDialog", "Find/Replace Font…", None, "{} — fonts used (missing ones flagged) and replacing them"),
|
||||
("app.insertXrefDialog", "Insert Cross-Reference…", None, "{} — New Cross-Reference dialog (paragraph or text anchor, format)"),
|
||||
("app.deleteAllGuides", "Delete All Guides on Spread", None, "{} — the spread in view"),
|
||||
@@ -427,6 +433,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"cmd:type.alignRight",
|
||||
"cmd:type.justify",
|
||||
"ui:app.paragraphRulesDialog",
|
||||
"ui:app.justificationDialog",
|
||||
"ui:app.paragraphBordersDialog",
|
||||
"ui:app.spanColumnsDialog",
|
||||
"ui:app.bulletsNumberingDialog",
|
||||
@@ -1027,6 +1034,18 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphLocal", fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.justificationDialog" => {
|
||||
let Some(a) = app.session.execute("type.selectionAttrs", &json!({"mixed": true})).ok().filter(|a| !a.is_null()) else {
|
||||
return Some(Err("select text or a text frame".into()));
|
||||
};
|
||||
let mixed: Vec<&str> = a["paraMixed"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
let current: serde_json::Map<String, Value> = crate::dialogs::JUSTIFY_ATTRS
|
||||
.iter()
|
||||
.map(|k| (k.to_string(), if mixed.contains(k) { Value::Null } else { a["para"][*k].clone() }))
|
||||
.collect();
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("justification", json!({"current": current})));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.footnoteOptionsDialog" => {
|
||||
let Some(st) = app.session.active() else { return Some(Err("no document open".into())) };
|
||||
let o = serde_json::to_value(&st.doc.footnote_options).unwrap_or_default();
|
||||
@@ -2023,6 +2042,9 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
|
||||
if waits_for_ime(app, id) {
|
||||
return false;
|
||||
}
|
||||
if id == "app.justificationDialog" {
|
||||
return enabled(app, "type.para");
|
||||
}
|
||||
if ui_label(id).is_some() {
|
||||
return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help.");
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user