From 5fb694d9917d232fbf5b2e0f42ac15d73febde7c Mon Sep 17 00:00:00 2001 From: xmaks82 Date: Sun, 1 Nov 2026 12:54:32 +0300 Subject: [PATCH] Text: a word doesn't break after ?, !, } or + (URLs wrap as in PowerPoint) (#924) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Text: a word doesn't break after ?, !, } or + (URLs wrap as in PowerPoint) Unicode line breaking allows a break after ?, !, } and + inside a word, so a long URL broke after its '?' and after each '+' of a query and left the rest of the line empty. Measured in PowerPoint 2016 for Windows with latinLnBrk off (its default), none of them is a break: the word fills the line and breaks where it no longer fits, as PowerPoint already does after / and \ (break_opportunities). The same synthetic slide now wraps line for line as PowerPoint's PDF does, a Yandex search URL included. * Text tests: the first line doesn't end at the separator * Text tests: check the line is full, not that it avoids ending at a separator * Keep the Unicode break after ?, !, / outside Latin words The PowerPoint no-break rule only holds inside a word: before an ideograph (日本!語) or punctuation the Unicode break stays, as CodeRabbit pointed out. * Only letters and digits continue a word for the no-break rule AL and AI also hold symbols such as & and ×, so a!&b kept no break after !. is_word_char now also requires c.is_alphanumeric() (CodeRabbit). * Test that Latin before CJK and a separator before a space still break --------- Co-authored-by: Магда М.Г. --- crates/text/src/breaking.rs | 15 ++++++++++++--- crates/text/src/tests.rs | 24 +++++++++++++++++++++--- docs/target-app-parity.md | 3 ++- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/crates/text/src/breaking.rs b/crates/text/src/breaking.rs index 36b9e592..bb764b64 100644 --- a/crates/text/src/breaking.rs +++ b/crates/text/src/breaking.rs @@ -82,9 +82,18 @@ fn is_cj_nonstarter(c: char) -> bool { ) } +/// A character that continues a Latin-style word: a letter or digit that Unicode does not break +/// between, as opposed to CJK ideographs, kana, spaces, punctuation and symbols such as `&` or `×` +/// (which share the AL and AI line-break classes with letters). +fn is_word_char(c: char) -> bool { + use unicode_linebreak::BreakClass::{Alphabetic, Ambiguous, HebrewLetter, Numeric}; + matches!(unicode_linebreak::break_property(c as u32), Alphabetic | Ambiguous | HebrewLetter | Numeric) && c.is_alphanumeric() +} + /// 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. +/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/`, `\`, `?`, `!`, `}` +/// or `+` inside a word (measured in PowerPoint 2016 for Windows; before an ideograph or a space +/// the Unicode break stays), and Korean breaks only between words. pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec, Vec) { let n = text.chars().count(); let mut allowed = vec![false; n + 1]; @@ -113,7 +122,7 @@ pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec, 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() + latin_break || !matches!(prev, '/' | '\\' | '?' | '!' | '}' | '+') || !is_word_char(next) }; if !keep && !mandatory.get(i + 1).copied().unwrap_or(false) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f9f3f310..656dfdb6 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -857,19 +857,37 @@ fn one_line(text: &str) -> f64 { #[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 ["/", "\\"] { + // where it no longer fits; Unicode line breaking would allow a break after each slash. The + // same holds after ?, !, } and + (a URL's query, measured in PowerPoint 2016 for Windows). + 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:?}"); + // The line breaks where the next character no longer fits, not at an earlier separator + // (it may still end with one when that is where the width runs out). + let next: String = lines[0].chars().chain(lines[1].chars().take(1)).collect(); + assert!(one_line(&next) > w, "no break right after {sep:?} while more fits: {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. + // Outside a Latin word the Unicode break stays: "日本!" fits, "語" moves to the next line. + let lines = wrapped("日本!語", one_line("日本!") + 0.5, |_| {}); + assert_eq!(lines[0], "日本!", "{lines:?}"); + // Nor before a symbol that only shares the letters' line-break class. + let (allowed, _) = break_opportunities("ab!&cd", false); + assert!(allowed[3], "break after ! before &: {allowed:?}"); + // Latin text before CJK still breaks after the mark: "ab?" fits, "日本" moves down. + let lines = wrapped("ab?日本", one_line("ab?") + 0.5, |_| {}); + assert_eq!(lines[0], "ab?", "{lines:?}"); + // A separator followed by a space still breaks, and hyphens still break. + for sep in ["/", "?", "!"] { + let lines = wrapped(&format!("alpha{sep} beta"), one_line(&format!("alpha{sep} b")) + 0.5, |_| {}); + assert_eq!(lines.iter().map(|l| l.trim_end()).collect::>(), [format!("alpha{sep}"), "beta".into()], "{lines:?}"); + } let lines = wrapped("alpha-beta-gamma-delta-epsilon", one_line("alpha-beta-gamma-") + 1.0, |_| {}); assert_eq!(lines[0], "alpha-beta-gamma-"); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4f30ce5f..33b63188 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (Animations shortfall: preview from the selected effect is in; previous: major, community PR wave 2026-10-14: breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) after `cargo xtask parity`; area table and What works today folded to one row or bullet each; 320 engine commands; ready ~55% and stage alpha unchanged; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (no break after `?`, `!`, `}`, `+` inside a word; previous: trivial, Animations shortfall: preview from the selected effect is in; before that: major, community PR wave 2026-10-14: breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) after `cargo xtask parity`; area table and What works today folded to one row or bullet each; 320 engine commands; ready ~55% and stage alpha unchanged; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -289,6 +289,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| | 2026-10-22 | trivial | Animations shortfall: preview from the selected effect (Play From) is in. Percentages unchanged | +| 2026-10-17 | trivial | A word no longer breaks after `?`, `!`, `}` or `+` (a URL's query wraps at the edge as in PowerPoint 2016 for Windows), as it already didn't after `/` and `\`. Headline percentages unchanged | | 2026-10-16 | trivial | Arrange shortfall: Align to Slide and Align Selected Objects are in the Align menu. Percentages unchanged | | 2026-10-14 | minor | Community PR wave folded: one headline breadth row (84% over 191 features after `cargo xtask parity`; duplicate checklist rows removed), one row per feature area with the latest breadth and shortfalls, one Themes / Slide show / Views / Review / Automation bullet each (320 engine commands). SmartArt ready ~35% (Text Pane, 6e40335) moves the area mean to 62.8%; dimension and headline percentages unchanged | | 2026-10-13 | minor | Shapes shortfall "text warp" narrowed to adjust handles: the Transform gallery applies a preset |