Skip to main content

kobo_core/rendering/
layout.rs

1//! Pure text-layout functions: word wrapping and sentence segmentation.
2//!
3//! Both functions have zero IO and work on any platform.
4//! `word_wrap_bytes` depends on [`crate::rendering::text_render`] for script
5//! detection and width measurement, and [`crate::html_text::Line`] for output.
6
7use crate::html_text;
8use crate::rendering::text_render;
9
10pub fn word_wrap_bytes(text: &str, max_w: usize, px: f32) -> Vec<html_text::Line> {
11    let script = text_render::detect_script(text);
12    if !script.uses_word_spacing() {
13        return word_wrap_char_based(text, max_w, px);
14    }
15    word_wrap_word_based(text, max_w, px)
16}
17
18fn word_wrap_char_based(text: &str, max_w: usize, px: f32) -> Vec<html_text::Line> {
19    let mut out = Vec::new();
20    let max_wf = (max_w.saturating_sub(12)) as f32;
21    let mut line_text = String::new();
22    let mut line_w: f32 = 0.0;
23    let mut byte_start = 0usize;
24
25    for (byte_off, ch) in text.char_indices() {
26        let ch_w = text_render::word_width(&ch.to_string(), px);
27        if line_w + ch_w > max_wf && !line_text.is_empty() {
28            out.push(html_text::Line {
29                text: std::mem::take(&mut line_text),
30                start: byte_start,
31                end: byte_off,
32                width: line_w,
33            });
34            byte_start = byte_off;
35            line_w = 0.0;
36        }
37        line_text.push(ch);
38        line_w += ch_w;
39    }
40    if !line_text.is_empty() {
41        out.push(html_text::Line {
42            text: line_text,
43            start: byte_start,
44            end: text.len(),
45            width: line_w,
46        });
47    }
48    out
49}
50
51fn word_wrap_word_based(text: &str, max_w: usize, px: f32) -> Vec<html_text::Line> {
52    let mut out = Vec::new();
53    let n = text.len();
54    let max_wf = (max_w - 12) as f32;
55    let mut line_start = 0usize;
56    let mut line_text = String::new();
57    let mut line_w: f32 = 0.0;
58    let mut i = 0usize;
59    while i < n {
60        while i < n && text[i..].chars().next().is_some_and(|c| c.is_whitespace()) {
61            i += text[i..].chars().next().map_or(0, |c| c.len_utf8());
62        }
63        if i >= n {
64            break;
65        }
66        let word_start = i;
67        while i < n && !text[i..].chars().next().is_some_and(|c| c.is_whitespace()) {
68            i += text[i..].chars().next().map_or(0, |c| c.len_utf8());
69        }
70        let word = &text[word_start..i];
71        let ww = text_render::word_width(word, px);
72        if !line_text.is_empty() && line_w + 8.0 + ww > max_wf {
73            out.push(html_text::Line {
74                text: line_text,
75                start: line_start,
76                end: word_start,
77                width: line_w,
78            });
79            line_start = word_start;
80            line_text = String::new();
81            line_w = 0.0;
82        }
83        if ww > max_wf && line_text.is_empty() {
84            let mut ci = 0usize;
85            while ci < word.len() {
86                let Some(ch) = word[ci..].chars().next() else {
87                    break;
88                };
89                let ch_w = text_render::word_width(&ch.to_string(), px);
90                if line_w + ch_w > max_wf && !line_text.is_empty() {
91                    out.push(html_text::Line {
92                        text: line_text.clone(),
93                        start: line_start,
94                        end: word_start + ci,
95                        width: line_w,
96                    });
97                    line_start = word_start + ci;
98                    line_text = String::new();
99                    line_w = 0.0;
100                }
101                line_text.push(ch);
102                line_w += ch_w;
103                ci += ch.len_utf8();
104            }
105        } else if line_text.is_empty() {
106            line_text.push_str(word);
107            line_w = ww;
108        } else {
109            line_text.push(' ');
110            line_text.push_str(word);
111            line_w += 8.0 + ww;
112        }
113    }
114    if !line_text.is_empty() {
115        out.push(html_text::Line {
116            text: line_text,
117            start: line_start,
118            end: n,
119            width: line_w,
120        });
121    }
122    out
123}
124
125const SENTENCE_ABBREVIATIONS: &[&str] = &[
126    "mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "vs", "etc", "inc", "ltd", "co", "corp",
127    "vol", "fig", "dept", "est", "approx", "e.g", "i.e", "u.s", "u.k", "ph.d", "a.m", "p.m",
128];
129
130pub fn sentences_with_ranges(text: &str) -> Vec<(String, usize, usize)> {
131    let chars: Vec<(usize, char)> = text.char_indices().collect();
132    let n = chars.len();
133    let mut out = Vec::new();
134    let mut chunk_start = 0usize;
135    let mut ci = 0usize;
136    while ci < n {
137        let (byte_idx, ch) = chars[ci];
138        if is_sentence_terminator(ch) && is_real_sentence_end(&chars, ci) {
139            let end = byte_idx + ch.len_utf8();
140            push_sentence(&mut out, text, chunk_start, end);
141            chunk_start = end;
142        }
143        ci += 1;
144    }
145    push_sentence(&mut out, text, chunk_start, text.len());
146    out
147}
148
149fn is_sentence_terminator(ch: char) -> bool {
150    matches!(
151        ch,
152        '.' | '!' | '?' | '\u{0964}' | '\u{0965}' | '\u{3002}' | '\u{FF01}' | '\u{FF1F}'
153    )
154}
155
156fn push_sentence(
157    out: &mut Vec<(String, usize, usize)>,
158    text: &str,
159    start_byte: usize,
160    end_byte: usize,
161) {
162    let end_byte = end_byte.min(text.len());
163    if start_byte >= end_byte {
164        return;
165    }
166    let chunk = &text[start_byte..end_byte];
167    let trimmed = chunk.trim();
168    if trimmed.is_empty() {
169        return;
170    }
171    let off = chunk.find(trimmed).unwrap_or(0);
172    let start = start_byte + off;
173    out.push((trimmed.to_string(), start, start + trimmed.len()));
174}
175
176fn char_at(chars: &[(usize, char)], i: isize) -> Option<char> {
177    if i < 0 || (i as usize) >= chars.len() {
178        None
179    } else {
180        Some(chars[i as usize].1)
181    }
182}
183
184fn next_non_space(chars: &[(usize, char)], from: usize) -> Option<char> {
185    let mut j = from;
186    while j < chars.len() {
187        let c = chars[j].1;
188        if !c.is_whitespace() {
189            return Some(c);
190        }
191        j += 1;
192    }
193    None
194}
195
196fn abbrev_token_before(chars: &[(usize, char)], i: usize) -> String {
197    let mut start = i;
198    while start > 0 && chars[start - 1].1.is_ascii_alphanumeric() {
199        start -= 1;
200    }
201    if start > 0 && chars[start - 1].1 == '.' {
202        let mut s2 = start - 1;
203        while s2 > 0 && chars[s2 - 1].1.is_ascii_alphanumeric() {
204            s2 -= 1;
205        }
206        if s2 < start - 1 {
207            start = s2;
208        }
209    }
210    chars[start..i]
211        .iter()
212        .map(|(_, c)| c.to_ascii_lowercase())
213        .collect()
214}
215
216fn is_real_sentence_end(chars: &[(usize, char)], i: usize) -> bool {
217    let ch = chars[i].1;
218    let prev = char_at(chars, (i as isize) - 1);
219    let next = char_at(chars, (i as isize) + 1);
220
221    if matches!(ch, '\u{3002}' | '\u{FF01}' | '\u{FF1F}') {
222        return true;
223    }
224
225    if matches!(ch, '!' | '?' | '\u{0964}' | '\u{0965}') {
226        return next.is_none()
227            | matches!(next, Some(c) if c.is_whitespace())
228            | matches!(next, Some('"') | Some(')') | Some(']') | Some('\''));
229    }
230
231    if prev == Some('.') || next == Some('.') {
232        return false;
233    }
234    if matches!(prev, Some(c) if c.is_ascii_digit()) {
235        return false;
236    }
237    if matches!(prev, Some(c) if c.is_ascii_uppercase()) {
238        if !matches!(char_at(chars, (i as isize) - 2), Some(c) if c.is_ascii_alphanumeric()) {
239            return false;
240        }
241    }
242    if SENTENCE_ABBREVIATIONS.contains(&abbrev_token_before(chars, i).as_str()) {
243        return false;
244    }
245    match next_non_space(chars, i + 1) {
246        None => true,
247        Some(c) if c.is_ascii_uppercase() => true,
248        Some(')') | Some('"') | Some(']') | Some('\'') => true,
249        _ => false,
250    }
251}