1use 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
20pub 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
35pub 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
42pub 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
87fn 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 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 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
209pub 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
249pub 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
259pub 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
337fn 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
356fn 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 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 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};