1use 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}