Skip to main content

shuyi_assets/
text.rs

1//! Word wrap for text boxes, the one the checks and the boxes share.
2//!
3//! Latin text breaks at spaces. Text without spaces (Japanese) breaks
4//! between any two of its characters, except where the usual *kinsoku*
5//! rules forbid it: never before closing punctuation or a small kana,
6//! never after an opening bracket. A run of Latin letters or digits inside
7//! it is kept whole, and so is text joined by a no-break space
8//! ([`NO_BREAK_SPACE`]). Chinese keeps the same rules with its own
9//! punctuation (`,` `》` `《`), and where its quotes, ellipsis and dash are
10//! wide ([`WIDE_PUNCTUATION`](crate::font::WIDE_PUNCTUATION)) a line never
11//! ends with `“` or starts with `”`, and `……` and `——` are never split.
12
13use crate::font::CellWidths;
14use crate::lang::text_width;
15use crate::prelude::*;
16
17/// A space [`word_wrap`] never breaks at (U+00A0), drawn as a space: what
18/// joins the words of a key's name filled into a text, so the name is
19/// never split across lines.
20pub const NO_BREAK_SPACE: char = '\u{a0}';
21
22/// Characters a line never starts with: closing punctuation, the
23/// prolonged sound mark and the small kana.
24const NO_START: &str = "、。,.・:;?!)》〉」』】〕ーぁぃぅぇぉっゃゅょァィゥェォッャュョ\
25                        ,.:;?!)]}";
26
27/// Characters a line never ends with: opening brackets.
28const NO_END: &str = "(《〈「『【〔([{";
29
30/// Closing quotes, the ellipsis and the dash, which a line never starts
31/// with where they are wide (Chinese): so `……` and `——` are never split.
32/// Narrow, they are English's, and break as they always have.
33const NO_START_WIDE: &str = "\u{201D}\u{2019}\u{2026}\u{2014}";
34
35/// Opening quotes, which a line never ends with where they are wide.
36const NO_END_WIDE: &str = "\u{201C}\u{2018}";
37
38/// Whether a line may not start with `c`.
39fn no_start(c: char, widths: &CellWidths) -> bool {
40    NO_START.contains(c) || (NO_START_WIDE.contains(c) && widths.is_wide(c))
41}
42
43/// Whether a line may not end with `c`.
44fn no_end(c: char, widths: &CellWidths) -> bool {
45    NO_END.contains(c) || (NO_END_WIDE.contains(c) && widths.is_wide(c))
46}
47
48/// Whether text may break at `c`, dropping it: whitespace, but not a
49/// no-break space.
50fn breaks_at(c: char) -> bool {
51    c.is_whitespace() && c != NO_BREAK_SPACE
52}
53
54/// Whether `c` is written without spaces between words, so a line may
55/// break on either side of it: a character `widths` has wide, or one of
56/// the Japanese and Chinese scripts and their punctuation (so text breaks
57/// the same before an atlas has its wide glyphs).
58fn breaks_around(c: char, widths: &CellWidths) -> bool {
59    widths.is_wide(c)
60        || matches!(c,
61            '\u{3000}'..='\u{30ff}'   // CJK punctuation, hiragana, katakana
62            | '\u{3400}'..='\u{4dbf}' // CJK extension A
63            | '\u{4e00}'..='\u{9fff}' // CJK unified ideographs
64            | '\u{f900}'..='\u{faff}' // CJK compatibility ideographs
65            | '\u{ff00}'..='\u{ffef}' // fullwidth and halfwidth forms
66        )
67}
68
69/// Whether a line may break between `a` and `b`, two characters of a word
70/// (text between spaces).
71fn may_break(a: char, b: char, widths: &CellWidths) -> bool {
72    (breaks_around(a, widths) || breaks_around(b, widths))
73        && a != NO_BREAK_SPACE
74        && b != NO_BREAK_SPACE
75        && !no_start(b, widths)
76        && !no_end(a, widths)
77}
78
79/// The pieces of `word` (text without a space to break at) that a line
80/// may break between.
81fn pieces<'a>(word: &'a str, widths: &CellWidths) -> Vec<&'a str> {
82    let mut out = Vec::new();
83    let mut start = 0;
84    let mut chars = word.char_indices().peekable();
85    while let Some((_, a)) = chars.next() {
86        if let Some(&(at, b)) = chars.peek()
87            && may_break(a, b, widths)
88        {
89            out.push(&word[start..at]);
90            start = at;
91        }
92    }
93    out.push(&word[start..]);
94    out
95}
96
97/// The lines being built: those done, the last one, and how many cells
98/// it takes.
99struct Lines<'w> {
100    width: usize,
101    widths: &'w CellWidths,
102    done: Vec<String>,
103    line: String,
104    cells: usize,
105}
106
107impl Lines<'_> {
108    /// Ends the line, if it holds anything.
109    fn end_line(&mut self) {
110        if self.cells > 0 {
111            self.done.push(core::mem::take(&mut self.line));
112            self.cells = 0;
113        }
114    }
115
116    /// Adds `piece`, after a space if `spaced` and the line holds
117    /// something: on this line if it fits, else on a new one, cut into
118    /// lines of `width` cells if it is wider than a line.
119    fn add(&mut self, piece: &str, spaced: bool) {
120        let cells = text_width(piece, self.widths);
121        let gap = usize::from(spaced && self.cells > 0);
122        if self.cells + gap + cells <= self.width {
123            if gap > 0 {
124                self.line.push(' ');
125            }
126            self.line.push_str(piece);
127            self.cells += gap + cells;
128            return;
129        }
130        self.end_line();
131        for c in piece.chars() {
132            let c_cells = self.widths.of_char(c);
133            if self.cells + c_cells > self.width {
134                self.end_line();
135            }
136            self.line.push(c);
137            self.cells += c_cells;
138        }
139    }
140}
141
142/// Splits `text` into lines of at most `width` cells (a character
143/// `widths` has wide takes two, [`text_width`]).
144///
145/// Latin text breaks at spaces; a word is never split unless it alone is
146/// wider than `width`, and then it is cut into `width`-wide pieces. Runs
147/// of spaces count as one break. Text without spaces (Japanese) breaks
148/// between any two characters with a wide or Japanese one among them,
149/// except before closing punctuation or a small kana (`。`, `、`, `」`,
150/// `っ`, `,`, `》`) and after an opening bracket (`「`, `《`); where `widths`
151/// has them wide, also before `” ’ … —` (so `……` and `——` stay whole)
152/// and after `“ ‘`. A run of Latin letters or
153/// digits inside it is kept whole, as is anything joined by a
154/// [`NO_BREAK_SPACE`]. Where those rules leave no break that fits, the
155/// text is cut anyway: a line never runs past `width`, unless `width` is
156/// 1 and the line is one wide character.
157///
158/// Empty (or all-space) text gives no lines. A `width` of 0 is treated as
159/// 1.
160pub fn word_wrap(text: &str, width: usize, widths: &CellWidths) -> Vec<String> {
161    let mut lines = Lines {
162        width: width.max(1),
163        widths,
164        done: Vec::new(),
165        line: String::new(),
166        cells: 0,
167    };
168    for word in text.split(breaks_at).filter(|w| !w.is_empty()) {
169        for (i, piece) in pieces(word, widths).into_iter().enumerate() {
170            lines.add(piece, i == 0);
171        }
172    }
173    lines.end_line();
174    lines.done
175}
176
177#[cfg(test)]
178mod tests {
179    use proptest::prelude::*;
180
181    use super::*;
182
183    fn wrap(text: &str, width: usize) -> Vec<String> {
184        word_wrap(text, width, &CellWidths::NARROW)
185    }
186
187    /// Widths where every Japanese character of these tests is wide.
188    fn japanese() -> CellWidths {
189        CellWidths::wide(
190            "今日はいい天気ですね。散歩に行きましょうか、それとも家で本を読みますか?\
191             彼女は「明日また来ます」と言った。ちょっと待ってくださいコーヒーを一杯\
192             私の名前ですゲームが好きで毎日遊んでいる遊ぶ(はい)"
193                .chars(),
194        )
195    }
196
197    #[test]
198    fn breaks_at_spaces() {
199        assert_eq!(wrap("the quick brown fox", 10), ["the quick", "brown fox"]);
200        assert_eq!(wrap("abc def", 7), ["abc def"]);
201        assert_eq!(wrap("abc def", 6), ["abc", "def"]);
202    }
203
204    #[test]
205    fn hard_splits_only_over_long_words() {
206        assert_eq!(wrap("ab abcdefgh c", 3), ["ab", "abc", "def", "gh", "c"]);
207        assert_eq!(wrap("abcdef", 3), ["abc", "def"]);
208    }
209
210    #[test]
211    fn empty_and_extra_spaces() {
212        let found = wrap("", 5);
213        assert!(found.is_empty(), "{found:?}");
214        let found = wrap(" ", 5);
215        assert!(found.is_empty(), "{found:?}");
216        assert_eq!(wrap("  a   b  ", 5), ["a b"]);
217        assert_eq!(wrap("ab", 0), ["a", "b"]);
218    }
219
220    #[test]
221    fn counts_characters_not_bytes() {
222        assert_eq!(wrap("ééé ééé", 3), ["ééé", "ééé"]);
223    }
224
225    #[test]
226    fn a_wide_character_takes_two_cells() {
227        let widths = japanese();
228        // Six characters, twelve cells: three to a line of six cells.
229        assert_eq!(word_wrap("今日はいい天", 6, &widths), ["今日は", "いい天"]);
230        // Measured narrow, the same text is six cells and one line.
231        assert_eq!(
232            word_wrap("今日はいい天", 6, &CellWidths::NARROW),
233            ["今日はいい天"]
234        );
235        // A wide character never straddles the end of a line.
236        assert_eq!(word_wrap("今日は", 5, &widths), ["今日", "は"]);
237    }
238
239    #[test]
240    fn japanese_breaks_between_characters_without_an_atlas_and_a_wide_one_anywhere() {
241        // Measured narrow, Japanese still breaks between its characters:
242        // `今日` joins `a` rather than the run being cut.
243        assert_eq!(
244            word_wrap("a 今日はいい", 4, &CellWidths::NARROW),
245            ["a 今日", "はいい"]
246        );
247        // A wide character of no Japanese script breaks around it too.
248        let hearts = CellWidths::wide(['♥']);
249        assert_eq!(word_wrap("a ♥♥", 4, &hearts), ["a ♥", "♥"]);
250    }
251
252    #[test]
253    fn a_japanese_paragraph_breaks_anywhere_within_the_width() {
254        let widths = japanese();
255        let text = "今日はいい天気ですね。散歩に行きましょうか、それとも家で本を読みますか?";
256        let lines = word_wrap(text, 16, &widths);
257        assert_eq!(
258            lines,
259            [
260                "今日はいい天気で",
261                "すね。散歩に行き",
262                "ましょうか、それ",
263                "とも家で本を読み",
264                "ますか?",
265            ]
266        );
267        assert_eq!(lines.concat(), text);
268    }
269
270    #[test]
271    fn no_line_starts_with_closing_punctuation_or_a_small_kana() {
272        let widths = japanese();
273        // `。` would start the second line at width 10: `ね` goes with it.
274        assert_eq!(
275            word_wrap("天気ですね。散歩", 10, &widths),
276            ["天気です", "ね。散歩"]
277        );
278        // `、` likewise.
279        assert_eq!(
280            word_wrap("行きましょうか、それ", 14, &widths),
281            ["行きましょう", "か、それ"]
282        );
283        // A small `ょ` never starts a line: at width 8 `行きまし` would
284        // leave it to start the next, so `し` goes down with it.
285        assert_eq!(word_wrap("行きましょう", 8, &widths), ["行きま", "しょう"]);
286        // Nor the prolonged sound mark, nor a closing bracket.
287        assert_eq!(word_wrap("コーヒー", 6, &widths), ["コー", "ヒー"]);
288        assert_eq!(word_wrap("(はい)", 6, &widths), ["(は", "い)"]);
289    }
290
291    #[test]
292    fn no_line_ends_with_an_opening_bracket() {
293        let widths = japanese();
294        let text = "彼女は「明日また来ます」と言った。";
295        // At width 8 the first line could be `彼女は「`: the bracket goes
296        // down with the word it opens.
297        let lines = word_wrap(text, 8, &widths);
298        assert_eq!(
299            lines,
300            ["彼女は", "「明日ま", "た来ま", "す」と", "言った。"]
301        );
302        for line in &lines {
303            assert!(!line.ends_with('「'), "{lines:?}");
304            assert!(!line.starts_with(['。', '、', '」']), "{lines:?}");
305        }
306    }
307
308    #[test]
309    fn breaks_anyway_where_the_rules_leave_no_break_that_fits() {
310        // Nothing may break in `「「「「`: it is cut at the width.
311        let brackets = CellWidths::wide(['「']);
312        assert_eq!(word_wrap("「「「「", 4, &brackets), ["「「", "「「"]);
313        // `ね。」` can't break, and is wider than a line of 4.
314        let widths = CellWidths::wide("ね。」".chars());
315        assert_eq!(word_wrap("ね。」", 4, &widths), ["ね。", "」"]);
316    }
317
318    #[test]
319    fn a_latin_run_inside_japanese_is_kept_whole() {
320        let widths = japanese();
321        let text = "私の名前はMeiです";
322        // `Mei` doesn't fit after `私の名前は` (10 cells) at width 12: it
323        // goes down whole.
324        assert_eq!(word_wrap(text, 12, &widths), ["私の名前は", "Meiです"]);
325        // Digits too.
326        assert_eq!(
327            word_wrap("毎日遊ぶ2026回", 10, &widths),
328            ["毎日遊ぶ", "2026回"]
329        );
330        // A run wider than the width is cut, as a long Latin word is.
331        assert_eq!(word_wrap("私abcdefg", 4, &widths), ["私", "abcd", "efg"]);
332    }
333
334    #[test]
335    fn latin_and_japanese_with_spaces_break_at_the_spaces_and_between_wide_characters() {
336        let widths = japanese();
337        assert_eq!(
338            word_wrap("Press Z ゲームが好きで", 12, &widths),
339            ["Press Z ゲー", "ムが好きで"]
340        );
341    }
342
343    #[test]
344    fn never_breaks_at_a_no_break_space() {
345        let nbsp = NO_BREAK_SPACE;
346        let text = format!("press Left{nbsp}Shift to run");
347        assert_eq!(
348            wrap(&text, 12),
349            ["press", format!("Left{nbsp}Shift").as_str(), "to run"]
350        );
351        // Nor beside one, between Japanese characters.
352        let widths = japanese();
353        let text = format!("毎日Left{nbsp}Shiftを押す");
354        assert_eq!(
355            word_wrap(&text, 12, &widths),
356            ["毎日", format!("Left{nbsp}Shiftを").as_str(), "押す"]
357        );
358    }
359
360    /// Chinese sentences with speech in `“”`, a doubled `……` and `——`.
361    const CHINESE: &str = "他说:“今天天气很好,我们去公园散步吧。”她回答:“好啊……不过我有点累\
362                           ——我们明天去,好吗?”《西游记》是一本书(很有名)。";
363
364    /// Widths where every character of [`CHINESE`] but the punctuation is
365    /// wide, and the punctuation as a Chinese pack has it.
366    fn chinese() -> CellWidths {
367        let hanzi = CHINESE
368            .chars()
369            .filter(|c| !crate::font::is_wide_punctuation(*c));
370        CellWidths::wide(hanzi).in_lang(Some("zh"), true)
371    }
372
373    #[test]
374    fn a_chinese_paragraph_wraps_within_the_width_by_its_rules() {
375        let widths = chinese();
376        assert_eq!(
377            word_wrap(CHINESE, 16, &widths),
378            [
379                "他说:“今天天气",
380                "很好,我们去公园",
381                "散步吧。”她回",
382                "答:“好啊……不",
383                "过我有点累——我",
384                "们明天去,好",
385                "吗?”《西游记》",
386                "是一本书(很有",
387                "名)。",
388            ]
389        );
390        // From 6, every piece the rules keep whole (`啊……`, `吗?”`) fits a
391        // line; narrower, such a piece is cut anyway.
392        for width in 6..40 {
393            let lines = word_wrap(CHINESE, width, &widths);
394            assert_eq!(lines.concat(), CHINESE, "at {width}");
395            for (i, line) in lines.iter().enumerate() {
396                assert!(text_width(line, &widths) <= width, "{lines:?}");
397                assert!(
398                    !line.starts_with([',', '。', '”', '》', ')']),
399                    "{lines:?}"
400                );
401                assert!(!line.ends_with(['“', '《', '(']), "{lines:?}");
402                // `……` and `——` stay whole: a line never starts with the
403                // second half of either.
404                let next = lines.get(i + 1);
405                for pair in ['…', '—'] {
406                    let split = line.ends_with(pair) && next.is_some_and(|n| n.starts_with(pair));
407                    assert!(!split, "{pair} split at {width}: {lines:?}");
408                }
409            }
410        }
411    }
412
413    #[test]
414    fn chinese_punctuation_never_starts_or_ends_a_line() {
415        let widths = chinese();
416        // `,` would start the second line: `好` goes down with it.
417        assert_eq!(word_wrap("很好,我们", 4, &widths), ["很", "好,", "我们"]);
418        // `“` would end the first line: it goes down with what it opens.
419        assert_eq!(word_wrap("他说“好”", 6, &widths), ["他说", "“好”"]);
420        // `……` is never split, even where half of it fits.
421        assert_eq!(word_wrap("好啊……", 6, &widths), ["好", "啊……"]);
422        assert_eq!(word_wrap("累——我", 6, &widths), ["累——", "我"]);
423        // `《` would end the first line, `》` start the last.
424        assert_eq!(
425            word_wrap("他是《西游记》", 6, &widths),
426            ["他是", "《西游", "记》"]
427        );
428    }
429
430    #[test]
431    fn narrow_quotes_and_dashes_wrap_as_english_does() {
432        // Narrow, `“`, `…` and `—` are English's: the rules for them in
433        // Chinese don't apply, and English wraps as it always has.
434        let text = "she said “wait… no—go” and left";
435        let lines = wrap(text, 10);
436        assert_eq!(lines, ["she said", "“wait…", "no—go” and", "left"]);
437        assert_eq!(lines, word_wrap(text, 10, &chinese().in_lang(None, false)));
438    }
439
440    fn words(max_len: usize) -> impl Strategy<Value = Vec<String>> {
441        prop::collection::vec(
442            prop::string::string_regex(&format!("[a-zA-Z.,!?']{{1,{max_len}}}")).unwrap(),
443            0..40,
444        )
445    }
446
447    /// Japanese text with Latin runs, punctuation and brackets, and no
448    /// spaces.
449    fn japanese_text() -> impl Strategy<Value = String> {
450        prop::collection::vec(
451            prop::string::string_regex("[今日はいい天気ですね。、「」()ょっーab1]{1,4}").unwrap(),
452            0..30,
453        )
454        .prop_map(|parts| parts.concat())
455    }
456
457    proptest! {
458        #[test]
459        fn no_line_is_too_wide(ws in words(30), width in 1usize..40) {
460            for line in wrap(&ws.join(" "), width) {
461                prop_assert!(line.chars().count() <= width, "{line:?} > {width}");
462                prop_assert!(!line.is_empty());
463            }
464        }
465
466        #[test]
467        fn keeps_every_word_when_words_fit(
468            ws in words(12),
469            width in 12usize..40,
470            gaps in prop::collection::vec(1usize..4, 40),
471        ) {
472            let mut text = String::new();
473            for (w, &g) in ws.iter().zip(&gaps) {
474                text.push_str(w);
475                text.push_str(&" ".repeat(g));
476            }
477            let lines = wrap(&text, width);
478            let rejoined: Vec<&str> = lines.iter().flat_map(|l| l.split(' ')).collect();
479            prop_assert_eq!(rejoined, ws.iter().map(String::as_str).collect::<Vec<_>>());
480        }
481
482        #[test]
483        fn keeps_every_character(ws in words(30), width in 1usize..40) {
484            let text = ws.join(" ");
485            let kept: String = wrap(&text, width).concat().replace(' ', "");
486            prop_assert_eq!(kept, text.replace(' ', ""));
487        }
488
489        #[test]
490        fn lines_are_filled_greedily(ws in words(12), width in 12usize..40) {
491            let lines = wrap(&ws.join(" "), width);
492            for pair in lines.windows(2) {
493                let next_word = pair[1].split(' ').next().unwrap_or("");
494                prop_assert!(
495                    pair[0].chars().count() + 1 + next_word.chars().count() > width,
496                    "{:?} could have taken {next_word:?}", pair[0]
497                );
498            }
499        }
500
501        #[test]
502        fn japanese_lines_fit_and_join_back_to_the_text(
503            text in japanese_text(),
504            width in 2usize..30,
505        ) {
506            let widths = japanese();
507            let lines = word_wrap(&text, width, &widths);
508            for line in &lines {
509                let cells = text_width(line, &widths);
510                prop_assert!(cells <= width, "{line:?} is {cells} cells > {width}");
511                prop_assert!(!line.is_empty());
512            }
513            prop_assert_eq!(lines.concat(), text);
514        }
515
516        #[test]
517        fn chinese_lines_fit_and_join_back_to_the_text_with_wide_punctuation(
518            parts in prop::collection::vec(
519                prop::string::string_regex("[他说好我们,。“”‘’…—《》ab]{1,4}").unwrap(),
520                0..30,
521            ),
522            width in 2usize..30,
523        ) {
524            let text = parts.concat();
525            let widths = chinese();
526            let lines = word_wrap(&text, width, &widths);
527            for line in &lines {
528                let cells = text_width(line, &widths);
529                prop_assert!(cells <= width, "{line:?} is {cells} cells > {width}");
530                prop_assert!(!line.is_empty());
531            }
532            prop_assert_eq!(lines.concat(), text);
533        }
534
535        #[test]
536        fn japanese_lines_keep_the_rules_where_a_break_fits(
537            text in japanese_text(),
538            width in 8usize..30,
539        ) {
540            let widths = japanese();
541            let lines = word_wrap(&text, width, &widths);
542            for pair in lines.windows(2) {
543                let (end, start) = (pair[0].chars().last(), pair[1].chars().next());
544                let (Some(end), Some(start)) = (end, start) else { continue };
545                // Only a piece wider than a line is cut against the rules.
546                let unbreakable = pieces(&format!("{}{}", pair[0], pair[1]), &widths)
547                    .iter()
548                    .any(|p| text_width(p, &widths) > width);
549                if !unbreakable {
550                    prop_assert!(may_break(end, start, &widths), "{lines:?}");
551                }
552            }
553        }
554    }
555}