Text: a word doesn't break after ?, !, } or + (URLs wrap as in PowerPoint) (#924)

* 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: Магда М.Г. <mail@mgmagda.ru>
This commit is contained in:
xmaks82
2026-11-01 10:54:32 +01:00
committed by GitHub
co-authored by Магда М.Г.
parent e690829543
commit 5fb694d991
3 changed files with 35 additions and 7 deletions
+12 -3
View File
@@ -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<bool>, Vec<bool>) {
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<bool>,
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)
+21 -3
View File
@@ -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::<Vec<_>>(), [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-");
}
+2 -1
View File
@@ -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 |