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 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
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 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
198pub 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
238pub 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
248pub 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
315fn 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
334fn 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 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 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};