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    let widths = text_render::char_widths_batch(text, px, style);
58    for ((byte_off, ch), ch_w) in text.char_indices().zip(widths.iter().copied()) {
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        // A newline is a hard paragraph boundary: block elements are joined with
188        // '\n', so it always ends an utterance even when a paragraph carries no
189        // terminal punctuation (Thai/Lao prose, a heading, or a caption). Without
190        // this, such paragraphs run together into one mega-utterance spanning
191        // several scripts, and detect_script on the blob picks the wrong voice.
192        if ch == '\n' {
193            push_sentence(&mut out, text, chunk_start, byte_idx);
194            chunk_start = byte_idx + 1;
195            ci += 1;
196            continue;
197        }
198        if is_sentence_terminator(ch) && is_real_sentence_end(&chars, ci) {
199            let end = byte_idx + ch.len_utf8();
200            push_sentence(&mut out, text, chunk_start, end);
201            chunk_start = end;
202        }
203        ci += 1;
204    }
205    push_sentence(&mut out, text, chunk_start, text.len());
206    out
207}
208
209/// Paginate row heights into pages that fit within `content_h` px.
210///
211/// Returns `(start, end)` row-index ranges, one per page. A heading that would
212/// land at the very bottom of a page is pushed to the next page instead so it
213/// is not orphaned from the body it introduces.
214pub fn paginate_heights(
215    heights: &[i32],
216    content_h: i32,
217    heading_indices: &[usize],
218) -> Vec<(usize, usize)> {
219    let heading_set: std::collections::HashSet<usize> = heading_indices.iter().copied().collect();
220    let mut pages = Vec::new();
221    let n = heights.len();
222    let mut i = 0usize;
223    while i < n {
224        let start = i;
225        let mut h = 0i32;
226        while i < n {
227            let rh = heights[i];
228            if h + rh > content_h && i > start {
229                break;
230            }
231            h += rh;
232            i += 1;
233        }
234        let page_end = i;
235        if page_end < n {
236            let last = page_end - 1;
237            if heading_set.contains(&last) && i < n && !heading_set.contains(&i) {
238                i = last;
239            }
240        }
241        pages.push((start, i));
242    }
243    if pages.is_empty() {
244        pages.push((0, 0));
245    }
246    pages
247}
248
249/// Clamp a page index into `[0, page_count-1]`. An empty chapter (0 pages)
250/// maps any request to 0.
251pub fn clamp_page(page: usize, page_count: usize) -> usize {
252    if page_count == 0 {
253        0
254    } else {
255        page.min(page_count - 1)
256    }
257}
258
259/// Map a progress-bar per-mille (0..1000) to a `(chapter, local_offset)` pair
260/// using the cumulative chapter-offset table.
261pub fn resolve_progress_target(
262    per_mille: i32,
263    chapter_offsets: &[usize],
264    chapter_count: usize,
265) -> (usize, usize) {
266    let total = (*chapter_offsets.last().unwrap_or(&1)).max(1);
267    let global = (per_mille as usize * total) / 1000;
268    let mut c = 0usize;
269    while c + 1 < chapter_offsets.len() && chapter_offsets[c + 1] <= global {
270        c += 1;
271    }
272    if c >= chapter_count {
273        c = chapter_count.saturating_sub(1);
274    }
275    let local = global.saturating_sub(*chapter_offsets.get(c).unwrap_or(&0));
276    (c, local)
277}
278
279fn is_sentence_terminator(ch: char) -> bool {
280    matches!(
281        ch,
282        '.' | '!'
283            | '?'
284            | '\u{0964}'
285            | '\u{0965}'
286            | '\u{3002}'
287            | '\u{FF01}'
288            | '\u{FF1F}'
289            | '\u{17D4}'
290            | '\u{17D5}'
291            | '\u{104A}'
292            | '\u{104B}'
293            | '\u{1362}'
294    )
295}
296
297fn push_sentence(
298    out: &mut Vec<(String, usize, usize)>,
299    text: &str,
300    start_byte: usize,
301    end_byte: usize,
302) {
303    let end_byte = end_byte.min(text.len());
304    if start_byte >= end_byte {
305        return;
306    }
307    let chunk = &text[start_byte..end_byte];
308    let trimmed = chunk.trim();
309    if trimmed.is_empty() {
310        return;
311    }
312    let off = chunk.find(trimmed).unwrap_or(0);
313    let start = start_byte + off;
314    out.push((trimmed.to_string(), start, start + trimmed.len()));
315}
316
317fn char_at(chars: &[(usize, char)], i: isize) -> Option<char> {
318    if i < 0 || (i as usize) >= chars.len() {
319        None
320    } else {
321        Some(chars[i as usize].1)
322    }
323}
324
325fn next_non_space(chars: &[(usize, char)], from: usize) -> Option<char> {
326    let mut j = from;
327    while j < chars.len() {
328        let c = chars[j].1;
329        if !c.is_whitespace() {
330            return Some(c);
331        }
332        j += 1;
333    }
334    None
335}
336
337/// True when a newline separates this position from the next visible text.
338///
339/// Chapter bodies join block elements with `\n`, so a terminator followed by a
340/// newline is always a paragraph break and therefore always a sentence end.
341fn newline_before_next(chars: &[(usize, char)], from: usize) -> bool {
342    let mut j = from;
343    while j < chars.len() {
344        let c = chars[j].1;
345        if c == '\n' {
346            return true;
347        }
348        if !c.is_whitespace() {
349            return false;
350        }
351        j += 1;
352    }
353    false
354}
355
356/// Characters that may legitimately follow a sentence terminator: ASCII closers
357/// plus the typographic quotes ebooks actually use. Accepting only the ASCII
358/// forms merged every `... .` + `'Dialogue'` pair into one run-on utterance.
359fn is_closer_or_quote(c: char) -> bool {
360    matches!(
361        c,
362        ')' | '"'
363            | ']'
364            | '\''
365            | '\u{2018}'
366            | '\u{2019}'
367            | '\u{201C}'
368            | '\u{201D}'
369            | '\u{00AB}'
370            | '\u{00BB}'
371    )
372}
373
374fn abbrev_token_before(chars: &[(usize, char)], i: usize) -> String {
375    let mut start = i;
376    while start > 0 && chars[start - 1].1.is_ascii_alphanumeric() {
377        start -= 1;
378    }
379    if start > 0 && chars[start - 1].1 == '.' {
380        let mut s2 = start - 1;
381        while s2 > 0 && chars[s2 - 1].1.is_ascii_alphanumeric() {
382            s2 -= 1;
383        }
384        if s2 < start - 1 {
385            start = s2;
386        }
387    }
388    chars[start..i]
389        .iter()
390        .map(|(_, c)| c.to_ascii_lowercase())
391        .collect()
392}
393
394fn is_real_sentence_end(chars: &[(usize, char)], i: usize) -> bool {
395    let ch = chars[i].1;
396    let prev = char_at(chars, (i as isize) - 1);
397    let next = char_at(chars, (i as isize) + 1);
398
399    if matches!(
400        ch,
401        '\u{3002}'
402            | '\u{FF01}'
403            | '\u{FF1F}'
404            | '\u{17D4}'
405            | '\u{17D5}'
406            | '\u{104A}'
407            | '\u{104B}'
408            | '\u{1362}'
409    ) {
410        return true;
411    }
412
413    // A paragraph break always ends a sentence, whatever precedes it. Without
414    // this, a paragraph closing on `.` before a line that opens with a quote
415    // runs on into the following paragraphs, building a single enormous
416    // utterance that no TTS timeout can ever synthesize.
417    if newline_before_next(chars, i + 1) {
418        return true;
419    }
420
421    if matches!(ch, '!' | '?' | '\u{0964}' | '\u{0965}') {
422        return next.is_none()
423            | matches!(next, Some(c) if c.is_whitespace())
424            | matches!(next, Some(c) if is_closer_or_quote(c));
425    }
426
427    if prev == Some('.') || next == Some('.') {
428        return false;
429    }
430    if matches!(prev, Some(c) if c.is_ascii_digit()) {
431        return false;
432    }
433    if matches!(prev, Some(c) if c.is_ascii_uppercase()) {
434        if !matches!(char_at(chars, (i as isize) - 2), Some(c) if c.is_ascii_alphanumeric()) {
435            return false;
436        }
437    }
438    if SENTENCE_ABBREVIATIONS.contains(&abbrev_token_before(chars, i).as_str()) {
439        return false;
440    }
441    match next_non_space(chars, i + 1) {
442        None => true,
443        // `is_uppercase`, not `is_ascii_uppercase`: accented capitals open
444        // sentences too.
445        Some(c) if c.is_uppercase() => true,
446        Some(c) if is_closer_or_quote(c) => true,
447        _ => false,
448    }
449}
450
451mod pagination;
452#[cfg(test)]
453mod tests;
454
455pub use pagination::{
456    block_indent_for, count_chapter_pages, estimate_chapter_offsets, ScreenLayout,
457};