Skip to main content

kobo_core/html_text/
lines.rs

1//! Line wrapping + sentence indexing for the Reader line model.
2
3/// A wrapped line of chapter text with its byte range into the source text.
4/// (char range so a WordMark's offset maps to a line; the Slint reader renders
5/// one row per line and shows the left accent on the current sentence's lines.)
6#[derive(Debug, Clone, PartialEq)]
7pub struct Line {
8    pub text: String,
9    pub start: usize,
10    pub end: usize,
11    pub width: f32,
12}
13
14/// Word-wrap `text` into lines of at most `max_chars` (by char count),
15/// preserving word boundaries. Each line carries its byte range.
16pub fn lines(text: &str, max_chars: usize) -> Vec<Line> {
17    let mut out: Vec<Line> = Vec::new();
18    let mut line_start: usize = 0;
19    let mut line_text = String::new();
20    let mut line_chars = 0usize;
21    let mut i = 0usize;
22    let bytes = text.as_bytes();
23    let n = bytes.len();
24    while i < n {
25        while i < n {
26            let ch_len = text[i..].chars().next().map_or(0, |c| c.len_utf8());
27            if ch_len == 0 {
28                break;
29            }
30            if text[i..].chars().next().is_some_and(|c| c.is_whitespace()) {
31                i += ch_len;
32            } else {
33                break;
34            }
35        }
36        let word_start = i;
37        while i < n {
38            let Some(ch) = text[i..].chars().next() else {
39                break;
40            };
41            if ch.is_whitespace() {
42                break;
43            }
44            i += ch.len_utf8();
45        }
46        if i == word_start {
47            break;
48        }
49        let word = &text[word_start..i];
50        let wc = word.chars().count();
51        if !line_text.is_empty() && line_chars + 1 + wc > max_chars {
52            out.push(Line {
53                text: line_text,
54                start: line_start,
55                end: word_start,
56                width: 0.0,
57            });
58            line_start = word_start;
59            line_text = String::new();
60            line_chars = 0;
61        }
62        if line_text.is_empty() {
63            line_text.push_str(word);
64            line_chars = wc;
65        } else {
66            line_text.push(' ');
67            line_text.push_str(word);
68            line_chars += 1 + wc;
69        }
70    }
71    if !line_text.is_empty() {
72        out.push(Line {
73            text: line_text,
74            start: line_start,
75            end: n,
76            width: 0.0,
77        });
78    }
79    out
80}
81
82/// Sentence index at a byte offset = count of sentence-ending punctuation
83/// (`. ! ?`) strictly before that offset. Used to map a line's start (and a
84/// WordMark's offset) to a sentence for the left-accent highlight.
85pub fn sentence_index_at(text: &str, byte_offset: usize) -> usize {
86    let upto = byte_offset.min(text.len());
87    text[..upto].matches(['.', '!', '?']).count()
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn linebreak_wraps_to_max_chars_with_ranges() {
96        let text = "alpha beta gamma delta epsilon zeta eta theta";
97        let ls = lines(text, 15);
98        for w in &ls {
99            assert!(w.text.chars().count() <= 15, "line too long: {:?}", w.text);
100        }
101        assert_eq!(ls.first().unwrap().start, 0);
102        assert_eq!(ls.last().unwrap().end, text.len());
103        for w in ls.windows(2) {
104            assert_eq!(w[0].end, w[1].start);
105        }
106        assert_eq!(
107            ls.iter()
108                .map(|l| l.text.as_str())
109                .collect::<Vec<_>>()
110                .join(" "),
111            text
112        );
113    }
114
115    #[test]
116    fn sentence_index_advances_on_punctuation() {
117        let text = "First sentence. Second one! And a third?";
118        assert_eq!(sentence_index_at(text, 0), 0);
119        assert_eq!(sentence_index_at(text, text.find("Second").unwrap()), 1);
120        assert_eq!(sentence_index_at(text, text.find("And").unwrap()), 2);
121        assert_eq!(sentence_index_at(text, text.len()), 3);
122    }
123}