From 9bab047f09697348b8c4068312cf57d31b3e5c74 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:28:09 +0900 Subject: [PATCH 1/2] Format: Asian typography paragraph options in the dialog and as commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a:pPr/@eaLnBrk, @hangingPunct and @latinLnBrk were modelled, read and written, but nothing could set them. Add format.eastAsianLineBreak, format.hangingPunctuation and format.latinLineBreak ({on?: bool}, toggling the selected paragraphs like format.rtl) and an Asian Typography section in the Paragraph dialog that applies all three together with the dialog's other paragraph edits in one undo step. The line breaker now honours east_asian_line_break too: Unicode offers no break before a small kana or the prolonged sound mark, so 禁則処理 off adds that opportunity instead of removing one, and a line may then start with one (Word's 禁則処理なし). The property absent still means on, and closing brackets and closing punctuation keep following the Unicode rule. Tests: the commands set, clear and toggle on the selection and on every selected paragraph, survive a PPTX save and re-open; the break opportunities relax with the flag and the default layout is unchanged; the dialog's OK and Cancel are covered by kittest. --- crates/engine/src/cmd/format.rs | 206 ++++++++++++++++++- crates/text/src/lib.rs | 40 +++- crates/text/src/tests.rs | 44 +++- crates/ui-egui/src/dialogs.rs | 13 ++ crates/ui-egui/src/paragraph_dialog_tests.rs | 67 ++++++ docs/localization-parity.md | 5 +- docs/ui-parity.md | 5 +- 7 files changed, 361 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 75952c998..62017b321 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -127,6 +127,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)), @@ -175,7 +202,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 ), @@ -786,6 +813,26 @@ fn rtl(s: &mut Session, p: &Value) -> Result { }) } +/// 禁則処理 (`eaLnBrk`): East Asian line breaking rules (small kana, the prolonged sound mark and +/// iteration marks never start a line). +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. +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 `\`. +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)) +} + fn bullets(s: &mut Session, p: &Value) -> Result { let cur = matches!(current_para(s).bullet, Some(Bullet::Char { .. })); let on = on_param(p, Some(cur)); @@ -899,8 +946,21 @@ 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()) @@ -916,6 +976,14 @@ 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"))), + } +} + fn clamp_pt(v: f64) -> f64 { v.clamp(0.0, 1584.0) } @@ -981,6 +1049,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), }) @@ -1028,6 +1101,9 @@ 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; @@ -1053,6 +1129,15 @@ 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); + } }) } @@ -1591,4 +1676,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/text/src/lib.rs b/crates/text/src/lib.rs index 9acc26c04..32f1324b3 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1314,9 +1314,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. -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. +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]; @@ -1341,17 +1343,30 @@ fn break_opportunities(text: &str, latin_break: bool) -> (Vec, Vec) 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`). + if !east_asian_break + && is_cj_nonstarter(next) + && !other_prohibition + && latin_allowed + && !mandatory_here + && let Some(a) = allowed.get_mut(i + 1) + { + *a = true; + } } (allowed, mandatory) } @@ -1374,7 +1389,12 @@ fn break_lines( x0_rest: f64, ) -> Vec { 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 a9d936cd4..2bbb6ccbe 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1114,9 +1114,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"); } @@ -1467,3 +1467,43 @@ fn empty_plain_paragraphs_and_soft_breaks_have_no_bullet_preview() { assert_eq!(l.lines.len(), 2); 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_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:?}"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2393bd611..cb77230b5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2920,6 +2920,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 { @@ -2943,6 +2946,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)), }); @@ -3037,6 +3043,10 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { 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() { @@ -3090,6 +3100,9 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { "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/paragraph_dialog_tests.rs b/crates/ui-egui/src/paragraph_dialog_tests.rs index fb8e796b1..8668d6138 100644 --- a/crates/ui-egui/src/paragraph_dialog_tests.rs +++ b/crates/ui-egui/src/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(); @@ -85,3 +98,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 f1cf80473..77f180a9a 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-14 · **Change:** 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-17 · **Last updated:** 2026-10-17 · **Change:** 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 | @@ -58,6 +58,7 @@ common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#p | Date | Change | Summary | |---|---|---| +| 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 3b1fedf71..a145c899d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** 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-17 · **Last updated:** 2026-10-17 · **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 @@ -71,7 +71,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; no preview from 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…) | 41 (`crates/ui-egui/src/dialogs.rs`): Font, Paragraph, Tabs, 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…) | 41 (`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, 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 @@ -165,6 +165,7 @@ consistency is open (#73, #56). | Date | Change | Summary | |---|---|---| +| 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-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing | | 2026-10-14 | minor | Community PR wave folded: one row each for Format Shape, Dialogs (41, listed from `dialogs.rs`), Animation Pane (timeline is in), window, Quick Access Toolbar, Format Background, rulers, image paste, context menus, exact size and edit points; estimates re-totalled (~26–46 h) | From 7881c619d4e6c5b1970c3f3b457b207a4bbc6af9 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:57:14 +0900 Subject: [PATCH 2/2] Format: eaLnBrk off lifts only the non-starter rule; repair doc headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a:pPr/@eaLnBrk="0" (禁則処理なし) added a break before a small kana or the prolonged sound mark wherever the local rules allowed it, which also dropped Unicode prohibitions that have nothing to do with non-starters: "漢「ッ」" got a break after the opening bracket (LB14), and a word joiner (U+2060) or a no-break space (U+00A0/U+202F) stopped binding (LB11/LB12). Rerun the line breaker on a copy where each non-starter is an ordinary kana — one char for one char, so the indices map back — and add the opportunity only where that relaxed run allows it, so relaxing 禁則 lifts LB21/LB16 and nothing else. Also repair the docs headers: both progress docs carried 2026-10-17 as Last reviewed/Last updated while their new revision-history rows are dated 2026-10-20. Keep Last reviewed at origin/main's 2026-10-14 (that is the date the document was reviewed) and move Last updated to 2026-10-20, matching the newest row as every earlier update did. Tests: the new opening-bracket / U+2060 / no-break-space test fails without the second run and passes with it. deckcraft-text 83 passed; deckcraft-engine 337 passed, 1 ignored, plus 6 integration; deckcraft-ui-egui 218 passed, 4 ignored (the PR's two asian-typography kittest cases pass); cargo fmt --all --check and clippy --all-targets -D warnings on the three crates are clean. --- crates/text/src/lib.rs | 26 +++++++++++++++++++++++++- crates/text/src/tests.rs | 17 +++++++++++++++++ docs/localization-parity.md | 2 +- docs/ui-parity.md | 2 +- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 32f1324b3..3d322fe6e 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1340,6 +1340,28 @@ fn break_opportunities(text: &str, latin_break: bool, east_asian_break: bool) -> } } 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). @@ -1357,12 +1379,14 @@ fn break_opportunities(text: &str, latin_break: bool, east_asian_break: bool) -> // 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`). + // 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; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 2bbb6ccbe..5d97bc45c 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1495,6 +1495,23 @@ fn east_asian_line_breaking_off_relaxes_the_non_starter_rule() { 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 diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 77f180a9a..3c746161a 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with PowerPoint -> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** 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) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** 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 diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a145c899d..bc49416c1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **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) +> **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