Skip to main content

kobo_core/html_text/
lines.rs

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