Skip to main content

kobo_core/rendering/
layout.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Nayeem Bin Ahsan
3//! Pure text-layout functions: word wrapping and sentence segmentation.
4//!
5//! Both functions have zero IO and work on any platform.
6//! `word_wrap_bytes` depends on [`crate::rendering::text_render`] for script
7//! detection and width measurement, and [`crate::html_text::Line`] for output.
8
9use crate::html_text;
10use crate::rendering::text_render;
11
12pub fn word_wrap_bytes(text: &str, max_w: usize, px: f32) -> Vec<html_text::Line> {
13    let script = text_render::detect_script(text);
14    if !script.uses_word_spacing() {
15        return word_wrap_char_based(text, max_w, px);
16    }
17    word_wrap_word_based(text, max_w, px)
18}
19
20/// Like `word_wrap_bytes` but reserves `first_indent` px on the first line for a
21/// paragraph indent. Only word-spacing scripts indent; the rest wrap unchanged.
22pub fn word_wrap_indent(
23    text: &str,
24    max_w: usize,
25    first_indent: usize,
26    px: f32,
27) -> Vec<html_text::Line> {
28    let script = text_render::detect_script(text);
29    if !script.uses_word_spacing() {
30        return word_wrap_char_based(text, max_w, px);
31    }
32    word_wrap_word_based_indent(text, max_w, first_indent, px)
33}
34
35/// Character-granular wrap that preserves every space and tab. Used for `<pre>`
36/// code, where indentation is meaningful and words must not be reflowed, and
37/// internally for scripts that do not separate words with spaces.
38pub fn word_wrap_char_based(text: &str, max_w: usize, px: f32) -> Vec<html_text::Line> {
39    word_wrap_char_based_styled(text, max_w, px, text_render::TextStyle::PLAIN)
40}
41
42/// As [`word_wrap_char_based`], measured in `style`. Code wraps in the
43/// monospace face, whose advances are wider than the proportional body face --
44/// measuring with the wrong one overfills every line.
45pub fn word_wrap_char_based_styled(
46    text: &str,
47    max_w: usize,
48    px: f32,
49    style: text_render::TextStyle,
50) -> Vec<html_text::Line> {
51    let mut out = Vec::new();
52    let max_wf = (max_w.saturating_sub(12)) as f32;
53    let mut line_text = String::new();
54    let mut line_w: f32 = 0.0;
55    let mut byte_start = 0usize;
56
57    for (byte_off, ch) in text.char_indices() {
58        let ch_w = text_render::word_width_styled(&ch.to_string(), px, style);
59        if line_w + ch_w > max_wf && !line_text.is_empty() {
60            out.push(html_text::Line {
61                text: std::mem::take(&mut line_text),
62                start: byte_start,
63                end: byte_off,
64                width: line_w,
65            });
66            byte_start = byte_off;
67            line_w = 0.0;
68        }
69        line_text.push(ch);
70        line_w += ch_w;
71    }
72    if !line_text.is_empty() {
73        out.push(html_text::Line {
74            text: line_text,
75            start: byte_start,
76            end: text.len(),
77            width: line_w,
78        });
79    }
80    out
81}
82
83fn word_wrap_word_based(text: &str, max_w: usize, px: f32) -> Vec<html_text::Line> {
84    word_wrap_word_based_indent(text, max_w, 0, px)
85}
86
87/// Word-wrap where the first line is narrower by `first_indent` px (for a
88/// first-line paragraph indent), so the indented line still fits the column.
89fn word_wrap_word_based_indent(
90    text: &str,
91    max_w: usize,
92    first_indent: usize,
93    px: f32,
94) -> Vec<html_text::Line> {
95    let mut out = Vec::new();
96    let n = text.len();
97    let full_wf = (max_w - 12) as f32;
98    let mut line_start = 0usize;
99    let mut line_text = String::new();
100    let mut line_w: f32 = 0.0;
101    let mut i = 0usize;
102    while i < n {
103        // First line (nothing pushed yet) reserves the indent.
104        let max_wf = if out.is_empty() {
105            full_wf - first_indent as f32
106        } else {
107            full_wf
108        };
109        while i < n && text[i..].chars().next().is_some_and(|c| c.is_whitespace()) {
110            i += text[i..].chars().next().map_or(0, |c| c.len_utf8());
111        }
112        if i >= n {
113            break;
114        }
115        let word_start = i;
116        while i < n && !text[i..].chars().next().is_some_and(|c| c.is_whitespace()) {
117            i += text[i..].chars().next().map_or(0, |c| c.len_utf8());
118        }
119        let word = &text[word_start..i];
120        let ww = text_render::word_width(word, px);
121        if !line_text.is_empty() && line_w + 8.0 + ww > max_wf {
122            out.push(html_text::Line {
123                text: line_text,
124                start: line_start,
125                end: word_start,
126                width: line_w,
127            });
128            line_start = word_start;
129            line_text = String::new();
130            line_w = 0.0;
131        }
132        if ww > max_wf && line_text.is_empty() {
133            let mut ci = 0usize;
134            while ci < word.len() {
135                let Some(ch) = word[ci..].chars().next() else {
136                    break;
137                };
138                let ch_w = text_render::word_width(&ch.to_string(), px);
139                if line_w + ch_w > max_wf && !line_text.is_empty() {
140                    out.push(html_text::Line {
141                        text: line_text.clone(),
142                        start: line_start,
143                        end: word_start + ci,
144                        width: line_w,
145                    });
146                    line_start = word_start + ci;
147                    line_text = String::new();
148                    line_w = 0.0;
149                }
150                line_text.push(ch);
151                line_w += ch_w;
152                ci += ch.len_utf8();
153            }
154        } else if line_text.is_empty() {
155            line_text.push_str(word);
156            line_w = ww;
157        } else {
158            line_text.push(' ');
159            line_text.push_str(word);
160            line_w += 8.0 + ww;
161        }
162    }
163    if !line_text.is_empty() {
164        out.push(html_text::Line {
165            text: line_text,
166            start: line_start,
167            end: n,
168            width: line_w,
169        });
170    }
171    out
172}
173
174const SENTENCE_ABBREVIATIONS: &[&str] = &[
175    "mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "vs", "etc", "inc", "ltd", "co", "corp",
176    "vol", "fig", "dept", "est", "approx", "e.g", "i.e", "u.s", "u.k", "ph.d", "a.m", "p.m",
177];
178
179pub fn sentences_with_ranges(text: &str) -> Vec<(String, usize, usize)> {
180    let chars: Vec<(usize, char)> = text.char_indices().collect();
181    let n = chars.len();
182    let mut out = Vec::new();
183    let mut chunk_start = 0usize;
184    let mut ci = 0usize;
185    while ci < n {
186        let (byte_idx, ch) = chars[ci];
187        if is_sentence_terminator(ch) && is_real_sentence_end(&chars, ci) {
188            let end = byte_idx + ch.len_utf8();
189            push_sentence(&mut out, text, chunk_start, end);
190            chunk_start = end;
191        }
192        ci += 1;
193    }
194    push_sentence(&mut out, text, chunk_start, text.len());
195    out
196}
197
198/// Paginate row heights into pages that fit within `content_h` px.
199///
200/// Returns `(start, end)` row-index ranges, one per page. A heading that would
201/// land at the very bottom of a page is pushed to the next page instead so it
202/// is not orphaned from the body it introduces.
203pub fn paginate_heights(
204    heights: &[i32],
205    content_h: i32,
206    heading_indices: &[usize],
207) -> Vec<(usize, usize)> {
208    let heading_set: std::collections::HashSet<usize> = heading_indices.iter().copied().collect();
209    let mut pages = Vec::new();
210    let n = heights.len();
211    let mut i = 0usize;
212    while i < n {
213        let start = i;
214        let mut h = 0i32;
215        while i < n {
216            let rh = heights[i];
217            if h + rh > content_h && i > start {
218                break;
219            }
220            h += rh;
221            i += 1;
222        }
223        let page_end = i;
224        if page_end < n {
225            let last = page_end - 1;
226            if heading_set.contains(&last) && i < n && !heading_set.contains(&i) {
227                i = last;
228            }
229        }
230        pages.push((start, i));
231    }
232    if pages.is_empty() {
233        pages.push((0, 0));
234    }
235    pages
236}
237
238/// Clamp a page index into `[0, page_count-1]`. An empty chapter (0 pages)
239/// maps any request to 0.
240pub fn clamp_page(page: usize, page_count: usize) -> usize {
241    if page_count == 0 {
242        0
243    } else {
244        page.min(page_count - 1)
245    }
246}
247
248/// Map a progress-bar per-mille (0..1000) to a `(chapter, local_offset)` pair
249/// using the cumulative chapter-offset table.
250pub fn resolve_progress_target(
251    per_mille: i32,
252    chapter_offsets: &[usize],
253    chapter_count: usize,
254) -> (usize, usize) {
255    let total = (*chapter_offsets.last().unwrap_or(&1)).max(1);
256    let global = (per_mille as usize * total) / 1000;
257    let mut c = 0usize;
258    while c + 1 < chapter_offsets.len() && chapter_offsets[c + 1] <= global {
259        c += 1;
260    }
261    if c >= chapter_count {
262        c = chapter_count.saturating_sub(1);
263    }
264    let local = global.saturating_sub(*chapter_offsets.get(c).unwrap_or(&0));
265    (c, local)
266}
267
268fn is_sentence_terminator(ch: char) -> bool {
269    matches!(
270        ch,
271        '.' | '!' | '?' | '\u{0964}' | '\u{0965}' | '\u{3002}' | '\u{FF01}' | '\u{FF1F}'
272    )
273}
274
275fn push_sentence(
276    out: &mut Vec<(String, usize, usize)>,
277    text: &str,
278    start_byte: usize,
279    end_byte: usize,
280) {
281    let end_byte = end_byte.min(text.len());
282    if start_byte >= end_byte {
283        return;
284    }
285    let chunk = &text[start_byte..end_byte];
286    let trimmed = chunk.trim();
287    if trimmed.is_empty() {
288        return;
289    }
290    let off = chunk.find(trimmed).unwrap_or(0);
291    let start = start_byte + off;
292    out.push((trimmed.to_string(), start, start + trimmed.len()));
293}
294
295fn char_at(chars: &[(usize, char)], i: isize) -> Option<char> {
296    if i < 0 || (i as usize) >= chars.len() {
297        None
298    } else {
299        Some(chars[i as usize].1)
300    }
301}
302
303fn next_non_space(chars: &[(usize, char)], from: usize) -> Option<char> {
304    let mut j = from;
305    while j < chars.len() {
306        let c = chars[j].1;
307        if !c.is_whitespace() {
308            return Some(c);
309        }
310        j += 1;
311    }
312    None
313}
314
315/// True when a newline separates this position from the next visible text.
316///
317/// Chapter bodies join block elements with `\n`, so a terminator followed by a
318/// newline is always a paragraph break and therefore always a sentence end.
319fn newline_before_next(chars: &[(usize, char)], from: usize) -> bool {
320    let mut j = from;
321    while j < chars.len() {
322        let c = chars[j].1;
323        if c == '\n' {
324            return true;
325        }
326        if !c.is_whitespace() {
327            return false;
328        }
329        j += 1;
330    }
331    false
332}
333
334/// Characters that may legitimately follow a sentence terminator: ASCII closers
335/// plus the typographic quotes ebooks actually use. Accepting only the ASCII
336/// forms merged every `... .` + `'Dialogue'` pair into one run-on utterance.
337fn is_closer_or_quote(c: char) -> bool {
338    matches!(
339        c,
340        ')' | '"'
341            | ']'
342            | '\''
343            | '\u{2018}'
344            | '\u{2019}'
345            | '\u{201C}'
346            | '\u{201D}'
347            | '\u{00AB}'
348            | '\u{00BB}'
349    )
350}
351
352fn abbrev_token_before(chars: &[(usize, char)], i: usize) -> String {
353    let mut start = i;
354    while start > 0 && chars[start - 1].1.is_ascii_alphanumeric() {
355        start -= 1;
356    }
357    if start > 0 && chars[start - 1].1 == '.' {
358        let mut s2 = start - 1;
359        while s2 > 0 && chars[s2 - 1].1.is_ascii_alphanumeric() {
360            s2 -= 1;
361        }
362        if s2 < start - 1 {
363            start = s2;
364        }
365    }
366    chars[start..i]
367        .iter()
368        .map(|(_, c)| c.to_ascii_lowercase())
369        .collect()
370}
371
372fn is_real_sentence_end(chars: &[(usize, char)], i: usize) -> bool {
373    let ch = chars[i].1;
374    let prev = char_at(chars, (i as isize) - 1);
375    let next = char_at(chars, (i as isize) + 1);
376
377    if matches!(ch, '\u{3002}' | '\u{FF01}' | '\u{FF1F}') {
378        return true;
379    }
380
381    // A paragraph break always ends a sentence, whatever precedes it. Without
382    // this, a paragraph closing on `.` before a line that opens with a quote
383    // runs on into the following paragraphs, building a single enormous
384    // utterance that no TTS timeout can ever synthesize.
385    if newline_before_next(chars, i + 1) {
386        return true;
387    }
388
389    if matches!(ch, '!' | '?' | '\u{0964}' | '\u{0965}') {
390        return next.is_none()
391            | matches!(next, Some(c) if c.is_whitespace())
392            | matches!(next, Some(c) if is_closer_or_quote(c));
393    }
394
395    if prev == Some('.') || next == Some('.') {
396        return false;
397    }
398    if matches!(prev, Some(c) if c.is_ascii_digit()) {
399        return false;
400    }
401    if matches!(prev, Some(c) if c.is_ascii_uppercase()) {
402        if !matches!(char_at(chars, (i as isize) - 2), Some(c) if c.is_ascii_alphanumeric()) {
403            return false;
404        }
405    }
406    if SENTENCE_ABBREVIATIONS.contains(&abbrev_token_before(chars, i).as_str()) {
407        return false;
408    }
409    match next_non_space(chars, i + 1) {
410        None => true,
411        // `is_uppercase`, not `is_ascii_uppercase`: accented capitals open
412        // sentences too.
413        Some(c) if c.is_uppercase() => true,
414        Some(c) if is_closer_or_quote(c) => true,
415        _ => false,
416    }
417}
418
419mod pagination;
420#[cfg(test)]
421mod tests;
422
423pub use pagination::{
424    block_indent_for, count_chapter_pages, estimate_chapter_offsets, ScreenLayout,
425};