From 8b160d5b03bda38e0d59af766dcfbb487a45b043 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 17:47:12 -0400 Subject: [PATCH] Text: wrap URLs, Korean and CJK punctuation where PowerPoint does (#276) * Text: wrap lines where PowerPoint does - Without latinLnBrk (PowerPoint's default) a word doesn't break after '/' or '\': URLs and paths fill the line and break at its edge. - Korean wraps between words, not between syllables. - With hangingPunct, closing East Asian punctuation hangs past the end of the line instead of dragging the previous character down. ParaProps gains latin_line_break and hanging_punct, read and written as latinLnBrk and hangingPunct. Co-Authored-By: Claude Opus 5.5 (1M context) * Text: simpler break guard (clippy) Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- crates/model/src/text.rs | 6 ++++ crates/pptx/src/read/dml.rs | 2 ++ crates/pptx/src/write/dml.rs | 2 ++ crates/pptx/tests/roundtrip.rs | 14 ++++++++ crates/text/src/lib.rs | 61 ++++++++++++++++++++++++++++++-- crates/text/src/tests.rs | 63 ++++++++++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 3 deletions(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index addbdff..feaff46 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -223,6 +223,10 @@ pub struct ParaProps { pub rtl: Option, pub font_align: Option, pub east_asian_line_break: Option, + /// `latinLnBrk`: Latin words may break inside (after `/`, `\`); off by default. + pub latin_line_break: Option, + /// `hangingPunct`: closing East Asian punctuation may hang past the end of a line. + pub hanging_punct: Option, } impl ParaProps { @@ -253,6 +257,8 @@ impl ParaProps { self.font_align.clone_from(&p.font_align); } self.east_asian_line_break = self.east_asian_line_break.or(p.east_asian_line_break); + self.latin_line_break = self.latin_line_break.or(p.latin_line_break); + self.hanging_punct = self.hanging_punct.or(p.hanging_punct); } } diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 01e08d7..36f2a66 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -345,6 +345,8 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option rtl: p.bool("rtl"), font_align: p.attr("fontAlgn").map(String::from), east_asian_line_break: p.bool("eaLnBrk"), + latin_line_break: p.bool("latinLnBrk"), + hanging_punct: p.bool("hangingPunct"), default_tab: pt(p, "defTabSz"), ..Default::default() }; diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index eddb618..23d3340 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -432,6 +432,8 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: .o("defTabSz", p.default_tab.map(|v| emu(v).max(0))) .b("rtl", p.rtl) .b("eaLnBrk", p.east_asian_line_break) + .b("latinLnBrk", p.latin_line_break) + .b("hangingPunct", p.hanging_punct) .o("fontAlgn", p.font_align.as_deref().filter(|f| matches!(*f, "auto" | "t" | "ctr" | "base" | "b"))); let has_kids = p.line_spacing.is_some() || p.space_before.is_some() diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index fddb0d9..66576e7 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -766,3 +766,17 @@ fn a_picture_duotone_round_trips() { assert!(matches!(d.base, deckcraft_model::ColorBase::Scheme { slot: SchemeSlot::Accent1 })); assert!(matches!(l.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(250, 240, 200))); } + +#[test] +fn line_break_options_round_trip() { + let mut p = defaults::new_presentation(None); + let first = p.slides[0].id; + let s = p.slide_mut(first).expect("slide"); + let t = s.shapes[0].text.insert(TextBody::from_text("x")); + t.paragraphs[0].props.latin_line_break = Some(true); + t.paragraphs[0].props.hanging_punct = Some(false); + let q = round(&p); + let props = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].props; + assert_eq!(props.latin_line_break, Some(true)); + assert_eq!(props.hanging_punct, Some(false)); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 5eeb40c..6918a8d 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -718,8 +718,44 @@ fn line_metrics(face: &FontFace, size: f64) -> (f64, f64) { (h * face.line_split, h * (1.0 - face.line_split)) } -/// Break opportunities: allowed[i] = a line may start at char i. -fn break_opportunities(text: &str) -> (Vec, Vec) { +/// Hangul syllables and jamo: PowerPoint wraps Korean at word boundaries (spaces), not between +/// syllables. +fn is_hangul(c: char) -> bool { + matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}') +} + +/// Closing East Asian punctuation that may hang past the end of a line (`hangingPunct`). +fn is_hanging_punct(c: char) -> bool { + matches!( + c, + '、' | '。' + | ',' + | '.' + | ':' + | ';' + | '!' + | '?' + | ')' + | '」' + | '』' + | '】' + | '〕' + | '〉' + | '》' + | '〙' + | '〛' + | '}' + | ']' + | '」' + | '、' + | '。' + ) +} + +/// 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) { let n = text.chars().count(); let mut allowed = vec![false; n + 1]; let mut mandatory = vec![false; n + 1]; @@ -740,6 +776,17 @@ fn break_opportunities(text: &str) -> (Vec, Vec) { } } } + let chars: Vec = text.chars().collect(); + for (i, w) in chars.windows(2).enumerate() { + let (prev, next) = (w[0], w[1]); + let keep = if is_hangul(prev) && is_hangul(next) { false } else { latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace() }; + if !keep + && !mandatory.get(i + 1).copied().unwrap_or(false) + && let Some(a) = allowed.get_mut(i + 1) + { + *a = false; + } + } (allowed, mandatory) } @@ -761,7 +808,8 @@ fn break_lines( x0_rest: f64, ) -> Vec { let text: String = p.cells.iter().map(|c| c.ch).collect(); - let (allowed, _) = break_opportunities(&text); + let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false)); + let hang = p.props.hanging_punct.unwrap_or(false); let n = p.cells.len(); let mut lines = vec![]; let mut start = 0; @@ -788,6 +836,13 @@ fn break_lines( if i > start && allowed.get(i).copied().unwrap_or(false) && breakable(i) { last_break = Some(i); } + // Closing East Asian punctuation hangs past the end instead of wrapping + // (`hangingPunct`); the line breaks after it. + if wrap && hang && x + adv > avail + 0.01 && x <= avail + 0.01 && is_hanging_punct(c) && i > start { + end = i + 1; + next = i + 1; + break; + } if wrap && x + adv > avail + 0.01 && !c.is_whitespace() && i > start { // Break at the last opportunity, or force mid-word (between clusters). let b = last_break diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 0959576..f801141 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -520,3 +520,66 @@ fn single_spaced_lines_are_1_2_times_the_size_apart_whatever_the_font() { assert!((first.baseline - first.top - size * LINE_PITCH * face.line_split).abs() < 1e-6); } } + +/// The lines of `text` in an Arial-free (whatever face resolves) box `w` wide, as strings. +fn wrapped(text: &str, w: f64, set: impl Fn(&mut ParaProps)) -> Vec { + let (p, mut sh) = bidi_setup(vec![Run::new(text)], w, false, Align::Left); + let t = sh.text.as_mut().unwrap(); + t.body.inset_l = Some(0.0); + t.body.inset_r = Some(0.0); + set(&mut t.paragraphs[0].props); + let l = lay(&p, &sh, w, 600.0); + let chars: Vec = text.chars().collect(); + l.lines.iter().map(|li| chars[li.start..li.end].iter().collect()).collect() +} + +/// Width of `text` on one line. +fn one_line(text: &str) -> f64 { + let (p, sh) = bidi_setup(vec![Run::new(text)], 5000.0, false, Align::Left); + let l = lay(&p, &sh, 5000.0, 300.0); + l.lines[0].caret_x.last().unwrap() - l.lines[0].caret_x[0] +} + +#[test] +fn a_word_does_not_break_after_a_slash_or_backslash() { + // PowerPoint (latinLnBrk off, its default) keeps "alpha/beta/gamma" whole and only breaks it + // where it no longer fits; Unicode line breaking would allow a break after each slash. + for sep in ["/", "\\"] { + let text = ["alpha", "beta", "gamma", "delta", "epsilon"].join(sep); + let w = one_line(&text) * 0.6; + let lines = wrapped(&text, w, |_| {}); + assert!(lines.len() >= 2, "{lines:?}"); + assert!(lines[0].chars().count() > "alpha/beta".len(), "filled to the edge, not cut at a separator: {lines:?}"); + } + // latinLnBrk on: Unicode's break after a slash is allowed again. + let text = "alpha/beta/gamma/delta/epsilon"; + let lines = wrapped(text, one_line(text) * 0.6, |p| p.latin_line_break = Some(true)); + assert!(lines[0].ends_with('/'), "{lines:?}"); + // A slash followed by a space still breaks, and hyphens still break. + let lines = wrapped("alpha-beta-gamma-delta-epsilon", one_line("alpha-beta-gamma-") + 1.0, |_| {}); + assert_eq!(lines[0], "alpha-beta-gamma-"); +} + +#[test] +fn korean_wraps_between_words() { + let text = "한국어 안녕하세요 반갑습니다 감사합니다"; + let w = one_line("한국어 안녕하세요 반") + 0.5; + let lines = wrapped(text, w, |_| {}); + assert_eq!(lines.iter().map(|l| l.trim_end()).collect::>(), ["한국어 안녕하세요", "반갑습니다", "감사합니다"], "{lines:?}"); + // Japanese and Chinese still break between characters. + let lines = wrapped("日本語の文章は空白なしで続きます", one_line("日本語の文章") + 0.5, |_| {}); + assert_eq!(lines[0], "日本語の文章"); +} + +#[test] +fn closing_punctuation_hangs_past_the_end_with_hanging_punct() { + // PowerPoint (hangingPunct on, as its default text styles set it) lets "。" hang past the + // edge instead of pulling the character before it onto the next line. + let text = "白なしで続きます。句読点も含めて。"; + let w = one_line("白なしで続きます") + 0.5; + let lines = wrapped(text, w, |p| p.hanging_punct = Some(true)); + assert_eq!(lines[0], "白なしで続きます。", "{lines:?}"); + // Without it, "。" can't start a line, so "す。" moves down together. + let lines = wrapped(text, w, |p| p.hanging_punct = Some(false)); + assert_eq!(lines[0], "白なしで続きま", "{lines:?}"); +}