diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 0f81cfaf5..94b3ec2e6 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -31,7 +31,8 @@ pub use painter::Painted; pub(crate) use painter::apply_painted; use painter::{font_add, font_rescan, painter_apply, painter_pick, state}; use paragraph::{ - align, align_to, anchor, autofit, bullets, columns, direction, level, line_spacing, margins, numbering, paragraph, paragraph_dialog, rtl, wrap, + align, align_to, anchor, autofit, bullets, columns, direction, east_asian_line_break, hanging_punctuation, latin_line_break, level, line_spacing, + margins, numbering, paragraph, paragraph_dialog, rtl, wrap, }; use ruler::{ruler, tabs}; use text_effects::{case, text_outline, text_shadow, text_warp}; @@ -161,6 +162,33 @@ pub fn specs() -> Vec { has_text_or_shapes, rtl ), + cmd!( + "format.eastAsianLineBreak", + "East Asian Line Breaking", + [], + None, + "{on?: bool} (禁則処理: PowerPoint's `eaLnBrk`)", + has_text_or_shapes, + east_asian_line_break + ), + cmd!( + "format.hangingPunctuation", + "Hang Punctuation", + [], + None, + "{on?: bool} (句読点のぶら下げ: PowerPoint's `hangingPunct`)", + has_text_or_shapes, + hanging_punctuation + ), + cmd!( + "format.latinLineBreak", + "Allow Latin Text to Wrap Mid-word", + [], + None, + "{on?: bool} (英単語の途中で改行を許可: PowerPoint's `latinLnBrk`)", + has_text_or_shapes, + latin_line_break + ), cmd!("format.alignLeft", "Align Left", ["Home", "Paragraph"], Some("Cmd+L"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Left)), cmd!("format.alignCenter", "Center", ["Home", "Paragraph"], Some("Cmd+E"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Center)), cmd!("format.alignRight", "Align Right", ["Home", "Paragraph"], Some("Cmd+R"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Right)), @@ -209,7 +237,7 @@ pub fn specs() -> Vec { "Paragraph…", ["Home", "Paragraph"], None, - "{align?: left|center|right|justify|justLow|distributed, indentLeft?: pt, indentRight?: pt, special?: none|first|hanging, specialBy?: pt, indentFirst?: pt, spaceBefore?: pt, spaceAfter?: pt, spaceBeforeKind?: pts|pct, spaceAfterKind?: pts|pct, lineSpacing?: lines, lineSpacingPt?: pt} — with none of these, opens the Paragraph dialog", + "{align?: left|center|right|justify|justLow|distributed, indentLeft?: pt, indentRight?: pt, special?: none|first|hanging, specialBy?: pt, indentFirst?: pt, spaceBefore?: pt, spaceAfter?: pt, spaceBeforeKind?: pts|pct, spaceAfterKind?: pts|pct, lineSpacing?: lines, lineSpacingPt?: pt, eastAsianLineBreak?: bool, hangingPunctuation?: bool, latinLineBreak?: bool} — with none of these, opens the Paragraph dialog", has_text_or_shapes, paragraph_dialog ), @@ -592,4 +620,119 @@ mod tests { assert!(s.execute("format.tabs", &json!({"set": "nope"})).is_err()); assert_eq!(s.doc().unwrap().history.undo.len(), n); } + + /// The three Asian typography paragraph properties as `format.*` sets them. + const ASIAN_PARA_COMMANDS: [(&str, fn(&ParaProps) -> Option); 3] = [ + ("format.eastAsianLineBreak", |p| p.east_asian_line_break), + ("format.hangingPunctuation", |p| p.hanging_punct), + ("format.latinLineBreak", |p| p.latin_line_break), + ]; + + #[test] + fn asian_paragraph_commands_set_clear_and_toggle_the_selection() { + for (cmd, field) in ASIAN_PARA_COMMANDS { + let mut s = shape(); + assert_eq!(field(¶(&s)), None, "{cmd} starts unset"); + // Absent `eaLnBrk` means on, so its first bare toggle turns the rule off; the other + // two start off and their first toggle turns them on. + s.execute(cmd, &json!({})).unwrap(); + let first = if cmd == "format.eastAsianLineBreak" { Some(false) } else { Some(true) }; + assert_eq!(field(¶(&s)), first, "{cmd} toggles from the effective default"); + let mut s = shape(); + s.execute(cmd, &json!({"on": true})).unwrap(); + assert_eq!(field(¶(&s)), Some(true), "{cmd} on"); + s.execute(cmd, &json!({"on": false})).unwrap(); + assert_eq!(field(¶(&s)), Some(false), "{cmd} off"); + // No `on`: toggle the value the caret's paragraph shows. A hostile `on` is ignored + // and toggles too, the way every other toggle command behaves. + for hostile in [json!({}), json!({"on": "yes"})] { + s.execute(cmd, &hostile).unwrap(); + assert_eq!(field(¶(&s)), Some(true), "{cmd} toggled on"); + s.execute(cmd, &hostile).unwrap(); + assert_eq!(field(¶(&s)), Some(false), "{cmd} toggled off"); + } + // Undo takes the command back to no explicit value. + let mut s = shape(); + s.execute(cmd, &json!({"on": true})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(field(¶(&s)), None, "undo clears what {cmd} set"); + // The command touched only its own property. + let mut s = shape(); + s.execute(cmd, &json!({"on": true})).unwrap(); + let p = para(&s); + assert_eq!(field(&p), Some(true)); + let set = [p.east_asian_line_break, p.hanging_punct, p.latin_line_break].iter().filter(|v| v.is_some()).count(); + assert_eq!(set, 1, "{cmd} left the other two properties alone"); + } + } + + #[test] + fn asian_paragraph_commands_reach_every_selected_paragraph() { + let mut s = Session::with_new(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 240.0, 80.0], "text": "一つ目\n二つ目"})).unwrap(); + let id = s.doc().unwrap().selected_shapes().first().map(|sh| sh.id.0).unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + s.execute("text.selectAll", &json!({})).unwrap(); + s.execute("format.hangingPunctuation", &json!({"on": true})).unwrap(); + let props: Vec> = s + .doc() + .unwrap() + .shape(deckcraft_model::ShapeId(id)) + .and_then(|sh| sh.text.as_ref()) + .map(|t| t.paragraphs.iter().map(|p| p.props.hanging_punct).collect()) + .unwrap_or_default(); + assert_eq!(props, vec![Some(true), Some(true)], "both selected paragraphs"); + } + + #[test] + fn asian_paragraph_options_survive_a_save_and_reopen() { + let mut s = shape(); + for (cmd, _) in ASIAN_PARA_COMMANDS { + s.execute(cmd, &json!({"on": true})).unwrap(); + } + let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); + let reopened = deckcraft_pptx::import(&crate::cmd::base64_decode(&saved).unwrap()).unwrap(); + let p = reopened.slides[0] + .shapes + .iter() + .filter_map(|sh| sh.text.as_ref()) + .flat_map(|t| t.paragraphs.iter()) + .find(|p| p.text() == "Hello") + .map(|p| p.props.clone()) + .unwrap(); + assert_eq!(p.east_asian_line_break, Some(true)); + assert_eq!(p.hanging_punct, Some(true)); + assert_eq!(p.latin_line_break, Some(true)); + } + + #[test] + fn paragraph_dialog_sets_asian_options_in_the_same_undo() { + let mut s = shape(); + let before = s.doc().unwrap().history.undo.len(); + s.execute("format.paragraphDialog", &json!({"align": "justify", "hangingPunctuation": true, "latinLineBreak": true})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Paragraph…"); + let p = para(&s); + assert_eq!(p.align, Some(Align::Justify)); + assert_eq!(p.hanging_punct, Some(true)); + assert_eq!(p.latin_line_break, Some(true)); + assert_eq!(p.east_asian_line_break, None, "an absent key leaves its property alone"); + s.execute("edit.undo", &json!({})).unwrap(); + let p = para(&s); + assert_eq!(p.hanging_punct, None); + assert_eq!(p.latin_line_break, None); + assert_eq!(p.align, Some(Align::Center), "undo restores the alignment shape.insert wrote"); + } + + #[test] + fn paragraph_dialog_rejects_a_hostile_asian_option_without_editing() { + let mut s = shape(); + let n = s.doc().unwrap().history.undo.len(); + assert!(s.execute("format.paragraphDialog", &json!({"hangingPunctuation": 1})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"eastAsianLineBreak": "on"})).is_err()); + assert!(s.execute("format.paragraphDialog", &json!({"latinLineBreak": null})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + let p = para(&s); + assert!(p.east_asian_line_break.is_none() && p.hanging_punct.is_none() && p.latin_line_break.is_none()); + } } diff --git a/crates/engine/src/cmd/format/paragraph.rs b/crates/engine/src/cmd/format/paragraph.rs index dd88f4055..9dc596344 100644 --- a/crates/engine/src/cmd/format/paragraph.rs +++ b/crates/engine/src/cmd/format/paragraph.rs @@ -148,8 +148,21 @@ pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result { }) } -const PARA_DIALOG_KEYS: &[&str] = - &["align", "indentLeft", "indentRight", "indentFirst", "special", "specialBy", "spaceBefore", "spaceAfter", "lineSpacing", "lineSpacingPt"]; +const PARA_DIALOG_KEYS: &[&str] = &[ + "align", + "indentLeft", + "indentRight", + "indentFirst", + "special", + "specialBy", + "spaceBefore", + "spaceAfter", + "lineSpacing", + "lineSpacingPt", + "eastAsianLineBreak", + "hangingPunctuation", + "latinLineBreak", +]; fn para_dialog_has_fields(p: &Value) -> bool { PARA_DIALOG_KEYS.iter().any(|k| p.get(*k).is_some()) @@ -165,6 +178,14 @@ pub(super) fn finite_key(p: &Value, cmd: &str, key: &str) -> Result> } } +fn bool_key(p: &Value, cmd: &str, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(bad(cmd, format!("`{key}` must be true or false"))), + } +} + pub(super) fn clamp_pt(v: f64) -> f64 { v.clamp(0.0, 1584.0) } @@ -230,6 +251,11 @@ fn paragraph_dialog_params(s: &Session) -> Value { "spaceAfterKind": after_kind, "lineKind": line_kind, "lineValue": line_value, + // The layout treats an absent `eaLnBrk` as on (PowerPoint's default), so the checkbox + // shows the effective value; a box that opened unchecked would write Some(false) on OK. + "eastAsianLineBreak": pp.east_asian_line_break.unwrap_or(true), + "hangingPunctuation": pp.hanging_punct.unwrap_or(false), + "latinLineBreak": pp.latin_line_break.unwrap_or(false), "tabs": tabs, "defaultTab": pp.default_tab.unwrap_or(72.0), }) @@ -277,6 +303,9 @@ pub(super) fn paragraph_dialog(s: &mut Session, p: &Value) -> Result { } let line_mult = finite_key(p, CMD, "lineSpacing")?.map(|v| v.clamp(0.0, 9.99)); let line_pt = finite_key(p, CMD, "lineSpacingPt")?.map(clamp_pt); + let east_asian_break = bool_key(p, CMD, "eastAsianLineBreak")?; + let hanging = bool_key(p, CMD, "hangingPunctuation")?; + let latin_break = bool_key(p, CMD, "latinLineBreak")?; format_para(s, &move |para| { if align.is_some() { para.props.align = align; @@ -302,9 +331,38 @@ pub(super) fn paragraph_dialog(s: &mut Session, p: &Value) -> Result { if let Some(v) = line_pt { para.props.line_spacing = Some(Spacing::Pts(v)); } + if let Some(v) = east_asian_break { + para.props.east_asian_line_break = Some(v); + } + if let Some(v) = hanging { + para.props.hanging_punct = Some(v); + } + if let Some(v) = latin_break { + para.props.latin_line_break = Some(v); + } }) } +/// 禁則処理 (`eaLnBrk`): East Asian line breaking rules (small kana, the prolonged sound mark and +/// iteration marks never start a line). +pub(super) fn east_asian_line_break(s: &mut Session, p: &Value) -> Result { + // Absent means on in the layout, so the first bare toggle turns it off. + let on = on_param(p, current_para(s).east_asian_line_break.or(Some(true))); + format_para(s, &move |para| para.props.east_asian_line_break = Some(on)) +} + +/// 句読点のぶら下げ (`hangingPunct`): closing East Asian punctuation may hang past the end of a line. +pub(super) fn hanging_punctuation(s: &mut Session, p: &Value) -> Result { + let on = on_param(p, current_para(s).hanging_punct); + format_para(s, &move |para| para.props.hanging_punct = Some(on)) +} + +/// 英単語の途中で改行を許可 (`latinLnBrk`): Latin words may break inside, after `/` or `\`. +pub(super) fn latin_line_break(s: &mut Session, p: &Value) -> Result { + let on = on_param(p, current_para(s).latin_line_break); + format_para(s, &move |para| para.props.latin_line_break = Some(on)) +} + pub(super) fn tab_align(s: &str) -> Option<&'static str> { Some(match s { "l" | "left" => "l", diff --git a/crates/text/src/breaking.rs b/crates/text/src/breaking.rs index 36b9e5925..bcacd0a52 100644 --- a/crates/text/src/breaking.rs +++ b/crates/text/src/breaking.rs @@ -83,9 +83,11 @@ fn is_cj_nonstarter(c: char) -> bool { } /// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then -/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and -/// Korean breaks only between words. -pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec, Vec) { +/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, Korean +/// breaks only between words, and without `eaLnBrk` (East Asian line breaking, the default) the +/// strict Japanese rule that keeps a small kana or the prolonged sound mark off the start of a +/// line is relaxed. +pub(crate) fn break_opportunities(text: &str, latin_break: bool, east_asian_break: bool) -> (Vec, Vec) { let n = text.chars().count(); let mut allowed = vec![false; n + 1]; let mut mandatory = vec![false; n + 1]; @@ -107,20 +109,57 @@ pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec, } } let chars: Vec = text.chars().collect(); + // Relaxing 禁則 (`eaLnBrk="0"`) lifts only the Unicode rule that keeps a non-starter off the + // start of a line (LB21, and LB16 before a non-starter), never a rule that binds the position + // for another reason: an opening bracket still ends no line (LB14), and U+2060 or a no-break + // space still holds (LB11, LB12). Rerun the algorithm on a copy where each non-starter is an + // ordinary kana — one char for one char, so char indices map back — and accept a break before + // a non-starter only where that relaxed run allows it. + let mut relaxed_allowed = vec![false; n + 1]; + if !east_asian_break && chars.iter().any(|c| is_cj_nonstarter(*c)) { + let relaxed: String = chars.iter().map(|c| if is_cj_nonstarter(*c) { 'あ' } else { *c }).collect(); + let mut char_of_byte = std::collections::HashMap::new(); + for (ci, (b, _)) in relaxed.char_indices().enumerate() { + char_of_byte.insert(b, ci); + } + char_of_byte.insert(relaxed.len(), n); + for (b, _) in unicode_linebreak::linebreaks(&relaxed) { + if let Some(&ci) = char_of_byte.get(&b) + && let Some(a) = relaxed_allowed.get_mut(ci) + { + *a = true; + } + } + } for (i, w) in chars.windows(2).enumerate() { let (prev, next) = (w[0], w[1]); // PowerPoint never breaks after a zero-width space, nor before an em dash (only after it). - let keep = if (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}' || is_cj_nonstarter(next) { - false - } else { - latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace() - }; + let other_prohibition = (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}'; + let latin_allowed = latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace(); + let keep = if other_prohibition || (east_asian_break && is_cj_nonstarter(next)) { false } else { latin_allowed }; + let mandatory_here = mandatory.get(i + 1).copied().unwrap_or(false); if !keep - && !mandatory.get(i + 1).copied().unwrap_or(false) + && !mandatory_here && let Some(a) = allowed.get_mut(i + 1) { *a = false; } + // With East Asian line breaking off (`eaLnBrk="0"`, 禁則処理なし) a line may start with a + // small kana or the prolonged sound mark. Unicode offers no break before them at all, so + // relaxing 禁則 means adding the opportunity, not keeping one — but only where no *other* + // rule forbids the break (after a zero-width space, before an em dash, or inside a Latin + // word without `latinLnBrk`) and where the relaxed run above still allows it (an opening + // bracket, U+2060 or a no-break space keeps its Unicode prohibition). + if !east_asian_break + && is_cj_nonstarter(next) + && !other_prohibition + && latin_allowed + && !mandatory_here + && relaxed_allowed.get(i + 1).copied().unwrap_or(false) + && let Some(a) = allowed.get_mut(i + 1) + { + *a = true; + } } (allowed, mandatory) } @@ -146,7 +185,12 @@ pub(crate) struct BreakParams<'a> { pub(crate) fn break_lines(p: &ParaShaped, params: &BreakParams) -> Vec { let BreakParams { first_w, rest_w, wrap, tab_stops, default_tab, x0_first, x0_rest } = *params; let text: String = p.cells.iter().map(|c| c.ch).collect(); - let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false)); + let (allowed, _) = break_opportunities( + &text, + p.props.latin_line_break.unwrap_or(false), + // `eaLnBrk` absent means on (PowerPoint's default): only an explicit off relaxes 禁則. + p.props.east_asian_line_break.unwrap_or(true), + ); let hang = p.props.hanging_punct.unwrap_or(false); let n = p.cells.len(); let mut lines = vec![]; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f9f3f3101..cb28f2989 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1454,9 +1454,9 @@ fn underlines_sit_at_the_font_underline_in_their_style() { #[test] fn no_break_after_a_zero_width_space_or_before_an_em_dash() { - let (allowed, _) = break_opportunities("ab\u{200B}cd", false); + let (allowed, _) = break_opportunities("ab\u{200B}cd", false, true); assert!(!allowed[3], "PowerPoint does not break after a zero-width space"); - let (allowed, _) = break_opportunities("ab\u{2014}cd", false); + let (allowed, _) = break_opportunities("ab\u{2014}cd", false, true); assert!(!allowed[2] && allowed[3], "an em dash ends a line, never starts one"); } @@ -1808,6 +1808,63 @@ fn empty_plain_paragraphs_and_soft_breaks_have_no_bullet_preview() { assert!(l.empty_bullets.is_empty(), "a soft line break is not a new list item"); } +#[test] +fn east_asian_line_breaking_off_relaxes_the_non_starter_rule() { + // Unicode offers no break before a small kana or the prolonged sound mark at all; the strict + // Japanese rule (`eaLnBrk` on, PowerPoint's default) keeps it that way, so relaxing it means + // adding the opportunity — 禁則処理なし lets a line start with one. + for c in ['っ', 'ゃ', 'ー', 'ッ', 'ャ', 'ヶ', 'ァ', 'ー'] { + let text = format!("漢{c}漢"); + let (on, _) = break_opportunities(&text, false, true); + let (off, _) = break_opportunities(&text, false, false); + assert!(!on[1], "eaLnBrk on: no line starts at {c:?}"); + assert!(off[1], "eaLnBrk off: a line may start at {c:?}"); + } + // The other break rules are untouched by the setting. + let (allowed, _) = break_opportunities("ab\u{200B}cd", false, false); + assert!(!allowed[3], "no break after a zero-width space either way"); + let (allowed, _) = break_opportunities("ab\u{2014}cd", false, false); + assert!(!allowed[2] && allowed[3], "an em dash ends a line, never starts one"); + // A non-starter right after a zero-width space keeps that prohibition, and one inside a + // Latin word still needs `latinLnBrk`: relaxing 禁則 lifts only the non-starter rule. + let (allowed, _) = break_opportunities("漢\u{200B}っ漢", false, false); + assert!(!allowed[2], "no break after a zero-width space, even before a non-starter"); + let (allowed, _) = break_opportunities("漢/っ漢", false, false); + assert!(!allowed[2], "no break after a slash without latinLnBrk, even before a non-starter"); + let (allowed, _) = break_opportunities("漢/っ漢", true, false); + assert!(allowed[2], "latinLnBrk lets the break stand before a non-starter too"); +} + +#[test] +fn east_asian_line_breaking_off_keeps_the_unicode_prohibitions_that_are_not_the_non_starter_rule() { + // Relaxing 禁則 must lift only the rule that keeps a non-starter off the start of a line + // (LB21, and LB16 before a non-starter). The same position can be prohibited for another + // reason — an opening bracket ends no line (LB14), a word joiner binds (LB11), a no-break + // space binds (LB12) — and those prohibitions still hold with `eaLnBrk` off. + for text in ["漢「ッ」", "漢(ッ)", "漢\u{2060}ッ", "漢\u{00A0}ッ", "漢\u{202F}ッ"] { + let (on, _) = break_opportunities(text, false, true); + let (off, _) = break_opportunities(text, false, false); + assert!(!on[2] && !off[2], "the break before the non-starter stays prohibited: {text:?}"); + } + // With nothing else binding the position, the same non-starter may start a line when 禁則 is + // off: the setting has not stopped working. + let (allowed, _) = break_opportunities("漢ッ", false, false); + assert!(allowed[1], "with 禁則 off a line may start at a small kana"); +} + +#[test] +fn east_asian_line_breaking_keeps_the_default_layout() { + // The default (`eaLnBrk` absent) wraps exactly as before: a non-starter stays on the line it + // belongs to and never starts the next one. + let text = "漢っ漢ゃ漢ー漢"; + let w = one_line("漢っ") + 0.5; + let lines = wrapped(text, w, |_| {}); + assert!(lines.len() >= 2, "{lines:?}"); + for line in &lines { + assert!(!line.starts_with(['っ', 'ゃ', 'ー']), "no line starts with a non-starter: {lines:?}"); + } +} + /// A run of `text` at `size` points. fn sized_run(text: &str, size: f64) -> Run { let mut r = Run::new(text); diff --git a/crates/ui-egui/src/dialogs/paragraph.rs b/crates/ui-egui/src/dialogs/paragraph.rs index 217076c35..1145af587 100644 --- a/crates/ui-egui/src/dialogs/paragraph.rs +++ b/crates/ui-egui/src/dialogs/paragraph.rs @@ -12,6 +12,9 @@ fn seed_paragraph_fields(d: &mut Dialog) { seed_once(d, "spaceAfterSeed", pt_text(d.params.get("spaceAfter"), 0.0)); seed_once(d, "lineKind", json_text(d.params.get("lineKind"), "multiple")); seed_once(d, "lineValue", pt_text(d.params.get("lineValue"), 1.0)); + seed_once(d, "eastAsianLineBreak", json_text(d.params.get("eastAsianLineBreak"), "0")); + seed_once(d, "hangingPunctuation", json_text(d.params.get("hangingPunctuation"), "0")); + seed_once(d, "latinLineBreak", json_text(d.params.get("latinLineBreak"), "0")); } fn paragraph_return_params(d: &mut Dialog) -> Value { @@ -35,6 +38,9 @@ fn paragraph_return_params(d: &mut Dialog) -> Value { "spaceAfterKind": if after_same { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, "lineKind": d.get("lineKind", "multiple"), "lineValue": line_value, + "eastAsianLineBreak": d.get("eastAsianLineBreak", "0") == "1", + "hangingPunctuation": d.get("hangingPunctuation", "0") == "1", + "latinLineBreak": d.get("latinLineBreak", "0") == "1", "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), }) @@ -131,6 +137,10 @@ pub(super) fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) d.fields.insert("lineValue".into(), "1".into()); } } + ui.label(egui::RichText::new("Asian Typography").strong()); + let _ = check(ui, d, "East Asian Line Breaking", "eastAsianLineBreak", false); + let _ = check(ui, d, "Hang Punctuation", "hangingPunctuation", false); + let _ = check(ui, d, "Allow Latin Text to Wrap Mid-word", "latinLineBreak", false); let mut open_tabs = false; ui.horizontal(|ui| { if ui.button("Tabs…").clicked() { @@ -184,6 +194,9 @@ pub(super) fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) "spaceAfter": space_after, "spaceBeforeKind": if d.get("spaceBefore", "") == d.get("spaceBeforeSeed", "") { d.params.get("spaceBeforeKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, "spaceAfterKind": if d.get("spaceAfter", "") == d.get("spaceAfterSeed", "") { d.params.get("spaceAfterKind").and_then(Value::as_str).unwrap_or("pts") } else { "pts" }, + "eastAsianLineBreak": d.get("eastAsianLineBreak", "0") == "1", + "hangingPunctuation": d.get("hangingPunctuation", "0") == "1", + "latinLineBreak": d.get("latinLineBreak", "0") == "1", }); if d.get("lineKind", "multiple") == "exactly" { params["lineSpacingPt"] = json!(line_value); diff --git a/crates/ui-egui/src/dialogs/paragraph_dialog_tests.rs b/crates/ui-egui/src/dialogs/paragraph_dialog_tests.rs index 029773075..1cec2d9fd 100644 --- a/crates/ui-egui/src/dialogs/paragraph_dialog_tests.rs +++ b/crates/ui-egui/src/dialogs/paragraph_dialog_tests.rs @@ -46,6 +46,19 @@ fn tabs_of(h: &Harness<'_, SlideApp>) -> Vec<(f64, String)> { .collect() } +fn para_props(h: &Harness<'_, SlideApp>) -> deckcraft_model::text::ParaProps { + h.state() + .session + .active() + .unwrap() + .selected_shapes() + .first() + .and_then(|sh| sh.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .map(|p| p.props.clone()) + .unwrap() +} + #[test] fn paragraph_button_aligns_text_in_one_undo() { let mut h = harness(); @@ -95,3 +108,57 @@ fn tabs_from_paragraph_applies_a_stop_and_returns() { assert!(h.state().dialog.is_none()); assert_eq!(align_of(&h).as_deref(), Some("ctr"), "cancelling Paragraph leaves the alignment shape.insert wrote"); } + +#[test] +fn paragraph_dialog_sets_asian_typography_in_one_undo() { + let mut h = harness(); + assert_eq!(para_props(&h).hanging_punct, None); + let before = h.state().session.active().unwrap().history.undo.len(); + h.get_by_label("Paragraph…").click(); + h.run_steps(2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("paragraph")); + assert!(h.query_all_by_label("Asian Typography").next().is_some(), "the Asian Typography section is shown"); + h.get_by_label("Hang Punctuation").click_accesskit(); + h.run_steps(1); + h.get_by_label("Allow Latin Text to Wrap Mid-word").click_accesskit(); + h.run_steps(1); + h.get_by_label("OK").click_accesskit(); + h.run_steps(1); + assert!(h.state().dialog.is_none(), "status {:?}", h.state().status); + let p = para_props(&h); + assert_eq!(p.hanging_punct, Some(true)); + assert_eq!(p.latin_line_break, Some(true)); + // The box opens checked: an absent `eaLnBrk` means on in the layout, so OK writes the + // effective value rather than turning Japanese wrapping off as a side effect. + assert_eq!(p.east_asian_line_break, Some(true), "the effective default is written"); + let undo = &h.state().session.active().unwrap().history.undo; + assert_eq!(undo.len(), before + 1, "the whole dialog is one undo step"); + assert_eq!(undo.last().unwrap().label, "Paragraph…"); +} + +#[test] +fn cancelling_the_paragraph_dialog_leaves_asian_typography_alone() { + let mut h = harness(); + let before = h.state().session.active().unwrap().history.undo.len(); + h.get_by_label("Paragraph…").click(); + h.run_steps(2); + { + // The East Asian box opens checked: an absent `eaLnBrk` means on in the layout. + let fields = &h.state().dialog.as_ref().unwrap().fields; + assert_eq!(fields.get("eastAsianLineBreak").map(String::as_str), Some("1"), "the box opens at its effective value"); + } + h.get_by_label("East Asian Line Breaking").click_accesskit(); + h.run_steps(1); + h.get_by_label("Hang Punctuation").click_accesskit(); + h.run_steps(1); + let fields = &h.state().dialog.as_ref().unwrap().fields; + assert_eq!(fields.get("eastAsianLineBreak").map(String::as_str), Some("0"), "the click reached the box"); + assert_eq!(fields.get("hangingPunctuation").map(String::as_str), Some("1")); + h.get_by_label("Cancel").click_accesskit(); + h.run_steps(1); + assert!(h.state().dialog.is_none()); + let p = para_props(&h); + assert_eq!(p.east_asian_line_break, None); + assert_eq!(p.hanging_punct, None); + assert_eq!(h.state().session.active().unwrap().history.undo.len(), before, "nothing was applied"); +} diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 5fb39d1e6..7f6526fff 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** minor (opt-in native LXGW WenKai Lite fallback and interface CJK tests; local implementation only, managed validation pending; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** minor (opt-in native LXGW WenKai Lite fallback and interface CJK tests; local implementation only, managed validation pending; previous: minor, Japanese paragraph options 禁則処理, 句読点のぶら下げ and 英単語の途中で改行を許可 are settable from the Paragraph dialog and by command; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft's interface is English only today: strings are literals in the UI and engine code, with no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34 @@ -32,7 +32,7 @@ support partly there), **55–90 h** to parity plus native-speaker review. | French | fr | 0 (0%) | no | full (Latin) | no | none | yes (fr, fr-CA) | 3–5 | | Portuguese | pt | 0 (0%) | no | full (Latin) | no | none | yes (pt-BR, pt-PT) | 3–5 | | Indonesian | id | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 | -| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; IME composition (#101); vertical text is rotated, not laid out vertically | no | none | yes | 4–7 | +| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; IME composition (#101); 禁則処理, 句読点のぶら下げ and 英単語の途中で改行を許可 are settable (`format.eastAsianLineBreak`, `format.hangingPunctuation`, `format.latinLineBreak`, and the Paragraph dialog) and round-trip as `eaLnBrk`, `hangingPunct` and `latinLnBrk` on `a:pPr`; 禁則 line breaking follows `eaLnBrk` (off adds the break before a small kana or the prolonged sound mark, as Word's 禁則処理なし does), while closing brackets and closing punctuation still follow the Unicode rule; vertical text is rotated, not laid out vertically | no | none | yes | 4–7 | | German | de | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 | | Korean | ko | 0 (0%) | no | system Hangul fonts, in the interface too; IME composition (#101) | no | none | yes | 4–7 | | Vietnamese | vi | 0 (0%) | no | stacked diacritics through HarfRust | no | none | **no** (Windows only) | 3–5 | @@ -62,6 +62,7 @@ of every text in the file and the default for new text (`format.languageAll`). |---|---|---| | 2026-10-24 | minor | Local 0.37 candidate adds an opt-in native LXGW WenKai Lite glyph fallback and CJK interface-test coverage. This records local implementation only: managed tests, formatting, clippy, wasm-size and native GUI qualification remain pending. Browser font budget and uniform Chinese typography are unchanged. | | 2026-10-21 | minor | Review › Language can set the proofing language of the whole presentation | +| 2026-10-20 | minor | Japanese paragraph options are settable, not only read: 禁則処理, 句読点のぶら下げ and 英単語の途中で改行を許可 in the Paragraph dialog and through `format.eastAsianLineBreak`, `format.hangingPunctuation` and `format.latinLineBreak`; `eaLnBrk` off now relaxes 禁則 in the line breaker (a line may start with a small kana or the prolonged sound mark) | | 2026-10-14 | minor | IME composition in a table cell is underlined in place; the candidate window sits on that cell's caret | | 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) | | 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1e2ef52f4..db71a1920 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -4,6 +4,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (dragging several selected slides moves them all; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (group fill and outline edits reach members, #1079; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** minor (Paragraph dialog: Asian Typography — East Asian line breaking, hanging punctuation and mid-word Latin wrapping; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs, direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in @@ -74,7 +75,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Comments pane (threads) | yes | yes: every slide, with Previous and Next on the Review tab | | Animation Pane | list + advanced timeline | list, reorder, timing, an advanced timeline with draggable bars, play the current slide on the canvas, and Play From starts at the selected effect | | Design Ideas (Designer) | yes (cloud) | no | -| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Photo Album, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 42 (`crates/ui-egui/src/dialogs.rs`): Font, Paragraph, Tabs, Columns (number and spacing; Home › Columns › More Columns… and Format Shape › Text Box › Columns…), Bullets and Numbering, Find and Replace, Replace Fonts, Table, Slide Size, Size and Position, Header and Footer, Date and Time, Hyperlink (address, slide and ScreenTip follow the selection), Action Settings, Set Up Show, Zoom (percent or Fit, applies on OK), Comment, Alt Text (decorative follows the selection), Outline, Rename (section, shape, layout), About, Preferences, AutoCorrect, Grid Options, Export, Symbol, Photo Album (Edit Photo Album reopens this dialog), Paste Special, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media (start, end and fades follow the selection), SmartArt chooser, Create New Theme Colors, Create New Theme Fonts, Quit, Notes Pages | +| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Photo Album, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 42 (`crates/ui-egui/src/dialogs.rs`): Font, Paragraph (alignment, indents, spacing, tabs and Asian Typography: East Asian line breaking, hanging punctuation, mid-word Latin wrapping), Tabs, Columns (number and spacing; Home › Columns › More Columns… and Format Shape › Text Box › Columns…), Bullets and Numbering, Find and Replace, Replace Fonts, Table, Slide Size, Size and Position, Header and Footer, Date and Time, Hyperlink (address, slide and ScreenTip follow the selection), Action Settings, Set Up Show, Zoom (percent or Fit, applies on OK), Comment, Alt Text (decorative follows the selection), Outline, Rename (section, shape, layout), About, Preferences, AutoCorrect, Grid Options, Export, Symbol, Photo Album (Edit Photo Album reopens this dialog), Paste Special, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media (start, end and fades follow the selection), SmartArt chooser, Create New Theme Colors, Create New Theme Fonts, Quit, Notes Pages | | Design ▸ Effects | named theme effect schemes | DeckCraft, Soft, Crisp, Lift, Glow and Plain (`design.effects`); colours and fonts stay | ## Direct manipulation @@ -179,6 +180,7 @@ consistency is open (#73, #56). | 2026-10-23 | trivial | Scroll bars on the zoomed slide pane (#1157) | | 2026-10-23 | minor | Shape Fill and Shape Outline (Home and Shape Format): the button applies the last colour picked from its menu, Accent 1 and Text 1 until one is picked. Line weight and dashes are not changed by the button; Font Color and Highlight stay as they were (#1080) | | 2026-10-22 | trivial | Group fill and outline commands reach members, including nested groups; selection and single-step undo are preserved (#1079). The existing Arrange / Group checklist score stays D | +| 2026-10-20 | minor | Paragraph dialog: Asian Typography section — East Asian line breaking (禁則処理), hanging punctuation (句読点のぶら下げ) and mid-word Latin wrapping (英単語の途中で改行を許可), applied with the dialog's other edits in one undo step | | 2026-10-16 | minor | Export › Change File Type › PNG and the Export… dialog offer a transparent background (#784) | | 2026-10-15 | minor | Edit Points: the right-click menu has Straight/Curved Segment on a segment and Open/Close Path on a point | | 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |