1use super::{
6 DeterministicTextMeasurer, TextMeasurer, TextStyle, WrapMode, estimate_char_width_em,
7 estimate_line_width_px,
8};
9
10pub fn ceil_to_1_64_px(v: f64) -> f64 {
11 if !(v.is_finite() && v >= 0.0) {
12 return 0.0;
13 }
14 let x = v * 64.0;
17 let r = x.round();
18 if (x - r).abs() < 1e-4 {
19 return r / 64.0;
20 }
21 ((x) - 1e-5).ceil() / 64.0
22}
23
24pub fn round_to_1_64_px(v: f64) -> f64 {
25 if !(v.is_finite() && v >= 0.0) {
26 return 0.0;
27 }
28 let x = v * 64.0;
29 let r = (x + 0.5).floor();
30 r / 64.0
31}
32
33pub fn round_to_1_64_px_ties_to_even(v: f64) -> f64 {
34 if !(v.is_finite() && v >= 0.0) {
35 return 0.0;
36 }
37 let x = v * 64.0;
38 let f = x.floor();
39 let frac = x - f;
40 let i = if frac < 0.5 {
41 f
42 } else if frac > 0.5 {
43 f + 1.0
44 } else {
45 let fi = f as i64;
46 if fi % 2 == 0 { f } else { f + 1.0 }
47 };
48 let out = i / 64.0;
49 if out == -0.0 { 0.0 } else { out }
50}
51
52pub fn wrap_text_lines_px(
53 text: &str,
54 style: &TextStyle,
55 max_width_px: Option<f64>,
56 wrap_mode: WrapMode,
57) -> Vec<String> {
58 let font_size = style.font_size.max(1.0);
59 let max_width_px = max_width_px.filter(|w| w.is_finite() && *w > 0.0);
60 let break_long_words = wrap_mode == WrapMode::SvgLike;
61
62 fn split_token_to_width_px(tok: &str, max_width_px: f64, font_size: f64) -> (String, String) {
63 let max_em = max_width_px / font_size;
64 let mut em = 0.0;
65 let chars = tok.chars().collect::<Vec<_>>();
66 let mut split_at = 0usize;
67 for (idx, ch) in chars.iter().enumerate() {
68 em += estimate_char_width_em(*ch);
69 if em > max_em && idx > 0 {
70 break;
71 }
72 split_at = idx + 1;
73 if em >= max_em {
74 break;
75 }
76 }
77 if split_at == 0 {
78 split_at = 1.min(chars.len());
79 }
80 let head = chars.iter().take(split_at).collect::<String>();
81 let tail = chars.iter().skip(split_at).collect::<String>();
82 (head, tail)
83 }
84
85 fn wrap_line_to_width_px(
86 line: &str,
87 max_width_px: f64,
88 font_size: f64,
89 break_long_words: bool,
90 ) -> Vec<String> {
91 let mut tokens =
92 std::collections::VecDeque::from(DeterministicTextMeasurer::split_line_to_words(line));
93 let mut out: Vec<String> = Vec::new();
94 let mut cur = String::new();
95
96 while let Some(tok) = tokens.pop_front() {
97 if cur.is_empty() && tok == " " {
98 continue;
99 }
100
101 let candidate = format!("{cur}{tok}");
102 let candidate_trimmed = candidate.trim_end();
103 if estimate_line_width_px(candidate_trimmed, font_size) <= max_width_px {
104 cur = candidate;
105 continue;
106 }
107
108 if !cur.trim().is_empty() {
109 out.push(cur.trim_end().to_string());
110 cur.clear();
111 tokens.push_front(tok);
112 continue;
113 }
114
115 if tok == " " {
116 continue;
117 }
118
119 if !break_long_words {
120 out.push(tok);
121 } else {
122 let (head, tail) = split_token_to_width_px(&tok, max_width_px, font_size);
123 out.push(head);
124 if !tail.is_empty() {
125 tokens.push_front(tail);
126 }
127 }
128 }
129
130 if !cur.trim().is_empty() {
131 out.push(cur.trim_end().to_string());
132 }
133
134 if out.is_empty() {
135 vec!["".to_string()]
136 } else {
137 out
138 }
139 }
140
141 let mut lines: Vec<String> = Vec::new();
142 for line in DeterministicTextMeasurer::normalized_text_lines(text) {
143 if let Some(w) = max_width_px {
144 lines.extend(wrap_line_to_width_px(&line, w, font_size, break_long_words));
145 } else {
146 lines.push(line);
147 }
148 }
149
150 if lines.is_empty() {
151 vec!["".to_string()]
152 } else {
153 lines
154 }
155}
156
157pub fn wrap_text_lines_measurer(
163 text: &str,
164 measurer: &dyn TextMeasurer,
165 style: &TextStyle,
166 max_width_px: Option<f64>,
167) -> Vec<String> {
168 fn wrap_line(
169 line: &str,
170 measurer: &dyn TextMeasurer,
171 style: &TextStyle,
172 max_width_px: f64,
173 ) -> Vec<String> {
174 use std::collections::VecDeque;
175
176 if !max_width_px.is_finite() || max_width_px <= 0.0 {
177 return vec![line.to_string()];
178 }
179
180 let mut tokens = VecDeque::from(DeterministicTextMeasurer::split_line_to_words(line));
181 let mut out: Vec<String> = Vec::new();
182 let mut cur = String::new();
183
184 while let Some(tok) = tokens.pop_front() {
185 if cur.is_empty() && tok == " " {
186 continue;
187 }
188
189 let candidate = format!("{cur}{tok}");
190 if measurer.measure(candidate.trim_end(), style).width <= max_width_px {
191 cur = candidate;
192 continue;
193 }
194
195 if !cur.trim().is_empty() {
196 out.push(cur.trim_end().to_string());
197 cur.clear();
198 tokens.push_front(tok);
199 continue;
200 }
201
202 if tok == " " {
203 continue;
204 }
205
206 let chars = tok.chars().collect::<Vec<_>>();
208 let mut cut = 1usize;
209 while cut < chars.len() {
210 let head: String = chars[..cut].iter().collect();
211 if measurer.measure(&head, style).width > max_width_px {
212 break;
213 }
214 cut += 1;
215 }
216 cut = cut.saturating_sub(1).max(1);
217 let head: String = chars[..cut].iter().collect();
218 let tail: String = chars[cut..].iter().collect();
219 out.push(head);
220 if !tail.is_empty() {
221 tokens.push_front(tail);
222 }
223 }
224
225 if !cur.trim().is_empty() {
226 out.push(cur.trim_end().to_string());
227 }
228
229 if out.is_empty() {
230 vec!["".to_string()]
231 } else {
232 out
233 }
234 }
235
236 let mut out: Vec<String> = Vec::new();
237 for line in split_html_br_lines(text) {
238 if let Some(w) = max_width_px {
239 out.extend(wrap_line(line, measurer, style, w));
240 } else {
241 out.push(line.to_string());
242 }
243 }
244 if out.is_empty() {
245 vec!["".to_string()]
246 } else {
247 out
248 }
249}
250
251pub(crate) fn wrap_svg_text_lines_by_measurement(
256 measurer: &dyn TextMeasurer,
257 text: &str,
258 style: &TextStyle,
259 max_width_px: Option<f64>,
260 break_long_words: bool,
261) -> Vec<String> {
262 const EPS_PX: f64 = 0.125;
263 let max_width_px = max_width_px.filter(|w| w.is_finite() && *w > 0.0);
264
265 fn measure_w_px(measurer: &dyn TextMeasurer, style: &TextStyle, s: &str) -> f64 {
266 measurer.measure(s, style).width
267 }
268
269 fn split_token_to_width_px(
270 measurer: &dyn TextMeasurer,
271 style: &TextStyle,
272 tok: &str,
273 max_width_px: f64,
274 ) -> (String, String) {
275 if max_width_px <= 0.0 {
276 return (tok.to_string(), String::new());
277 }
278 let chars = tok.chars().collect::<Vec<_>>();
279 if chars.is_empty() {
280 return (String::new(), String::new());
281 }
282
283 let mut split_at = 1usize;
284 for i in 1..=chars.len() {
285 let head = chars[..i].iter().collect::<String>();
286 let w = measure_w_px(measurer, style, &head);
287 if w.is_finite() && w <= max_width_px + EPS_PX {
288 split_at = i;
289 } else {
290 break;
291 }
292 }
293 let head = chars[..split_at].iter().collect::<String>();
294 let tail = chars[split_at..].iter().collect::<String>();
295 (head, tail)
296 }
297
298 fn wrap_line_to_width_px(
299 measurer: &dyn TextMeasurer,
300 style: &TextStyle,
301 line: &str,
302 max_width_px: f64,
303 break_long_words: bool,
304 ) -> Vec<String> {
305 let mut tokens =
306 std::collections::VecDeque::from(DeterministicTextMeasurer::split_line_to_words(line));
307 let mut out: Vec<String> = Vec::new();
308 let mut cur = String::new();
309
310 while let Some(tok) = tokens.pop_front() {
311 if cur.is_empty() && tok == " " {
312 continue;
313 }
314
315 let candidate = format!("{cur}{tok}");
316 let candidate_trimmed = candidate.trim_end();
317 if measure_w_px(measurer, style, candidate_trimmed) <= max_width_px + EPS_PX {
318 cur = candidate;
319 continue;
320 }
321
322 if !cur.trim().is_empty() {
323 out.push(cur.trim_end().to_string());
324 cur.clear();
325 tokens.push_front(tok);
326 continue;
327 }
328
329 if tok == " " {
330 continue;
331 }
332
333 if measure_w_px(measurer, style, tok.as_str()) <= max_width_px + EPS_PX {
334 cur = tok;
335 continue;
336 }
337
338 if !break_long_words {
339 out.push(tok);
340 continue;
341 }
342
343 let (head, tail) = split_token_to_width_px(measurer, style, &tok, max_width_px);
344 out.push(head);
345 if !tail.is_empty() {
346 tokens.push_front(tail);
347 }
348 }
349
350 if !cur.trim().is_empty() {
351 out.push(cur.trim_end().to_string());
352 }
353
354 if out.is_empty() {
355 vec!["".to_string()]
356 } else {
357 out
358 }
359 }
360
361 let mut lines = Vec::new();
362 for line in DeterministicTextMeasurer::normalized_text_lines(text) {
363 if let Some(w) = max_width_px {
364 lines.extend(wrap_line_to_width_px(
365 measurer,
366 style,
367 &line,
368 w,
369 break_long_words,
370 ));
371 } else {
372 lines.push(line);
373 }
374 }
375
376 if lines.is_empty() {
377 vec!["".to_string()]
378 } else {
379 lines
380 }
381}
382
383pub fn split_html_br_lines(text: &str) -> Vec<&str> {
389 let b = text.as_bytes();
390 let mut parts: Vec<&str> = Vec::new();
391 let mut start = 0usize;
392 let mut i = 0usize;
393 while i + 3 < b.len() {
394 if b[i] != b'<' {
395 i += 1;
396 continue;
397 }
398 let b1 = b[i + 1];
399 let b2 = b[i + 2];
400 if !matches!(b1, b'b' | b'B') || !matches!(b2, b'r' | b'R') {
401 i += 1;
402 continue;
403 }
404 let mut j = i + 3;
405 while j < b.len() && matches!(b[j], b' ' | b'\t' | b'\r' | b'\n') {
406 j += 1;
407 }
408 if j < b.len() && b[j] == b'/' {
409 j += 1;
410 }
411 if j < b.len() && b[j] == b'>' {
412 parts.push(&text[start..i]);
413 start = j + 1;
414 i = start;
415 continue;
416 }
417 i += 1;
418 }
419 parts.push(&text[start..]);
420 parts
421}
422
423const EXACT_SVG_WRAP_SINGLE_LINE_GUARD_PX: f64 = 4.0;
424
425fn exact_svg_single_line_evidence_fits(
426 label: &str,
427 measurer: &dyn TextMeasurer,
428 style: &TextStyle,
429 max_width_px: f64,
430) -> bool {
431 if label.is_empty() || !max_width_px.is_finite() || max_width_px <= 0.0 {
432 return false;
433 }
434
435 let exact_w = measurer
436 .measure_svg_simple_text_bbox_width_px(label, style)
437 .floor()
438 .max(0.0);
439 let probe_w = measurer
440 .measure_svg_simple_text_bbox_width_for_wrap_px(label, style)
441 .floor()
442 .max(0.0);
443
444 exact_w + EXACT_SVG_WRAP_SINGLE_LINE_GUARD_PX <= max_width_px
445 && exact_w + EXACT_SVG_WRAP_SINGLE_LINE_GUARD_PX < probe_w
446}
447
448pub fn wrap_label_like_mermaid_lines(
453 label: &str,
454 measurer: &dyn TextMeasurer,
455 style: &TextStyle,
456 max_width_px: f64,
457) -> Vec<String> {
458 if label.is_empty() {
459 return Vec::new();
460 }
461 if !max_width_px.is_finite() || max_width_px <= 0.0 {
462 return vec![label.to_string()];
463 }
464
465 if split_html_br_lines(label).len() > 1 {
467 return split_html_br_lines(label)
468 .into_iter()
469 .map(|s| s.to_string())
470 .collect();
471 }
472 if exact_svg_single_line_evidence_fits(label, measurer, style, max_width_px) {
473 return vec![label.to_string()];
474 }
475
476 fn w_px(measurer: &dyn TextMeasurer, style: &TextStyle, s: &str) -> f64 {
477 measurer
479 .measure_svg_simple_text_bbox_width_for_wrap_px(s, style)
480 .round()
481 }
482
483 fn break_string_like_mermaid(
484 word: &str,
485 max_width_px: f64,
486 measurer: &dyn TextMeasurer,
487 style: &TextStyle,
488 ) -> (Vec<String>, String) {
489 let chars: Vec<char> = word.chars().collect();
490 let mut lines: Vec<String> = Vec::new();
491 let mut current = String::new();
492 for (idx, ch) in chars.iter().enumerate() {
493 let next_line = format!("{current}{ch}");
494 let line_w = w_px(measurer, style, &next_line);
495 if line_w >= max_width_px {
496 let is_last = idx + 1 == chars.len();
497 if is_last {
498 lines.push(next_line);
499 } else {
500 lines.push(format!("{next_line}-"));
501 }
502 current.clear();
503 } else {
504 current = next_line;
505 }
506 }
507 (lines, current)
508 }
509
510 let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
512 if words.is_empty() {
513 return vec![label.to_string()];
514 }
515
516 let mut completed: Vec<String> = Vec::new();
517 let mut next_line = String::new();
518 for (idx, word) in words.iter().enumerate() {
519 let word_len = w_px(measurer, style, &format!("{word} "));
520 let next_len = w_px(measurer, style, &next_line);
521 if word_len > max_width_px {
522 let (hyphenated, remaining) =
523 break_string_like_mermaid(word, max_width_px, measurer, style);
524 completed.push(next_line.clone());
525 completed.extend(hyphenated);
526 next_line = remaining;
527 } else if next_len + word_len >= max_width_px {
528 completed.push(next_line.clone());
529 next_line = (*word).to_string();
530 } else if next_line.is_empty() {
531 next_line = (*word).to_string();
532 } else {
533 next_line.push(' ');
534 next_line.push_str(word);
535 }
536
537 let is_last = idx + 1 == words.len();
538 if is_last {
539 completed.push(next_line.clone());
540 }
541 }
542
543 completed.into_iter().filter(|l| !l.is_empty()).collect()
544}
545
546pub fn wrap_label_like_mermaid_lines_relaxed(
552 label: &str,
553 measurer: &dyn TextMeasurer,
554 style: &TextStyle,
555 max_width_px: f64,
556) -> Vec<String> {
557 if label.is_empty() {
558 return Vec::new();
559 }
560 if !max_width_px.is_finite() || max_width_px <= 0.0 {
561 return vec![label.to_string()];
562 }
563
564 if split_html_br_lines(label).len() > 1 {
565 return split_html_br_lines(label)
566 .into_iter()
567 .map(|s| s.to_string())
568 .collect();
569 }
570 if exact_svg_single_line_evidence_fits(label, measurer, style, max_width_px) {
571 return vec![label.to_string()];
572 }
573
574 fn w_px(measurer: &dyn TextMeasurer, style: &TextStyle, s: &str) -> f64 {
575 measurer.measure(s, style).width.round()
576 }
577
578 fn break_string_like_mermaid(
579 word: &str,
580 max_width_px: f64,
581 measurer: &dyn TextMeasurer,
582 style: &TextStyle,
583 ) -> (Vec<String>, String) {
584 let chars: Vec<char> = word.chars().collect();
585 let mut lines: Vec<String> = Vec::new();
586 let mut current = String::new();
587 for (idx, ch) in chars.iter().enumerate() {
588 let next_line = format!("{current}{ch}");
589 let line_w = w_px(measurer, style, &next_line);
590 if line_w >= max_width_px {
591 let is_last = idx + 1 == chars.len();
592 if is_last {
593 lines.push(next_line);
594 } else {
595 lines.push(format!("{next_line}-"));
596 }
597 current.clear();
598 } else {
599 current = next_line;
600 }
601 }
602 (lines, current)
603 }
604
605 let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
606 if words.is_empty() {
607 return vec![label.to_string()];
608 }
609
610 let mut completed: Vec<String> = Vec::new();
611 let mut next_line = String::new();
612 for (idx, word) in words.iter().enumerate() {
613 let word_len = w_px(measurer, style, &format!("{word} "));
614 let next_len = w_px(measurer, style, &next_line);
615 if word_len > max_width_px {
616 let (hyphenated, remaining) =
617 break_string_like_mermaid(word, max_width_px, measurer, style);
618 completed.push(next_line.clone());
619 completed.extend(hyphenated);
620 next_line = remaining;
621 } else if next_len + word_len >= max_width_px {
622 completed.push(next_line.clone());
623 next_line = (*word).to_string();
624 } else if next_line.is_empty() {
625 next_line = (*word).to_string();
626 } else {
627 next_line.push(' ');
628 next_line.push_str(word);
629 }
630
631 let is_last = idx + 1 == words.len();
632 if is_last {
633 completed.push(next_line.clone());
634 }
635 }
636
637 completed.into_iter().filter(|l| !l.is_empty()).collect()
638}
639
640pub fn wrap_label_like_mermaid_lines_floored_bbox(
646 label: &str,
647 measurer: &dyn TextMeasurer,
648 style: &TextStyle,
649 max_width_px: f64,
650) -> Vec<String> {
651 if label.is_empty() {
652 return Vec::new();
653 }
654 if !max_width_px.is_finite() || max_width_px <= 0.0 {
655 return vec![label.to_string()];
656 }
657
658 if split_html_br_lines(label).len() > 1 {
659 return split_html_br_lines(label)
660 .into_iter()
661 .map(|s| s.to_string())
662 .collect();
663 }
664 if exact_svg_single_line_evidence_fits(label, measurer, style, max_width_px) {
665 return vec![label.to_string()];
666 }
667
668 fn w_px(measurer: &dyn TextMeasurer, style: &TextStyle, s: &str) -> f64 {
669 measurer
670 .measure_svg_simple_text_bbox_width_for_wrap_px(s, style)
671 .floor()
672 }
673
674 fn break_string_like_mermaid(
675 word: &str,
676 max_width_px: f64,
677 measurer: &dyn TextMeasurer,
678 style: &TextStyle,
679 ) -> (Vec<String>, String) {
680 let chars: Vec<char> = word.chars().collect();
681 let mut lines: Vec<String> = Vec::new();
682 let mut current = String::new();
683 for (idx, ch) in chars.iter().enumerate() {
684 let next_line = format!("{current}{ch}");
685 let line_w = w_px(measurer, style, &next_line);
686 if line_w >= max_width_px {
687 let is_last = idx + 1 == chars.len();
688 if is_last {
689 lines.push(next_line);
690 } else {
691 lines.push(format!("{next_line}-"));
692 }
693 current.clear();
694 } else {
695 current = next_line;
696 }
697 }
698 (lines, current)
699 }
700
701 let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
702 if words.is_empty() {
703 return vec![label.to_string()];
704 }
705
706 let mut completed: Vec<String> = Vec::new();
707 let mut next_line = String::new();
708 for (idx, word) in words.iter().enumerate() {
709 let word_len = w_px(measurer, style, &format!("{word} "));
710 let next_len = w_px(measurer, style, &next_line);
711 if word_len > max_width_px {
712 let (hyphenated, remaining) =
713 break_string_like_mermaid(word, max_width_px, measurer, style);
714 completed.push(next_line.clone());
715 completed.extend(hyphenated);
716 next_line = remaining;
717 } else if next_len + word_len >= max_width_px {
718 completed.push(next_line.clone());
719 next_line = (*word).to_string();
720 } else if next_line.is_empty() {
721 next_line = (*word).to_string();
722 } else {
723 next_line.push(' ');
724 next_line.push_str(word);
725 }
726
727 let is_last = idx + 1 == words.len();
728 if is_last {
729 completed.push(next_line.clone());
730 }
731 }
732
733 completed.into_iter().filter(|l| !l.is_empty()).collect()
734}