1use super::{
6 DeterministicTextMeasurer, TextMeasurer, TextStyle, trim_end_html_collapsible_ascii_whitespace,
7 trim_html_collapsible_ascii_whitespace,
8};
9use unicode_segmentation::UnicodeSegmentation;
10
11pub fn ceil_to_1_64_px(v: f64) -> f64 {
12 if !(v.is_finite() && v >= 0.0) {
13 return 0.0;
14 }
15 let x = v * 64.0;
18 let r = x.round();
19 if (x - r).abs() < 1e-4 {
20 return r / 64.0;
21 }
22 ((x) - 1e-5).ceil() / 64.0
23}
24
25pub fn round_to_1_64_px(v: f64) -> f64 {
26 if !(v.is_finite() && v >= 0.0) {
27 return 0.0;
28 }
29 let x = v * 64.0;
30 let r = (x + 0.5).floor();
31 r / 64.0
32}
33
34pub fn round_to_1_64_px_ties_to_even(v: f64) -> f64 {
35 if !(v.is_finite() && v >= 0.0) {
36 return 0.0;
37 }
38 let x = v * 64.0;
39 let f = x.floor();
40 let frac = x - f;
41 let i = if frac < 0.5 {
42 f
43 } else if frac > 0.5 {
44 f + 1.0
45 } else {
46 let fi = f as i64;
47 if fi % 2 == 0 { f } else { f + 1.0 }
48 };
49 let out = i / 64.0;
50 if out == -0.0 { 0.0 } else { out }
51}
52
53pub(super) fn split_graphemes_to_width<E>(
55 token: &str,
56 max_width_px: f64,
57 mut measure_candidate: impl FnMut(&str, &str) -> Result<f64, E>,
58) -> Result<(&str, &str), E> {
59 let mut split_at = 0;
60 let mut width_px = 0.0;
61 let mut has_positive_advance = false;
62
63 for (index, grapheme) in token.grapheme_indices(true) {
64 let candidate_end = index + grapheme.len();
65 let candidate_width_px = measure_candidate(&token[..candidate_end], grapheme)?;
66 if has_positive_advance
67 && candidate_width_px > width_px
68 && candidate_width_px > max_width_px
69 {
70 break;
71 }
72
73 split_at = candidate_end;
74 width_px = candidate_width_px;
75 has_positive_advance |= candidate_width_px > 0.0;
76 }
77
78 if split_at == 0 {
81 split_at = token.graphemes(true).next().map_or(0, str::len);
82 }
83 Ok(token.split_at(split_at))
84}
85
86pub fn wrap_text_lines_measurer(
92 text: &str,
93 measurer: &dyn TextMeasurer,
94 style: &TextStyle,
95 max_width_px: Option<f64>,
96) -> Vec<String> {
97 fn wrap_line(
98 line: &str,
99 measurer: &dyn TextMeasurer,
100 style: &TextStyle,
101 max_width_px: f64,
102 ) -> Vec<String> {
103 use std::collections::VecDeque;
104
105 if !max_width_px.is_finite() || max_width_px <= 0.0 {
106 return vec![line.to_string()];
107 }
108
109 let mut tokens = VecDeque::from(DeterministicTextMeasurer::split_line_to_words(line));
110 let mut out: Vec<String> = Vec::new();
111 let mut cur = String::new();
112
113 while let Some(tok) = tokens.pop_front() {
114 if cur.is_empty() && tok == " " {
115 continue;
116 }
117
118 let candidate = format!("{cur}{tok}");
119 if measurer
120 .measure(
121 trim_end_html_collapsible_ascii_whitespace(&candidate),
122 style,
123 )
124 .width
125 <= max_width_px
126 {
127 cur = candidate;
128 continue;
129 }
130
131 if !trim_html_collapsible_ascii_whitespace(&cur).is_empty() {
132 out.push(trim_end_html_collapsible_ascii_whitespace(&cur).to_string());
133 cur.clear();
134 tokens.push_front(tok);
135 continue;
136 }
137
138 if tok == " " {
139 continue;
140 }
141
142 let (head, tail) = match split_graphemes_to_width(&tok, max_width_px, |candidate, _| {
143 Ok::<_, std::convert::Infallible>(measurer.measure(candidate, style).width)
144 }) {
145 Ok(parts) => parts,
146 Err(error) => match error {},
147 };
148 out.push(head.to_string());
149 if !tail.is_empty() {
150 tokens.push_front(tail.to_string());
151 }
152 }
153
154 if !trim_html_collapsible_ascii_whitespace(&cur).is_empty() {
155 out.push(trim_end_html_collapsible_ascii_whitespace(&cur).to_string());
156 }
157
158 if out.is_empty() {
159 vec!["".to_string()]
160 } else {
161 out
162 }
163 }
164
165 let mut out: Vec<String> = Vec::new();
166 for line in split_html_br_lines(text) {
167 if let Some(w) = max_width_px {
168 out.extend(wrap_line(line, measurer, style, w));
169 } else {
170 out.push(line.to_string());
171 }
172 }
173 if out.is_empty() {
174 vec!["".to_string()]
175 } else {
176 out
177 }
178}
179
180pub fn split_html_br_lines(text: &str) -> Vec<&str> {
186 let b = text.as_bytes();
187 let mut parts: Vec<&str> = Vec::new();
188 let mut start = 0usize;
189 let mut i = 0usize;
190 while i + 3 < b.len() {
191 if b[i] != b'<' {
192 i += 1;
193 continue;
194 }
195 let b1 = b[i + 1];
196 let b2 = b[i + 2];
197 if !matches!(b1, b'b' | b'B') || !matches!(b2, b'r' | b'R') {
198 i += 1;
199 continue;
200 }
201 let mut j = i + 3;
202 while j < b.len() && matches!(b[j], b' ' | b'\t' | b'\r' | b'\n') {
203 j += 1;
204 }
205 if j < b.len() && b[j] == b'/' {
206 j += 1;
207 }
208 if j < b.len() && b[j] == b'>' {
209 parts.push(&text[start..i]);
210 start = j + 1;
211 i = start;
212 continue;
213 }
214 i += 1;
215 }
216 parts.push(&text[start..]);
217 parts
218}
219
220fn try_wrap_label_like_mermaid_lines<E>(
221 label: &str,
222 measurer: &dyn TextMeasurer,
223 style: &TextStyle,
224 max_width_px: f64,
225 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
226) -> std::result::Result<Vec<String>, E> {
227 checkpoint()?;
228 if label.is_empty() {
229 return Ok(Vec::new());
230 }
231 if !max_width_px.is_finite() || max_width_px <= 0.0 {
232 return Ok(vec![label.to_string()]);
233 }
234
235 let explicit_lines = split_html_br_lines(label);
237 if explicit_lines.len() > 1 {
238 let mut lines = Vec::with_capacity(explicit_lines.len());
239 for line in explicit_lines {
240 checkpoint()?;
241 lines.push(line.to_string());
242 }
243 checkpoint()?;
244 return Ok(lines);
245 }
246 fn w_px<E>(
247 measurer: &dyn TextMeasurer,
248 style: &TextStyle,
249 s: &str,
250 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
251 ) -> std::result::Result<f64, E> {
252 checkpoint()?;
254 let width = measurer
255 .measure_svg_simple_text_bbox_width_for_wrap_px(s, style)
256 .round();
257 checkpoint()?;
258 Ok(width)
259 }
260
261 fn break_string_like_mermaid<E>(
262 word: &str,
263 max_width_px: f64,
264 measurer: &dyn TextMeasurer,
265 style: &TextStyle,
266 checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
267 ) -> std::result::Result<(Vec<String>, String), E> {
268 let chars: Vec<char> = word.chars().collect();
269 let mut lines: Vec<String> = Vec::new();
270 let mut current = String::new();
271 for (idx, ch) in chars.iter().enumerate() {
272 checkpoint()?;
273 let next_line = format!("{current}{ch}");
274 let line_w = w_px(measurer, style, &next_line, checkpoint)?;
275 if line_w >= max_width_px {
276 let is_last = idx + 1 == chars.len();
277 if is_last {
278 lines.push(next_line);
279 } else {
280 lines.push(format!("{next_line}-"));
281 }
282 current.clear();
283 } else {
284 current = next_line;
285 }
286 }
287 Ok((lines, current))
288 }
289
290 let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
292 if words.is_empty() {
293 return Ok(vec![label.to_string()]);
294 }
295
296 let mut completed: Vec<String> = Vec::new();
297 let mut next_line = String::new();
298 for (idx, word) in words.iter().enumerate() {
299 checkpoint()?;
300 let word_len = w_px(measurer, style, &format!("{word} "), checkpoint)?;
301 let next_len = w_px(measurer, style, &next_line, checkpoint)?;
302 if word_len > max_width_px {
303 let (hyphenated, remaining) =
304 break_string_like_mermaid(word, max_width_px, measurer, style, checkpoint)?;
305 completed.push(next_line.clone());
306 completed.extend(hyphenated);
307 next_line = remaining;
308 } else if next_len + word_len >= max_width_px {
309 completed.push(next_line.clone());
310 next_line = (*word).to_string();
311 } else if next_line.is_empty() {
312 next_line = (*word).to_string();
313 } else {
314 next_line.push(' ');
315 next_line.push_str(word);
316 }
317
318 let is_last = idx + 1 == words.len();
319 if is_last {
320 completed.push(next_line.clone());
321 }
322 }
323
324 checkpoint()?;
325 Ok(completed.into_iter().filter(|l| !l.is_empty()).collect())
326}
327
328pub fn wrap_label_like_mermaid_lines(
333 label: &str,
334 measurer: &dyn TextMeasurer,
335 style: &TextStyle,
336 max_width_px: f64,
337) -> Vec<String> {
338 let mut checkpoint = || Ok::<(), std::convert::Infallible>(());
339 match try_wrap_label_like_mermaid_lines(label, measurer, style, max_width_px, &mut checkpoint) {
340 Ok(lines) => lines,
341 Err(error) => match error {},
342 }
343}
344
345pub(crate) fn wrap_label_like_mermaid_lines_with_checkpoint(
346 label: &str,
347 measurer: &dyn TextMeasurer,
348 style: &TextStyle,
349 max_width_px: f64,
350 mut checkpoint: impl FnMut() -> crate::Result<()>,
351) -> crate::Result<Vec<String>> {
352 try_wrap_label_like_mermaid_lines(label, measurer, style, max_width_px, &mut checkpoint)
353}
354
355#[cfg(test)]
356mod tests {
357 use super::{split_graphemes_to_width, wrap_text_lines_measurer};
358 use crate::text::{
359 DeterministicTextMeasurer, TextMeasurer, TextStyle, WrapMode, try_measure_wrapped_text,
360 };
361
362 #[test]
363 fn grapheme_splitting_keeps_non_increasing_overwide_candidates() {
364 let mut widths = [0.0, 8.0, 8.0, 7.0, 9.0].into_iter();
365 let parts = split_graphemes_to_width("\u{0301}Wabc", 1.0, |_, _| {
366 Ok::<_, ()>(widths.next().unwrap())
367 })
368 .unwrap();
369 assert_eq!(parts, ("\u{0301}Wab", "c"));
370 }
371
372 #[test]
373 fn grapheme_splitting_stops_on_measurement_errors() {
374 let mut probes = Vec::new();
375 let result =
376 split_graphemes_to_width("\u{0301}π©βπ¬WZ", 1.0, |candidate, grapheme| {
377 probes.push((candidate.to_string(), grapheme.to_string()));
378 match grapheme {
379 "\u{0301}" => Ok(0.0),
380 "π©βπ¬" => Ok(8.0),
381 _ => Err("canceled"),
382 }
383 });
384 assert_eq!(result, Err("canceled"));
385 assert_eq!(
386 probes,
387 [
388 ("\u{0301}".to_string(), "\u{0301}".to_string()),
389 ("\u{0301}π©βπ¬".to_string(), "π©βπ¬".to_string()),
390 ("\u{0301}π©βπ¬W".to_string(), "W".to_string()),
391 ],
392 );
393 }
394
395 #[test]
396 fn emitted_long_tokens_match_shared_grapheme_wrapping() {
397 let measurer = DeterministicTextMeasurer::default();
398 let style = TextStyle {
399 font_size: 16.0,
400 ..TextStyle::default()
401 };
402 for text in [
403 "W\u{0301}W",
404 "\u{0301}WW",
405 "π©βπ¬π¨βπ¬",
406 "ππ½ππ»",
407 "π¨π³πΊπΈ",
408 "1οΈβ£2οΈβ£",
409 "a\u{200b}b",
410 ] {
411 for limit in [1.0, 8.0, 16.0, 25.0] {
412 let expected = try_measure_wrapped_text(
413 text,
414 &style,
415 Some(limit),
416 WrapMode::SvgLike,
417 19.0,
418 |candidate, style| Ok::<_, ()>(measurer.measure(candidate, style).width),
419 )
420 .unwrap();
421 let emitted = wrap_text_lines_measurer(text, &measurer, &style, Some(limit));
422 assert_eq!(emitted, expected.lines, "text={text:?}, limit={limit}");
423 assert_eq!(emitted.concat(), text);
424 }
425 }
426 }
427
428 #[test]
429 fn emitted_wrapping_keeps_leading_zero_width_text_with_overwide_grapheme() {
430 let measurer = DeterministicTextMeasurer::default();
431 let style = TextStyle {
432 font_size: 16.0,
433 ..TextStyle::default()
434 };
435 assert_eq!(
436 wrap_text_lines_measurer("\u{0301}WW", &measurer, &style, Some(1.0)),
437 ["\u{0301}W", "W"],
438 );
439 assert_eq!(
440 wrap_text_lines_measurer("π©βπ¬π¨βπ¬", &measurer, &style, Some(1.0)),
441 ["π©βπ¬", "π¨βπ¬"],
442 );
443 }
444}