1use super::heuristic::append_text_width_em;
4use super::line_break::html_break_spaces_segments;
5use super::{
6 TextMeasurer, TextMetrics, TextStyle, WrapMode, trim_end_html_collapsible_ascii_whitespace,
7 trim_html_collapsible_ascii_whitespace,
8};
9use std::sync::Arc;
10use unicode_segmentation::UnicodeSegmentation;
11
12type TextWidthPxFn = Arc<dyn Fn(&str, &TextStyle) -> f64 + Send + Sync>;
13
14#[derive(Clone)]
15struct WidthCallbackTextMeasurer {
16 width_px: TextWidthPxFn,
17 line_height_factor: f64,
18}
19
20#[derive(Clone, Copy, Default)]
21struct LineWidthAccumulator {
22 heuristic_em: f64,
23 char_count: usize,
24}
25
26#[derive(Clone, Copy)]
27enum LineWidthSource<'a> {
28 Heuristic,
29 UniformAdvanceEm(f64),
30 Callback(&'a TextWidthPxFn),
31}
32
33#[derive(Clone, Copy)]
34struct LineWidthModel<'a> {
35 font_size: f64,
36 source: LineWidthSource<'a>,
37 style: &'a TextStyle,
38}
39
40impl LineWidthModel<'_> {
41 fn width_px(self, text: &str) -> f64 {
42 if let LineWidthSource::Callback(width_px) = self.source {
43 return width_px(text, self.style);
44 }
45
46 let mut width = LineWidthAccumulator::default();
47 self.append_builtin(&mut width, text);
48 self.finish_builtin(width)
49 }
50
51 fn candidate_width_px(
52 self,
53 width: LineWidthAccumulator,
54 candidate: &str,
55 appended: &str,
56 ) -> (LineWidthAccumulator, f64) {
57 if let LineWidthSource::Callback(width_px) = self.source {
58 return (width, width_px(candidate, self.style));
59 }
60
61 let mut width = width;
62 self.append_builtin(&mut width, appended);
63 (width, self.finish_builtin(width))
64 }
65
66 fn append_builtin(self, width: &mut LineWidthAccumulator, text: &str) {
67 match self.source {
68 LineWidthSource::Heuristic => append_text_width_em(&mut width.heuristic_em, text),
69 LineWidthSource::UniformAdvanceEm(_) => {
70 width.char_count = width.char_count.saturating_add(text.chars().count());
71 }
72 LineWidthSource::Callback(_) => unreachable!("callback widths use complete strings"),
73 }
74 }
75
76 fn finish_builtin(self, width: LineWidthAccumulator) -> f64 {
77 match self.source {
78 LineWidthSource::Heuristic => width.heuristic_em * self.font_size,
79 LineWidthSource::UniformAdvanceEm(advance_em) => {
80 width.char_count as f64 * self.font_size * advance_em
81 }
82 LineWidthSource::Callback(_) => unreachable!("callback widths use complete strings"),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Default)]
88pub struct DeterministicTextMeasurer {
89 pub char_width_factor: f64,
90 pub line_height_factor: f64,
91}
92
93impl DeterministicTextMeasurer {
94 pub fn with_width_callback(
103 self,
104 width_px: impl Fn(&str, &TextStyle) -> f64 + Send + Sync + 'static,
105 ) -> Arc<dyn TextMeasurer + Send + Sync> {
106 Arc::new(WidthCallbackTextMeasurer {
107 width_px: Arc::new(width_px),
108 line_height_factor: self.line_height_factor,
109 })
110 }
111
112 fn collapse_svg_text_whitespace(text: &str) -> String {
113 let mut collapsed = String::with_capacity(text.len());
114 let mut pending_space = false;
115
116 for ch in text.chars() {
117 if matches!(ch, ' ' | '\t' | '\r' | '\n') {
118 pending_space = !collapsed.is_empty();
119 } else {
120 if pending_space {
121 collapsed.push(' ');
122 pending_space = false;
123 }
124 collapsed.push(ch);
125 }
126 }
127
128 collapsed
129 }
130
131 fn replace_br_variants(text: &str) -> String {
132 let mut out = String::with_capacity(text.len());
133 let mut i = 0usize;
134 while i < text.len() {
135 let Some(rest) = text.get(i..) else {
136 break;
137 };
138
139 if rest.starts_with('<') {
143 let bytes = text.as_bytes();
144 if i + 3 < bytes.len()
145 && matches!(bytes[i + 1], b'b' | b'B')
146 && matches!(bytes[i + 2], b'r' | b'R')
147 {
148 let mut j = i + 3;
149 while j < bytes.len() && matches!(bytes[j], b' ' | b'\t' | b'\r' | b'\n') {
150 j += 1;
151 }
152 if j < bytes.len() && bytes[j] == b'/' {
153 j += 1;
154 }
155 if j < bytes.len() && bytes[j] == b'>' {
156 out.push('\n');
157 i = j + 1;
158 continue;
159 }
160 }
161 }
162
163 let Some(ch) = rest.chars().next() else {
164 break;
165 };
166 out.push(ch);
167 i += ch.len_utf8();
168 }
169 out
170 }
171
172 fn normalized_text_lines_with(
173 text: &str,
174 trailing_line_is_empty: impl Fn(&str) -> bool,
175 ) -> Vec<String> {
176 let t = Self::replace_br_variants(text);
177 let mut out = t.split('\n').map(|s| s.to_string()).collect::<Vec<_>>();
178
179 while out.len() > 1 && out.last().is_some_and(|s| trailing_line_is_empty(s)) {
183 out.pop();
184 }
185
186 if out.is_empty() {
187 vec!["".to_string()]
188 } else {
189 out
190 }
191 }
192
193 pub fn normalized_text_lines(text: &str) -> Vec<String> {
194 Self::normalized_text_lines_with(text, |line| {
195 trim_html_collapsible_ascii_whitespace(line).is_empty()
196 })
197 }
198
199 pub(crate) fn split_line_to_words(text: &str) -> Vec<String> {
200 let parts = text.split(' ').collect::<Vec<_>>();
203 let mut out: Vec<String> = Vec::new();
204 for part in parts {
205 if !part.is_empty() {
206 out.push(part.to_string());
207 }
208 out.push(" ".to_string());
209 }
210 while out.last().is_some_and(|s| s == " ") {
211 out.pop();
212 }
213 out
214 }
215
216 fn wrapped_line_has_visible_content(text: &str) -> bool {
217 !trim_html_collapsible_ascii_whitespace(text).is_empty()
218 }
219
220 fn split_token_to_width<'t>(
221 token: &'t str,
222 max_width_px: f64,
223 width_model: LineWidthModel<'_>,
224 ) -> (&'t str, &'t str) {
225 let mut split_at = 0;
226 let mut width = LineWidthAccumulator::default();
227 let mut width_px = 0.0;
228 let mut has_positive_advance = false;
229
230 for (index, grapheme) in token.grapheme_indices(true) {
231 let candidate_end = index + grapheme.len();
232 let (candidate, candidate_width_px) =
233 width_model.candidate_width_px(width, &token[..candidate_end], grapheme);
234 if has_positive_advance
235 && candidate_width_px > width_px
236 && candidate_width_px > max_width_px
237 {
238 break;
239 }
240
241 split_at = candidate_end;
242 width = candidate;
243 width_px = candidate_width_px;
244 has_positive_advance |= candidate_width_px > 0.0;
245 }
246
247 if split_at == 0 {
250 split_at = token.graphemes(true).next().map_or(0, str::len);
251 }
252 token.split_at(split_at)
253 }
254
255 fn wrap_line(
256 line: &str,
257 max_width_px: f64,
258 break_long_words: bool,
259 wrap_mode: WrapMode,
260 html_break_spaces_active: bool,
261 width_model: LineWidthModel<'_>,
262 ) -> Vec<String> {
263 if !max_width_px.is_finite() || max_width_px <= 0.0 {
264 return vec![line.to_string()];
265 }
266
267 let tokens = match (wrap_mode, html_break_spaces_active) {
268 (WrapMode::HtmlLike, true) => html_break_spaces_segments(line)
269 .into_iter()
270 .map(str::to_string)
271 .collect(),
272 _ => Self::split_line_to_words(line),
273 };
274 let mut tokens = std::collections::VecDeque::from(tokens);
275 let mut out: Vec<String> = Vec::new();
276 let mut cur = String::new();
277 let mut cur_width = LineWidthAccumulator::default();
278
279 while let Some(tok) = tokens.pop_front() {
280 if cur.is_empty() && tok == " " && !html_break_spaces_active {
281 continue;
282 }
283
284 let previous_len = cur.len();
285 cur.push_str(&tok);
286 let (candidate_width, candidate_width_px) =
287 width_model.candidate_width_px(cur_width, &cur, &tok);
288 if candidate_width_px <= max_width_px {
289 cur_width = candidate_width;
290 continue;
291 }
292 cur.truncate(previous_len);
293
294 let current_line_has_content = if html_break_spaces_active {
295 !cur.is_empty()
296 } else {
297 Self::wrapped_line_has_visible_content(&cur)
298 };
299 if current_line_has_content {
300 out.push(if html_break_spaces_active {
301 std::mem::take(&mut cur)
302 } else {
303 let line = trim_end_html_collapsible_ascii_whitespace(&cur).to_string();
304 cur.clear();
305 line
306 });
307 cur_width = LineWidthAccumulator::default();
308 tokens.push_front(tok);
309 continue;
310 }
311
312 if tok == " " && !html_break_spaces_active {
314 continue;
315 }
316 if !break_long_words {
317 out.push(tok);
318 } else {
319 let (head, tail) = Self::split_token_to_width(&tok, max_width_px, width_model);
322 out.push(head.to_string());
323 if !tail.is_empty() {
324 tokens.push_front(tail.to_string());
325 }
326 }
327 }
328
329 if html_break_spaces_active && !cur.is_empty() {
330 out.push(cur);
331 } else if Self::wrapped_line_has_visible_content(&cur) {
332 out.push(trim_end_html_collapsible_ascii_whitespace(&cur).to_string());
333 }
334
335 if out.is_empty() {
336 vec!["".to_string()]
337 } else {
338 out
339 }
340 }
341}
342
343impl TextMeasurer for DeterministicTextMeasurer {
344 #[allow(private_interfaces)]
345 fn begin_svg_text_computed_length(
346 &self,
347 style: &TextStyle,
348 ) -> Option<crate::environment::BuiltinSvgComputedLength> {
349 (self.char_width_factor == 0.0)
350 .then(|| crate::environment::BuiltinSvgComputedLength::deterministic(style))
351 }
352
353 fn measure(&self, text: &str, style: &TextStyle) -> TextMetrics {
354 self.measure_wrapped(text, style, None, WrapMode::SvgLike)
355 }
356
357 fn measure_wrapped(
358 &self,
359 text: &str,
360 style: &TextStyle,
361 max_width: Option<f64>,
362 wrap_mode: WrapMode,
363 ) -> TextMetrics {
364 self.measure_wrapped_impl(text, style, max_width, wrap_mode)
365 .0
366 }
367
368 fn measure_wrapped_with_raw_width(
369 &self,
370 text: &str,
371 style: &TextStyle,
372 max_width: Option<f64>,
373 wrap_mode: WrapMode,
374 ) -> (TextMetrics, Option<f64>) {
375 self.measure_wrapped_impl(text, style, max_width, wrap_mode)
376 }
377
378 fn measure_mermaid_calculate_text_dimensions(
379 &self,
380 text: &str,
381 style: &TextStyle,
382 ) -> TextMetrics {
383 let collapsed = Self::collapse_svg_text_whitespace(text);
384 TextMetrics {
385 width: self.measure_svg_simple_text_bbox_width_for_wrap_px(&collapsed, style),
386 height: self.measure_svg_simple_text_bbox_height_px(&collapsed, style),
387 line_count: 1,
388 }
389 }
390
391 fn measure_svg_simple_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
392 let t = trim_end_html_collapsible_ascii_whitespace(text);
393 if t.is_empty() {
394 return 0.0;
395 }
396 (style.font_size.max(1.0) * 1.1).max(0.0)
397 }
398
399 fn measure_svg_tspan_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
400 if trim_end_html_collapsible_ascii_whitespace(text).is_empty() {
401 0.0
402 } else {
403 super::svg_wrapped_first_line_bbox_height_px(style)
404 }
405 }
406}
407
408impl TextMeasurer for WidthCallbackTextMeasurer {
409 fn measure(&self, text: &str, style: &TextStyle) -> TextMetrics {
410 self.measure_wrapped(text, style, None, WrapMode::SvgLike)
411 }
412
413 fn measure_wrapped(
414 &self,
415 text: &str,
416 style: &TextStyle,
417 max_width: Option<f64>,
418 wrap_mode: WrapMode,
419 ) -> TextMetrics {
420 DeterministicTextMeasurer::measure_wrapped_impl_with_width(
421 text,
422 style,
423 max_width,
424 wrap_mode,
425 LineWidthSource::Callback(&self.width_px),
426 self.line_height_factor,
427 )
428 .0
429 }
430
431 fn measure_wrapped_with_raw_width(
432 &self,
433 text: &str,
434 style: &TextStyle,
435 max_width: Option<f64>,
436 wrap_mode: WrapMode,
437 ) -> (TextMetrics, Option<f64>) {
438 DeterministicTextMeasurer::measure_wrapped_impl_with_width(
439 text,
440 style,
441 max_width,
442 wrap_mode,
443 LineWidthSource::Callback(&self.width_px),
444 self.line_height_factor,
445 )
446 }
447
448 fn measure_mermaid_calculate_text_dimensions(
449 &self,
450 text: &str,
451 style: &TextStyle,
452 ) -> TextMetrics {
453 let collapsed = DeterministicTextMeasurer::collapse_svg_text_whitespace(text);
454 TextMetrics {
455 width: self.measure_svg_simple_text_bbox_width_for_wrap_px(&collapsed, style),
456 height: self.measure_svg_simple_text_bbox_height_px(&collapsed, style),
457 line_count: 1,
458 }
459 }
460
461 fn measure_svg_simple_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
462 let text = trim_end_html_collapsible_ascii_whitespace(text);
463 if text.is_empty() {
464 return 0.0;
465 }
466 (style.font_size.max(1.0) * 1.1).max(0.0)
467 }
468
469 fn measure_svg_tspan_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
470 if trim_end_html_collapsible_ascii_whitespace(text).is_empty() {
471 0.0
472 } else {
473 super::svg_wrapped_first_line_bbox_height_px(style)
474 }
475 }
476}
477
478impl DeterministicTextMeasurer {
479 fn measure_wrapped_impl(
480 &self,
481 text: &str,
482 style: &TextStyle,
483 max_width: Option<f64>,
484 wrap_mode: WrapMode,
485 ) -> (TextMetrics, Option<f64>) {
486 let width_source = if self.char_width_factor == 0.0 {
487 LineWidthSource::Heuristic
488 } else {
489 LineWidthSource::UniformAdvanceEm(self.char_width_factor)
490 };
491 Self::measure_wrapped_impl_with_width(
492 text,
493 style,
494 max_width,
495 wrap_mode,
496 width_source,
497 self.line_height_factor,
498 )
499 }
500
501 fn measure_wrapped_impl_with_width(
502 text: &str,
503 style: &TextStyle,
504 max_width: Option<f64>,
505 wrap_mode: WrapMode,
506 width_source: LineWidthSource<'_>,
507 configured_line_height_factor: f64,
508 ) -> (TextMetrics, Option<f64>) {
509 let default_line_height_factor = match wrap_mode {
510 WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => 1.1,
511 WrapMode::HtmlLike => 1.5,
512 };
513 let line_height_factor = if configured_line_height_factor == 0.0 {
514 default_line_height_factor
515 } else {
516 configured_line_height_factor
517 };
518
519 let font_size = style.font_size.max(1.0);
520 let width_model = LineWidthModel {
521 font_size,
522 source: width_source,
523 style,
524 };
525 let max_width = max_width.filter(|w| w.is_finite() && *w > 0.0);
526 let break_long_words = matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun);
527
528 let raw_lines = Self::normalized_text_lines(text);
529 let raw_width = (wrap_mode == WrapMode::HtmlLike || max_width.is_none()).then(|| {
530 raw_lines
531 .iter()
532 .fold(0.0_f64, |width, line| width.max(width_model.width_px(line)))
533 });
534 let html_break_spaces_active = wrap_mode == WrapMode::HtmlLike
535 && max_width
536 .is_some_and(|max_width| raw_width.is_some_and(|raw_width| raw_width > max_width));
537 let mut lines = Vec::new();
538 for line in raw_lines {
539 if let Some(w) = max_width {
540 lines.extend(Self::wrap_line(
541 &line,
542 w,
543 break_long_words,
544 wrap_mode,
545 html_break_spaces_active,
546 width_model,
547 ));
548 } else {
549 lines.push(line);
550 }
551 }
552
553 let mut width = if max_width.is_none() {
554 raw_width.expect("unwrapped measurement computes raw width")
555 } else {
556 lines
557 .iter()
558 .fold(0.0_f64, |width, line| width.max(width_model.width_px(line)))
559 };
560 if html_break_spaces_active && let Some(max_width) = max_width {
561 width = width.max(max_width);
565 }
566 let height = lines.len() as f64 * font_size * line_height_factor;
567 let metrics = TextMetrics {
568 width,
569 height,
570 line_count: lines.len(),
571 };
572 let raw_width_px = if wrap_mode == WrapMode::HtmlLike {
573 Some(raw_width.expect("HTML measurement computes raw width"))
574 } else {
575 None
576 };
577 (metrics, raw_width_px)
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::{DeterministicTextMeasurer, LineWidthModel, LineWidthSource};
584 use crate::text::{TextMeasurer, TextStyle, WrapMode};
585 use std::sync::{Arc, Mutex};
586 use unicode_segmentation::UnicodeSegmentation;
587
588 #[test]
589 fn wrapping_uses_estimated_width_instead_of_character_count() {
590 let measurer = DeterministicTextMeasurer::default();
591 let metrics = measurer.measure_wrapped(
592 "iiii WWWW",
593 &TextStyle {
594 font_size: 10.0,
595 ..TextStyle::default()
596 },
597 Some(20.0),
598 WrapMode::SvgLike,
599 );
600
601 assert_eq!(metrics.line_count, 3);
602 assert!((metrics.width - 17.0).abs() < f64::EPSILON, "{metrics:?}");
603 }
604
605 #[test]
606 fn long_word_splitting_keeps_zero_width_scalars_with_visible_text() {
607 let (head, tail) = DeterministicTextMeasurer::split_token_to_width(
608 "W\u{0301}W",
609 8.0,
610 LineWidthModel {
611 font_size: 10.0,
612 source: LineWidthSource::Heuristic,
613 style: &TextStyle::default(),
614 },
615 );
616 assert_eq!(head, "W\u{0301}");
617 assert_eq!(tail, "W");
618
619 let metrics = DeterministicTextMeasurer::default().measure_wrapped(
620 "\u{0301}WW",
621 &TextStyle {
622 font_size: 10.0,
623 ..TextStyle::default()
624 },
625 Some(8.5),
626 WrapMode::SvgLike,
627 );
628
629 assert_eq!(metrics.line_count, 2);
630 assert!((metrics.width - 8.5).abs() < f64::EPSILON, "{metrics:?}");
631 }
632
633 #[test]
634 fn default_width_uses_sequence_aware_unicode_display_width() {
635 let measurer = DeterministicTextMeasurer::default();
636 let style = TextStyle {
637 font_size: 16.0,
638 ..TextStyle::default()
639 };
640
641 for sequence in ["๐ฉโ๐ฌ", "๐จโ๐ฉโ๐งโ๐ฆ", "๐๐ฝ", "๐จ๐ณ", "1๏ธโฃ"]
642 {
643 let metrics = measurer.measure(sequence, &style);
644 assert_eq!(metrics.width, 16.0, "sequence={sequence:?}");
645 }
646 }
647
648 #[test]
649 fn wrapping_splits_long_emoji_runs_only_at_grapheme_boundaries() {
650 let measurer = DeterministicTextMeasurer::default();
651 let style = TextStyle {
652 font_size: 16.0,
653 ..TextStyle::default()
654 };
655 let width_model = LineWidthModel {
656 font_size: 16.0,
657 source: LineWidthSource::Heuristic,
658 style: &style,
659 };
660
661 for (text, first, second) in [
662 ("๐ฉโ๐ฌ๐จโ๐ฌ", "๐ฉโ๐ฌ", "๐จโ๐ฌ"),
663 ("๐๐ฝ๐๐ป", "๐๐ฝ", "๐๐ป"),
664 ("๐จ๐ณ๐บ๐ธ", "๐จ๐ณ", "๐บ๐ธ"),
665 ("1๏ธโฃ2๏ธโฃ", "1๏ธโฃ", "2๏ธโฃ"),
666 ] {
667 let (head, tail) =
668 DeterministicTextMeasurer::split_token_to_width(text, 16.0, width_model);
669 assert_eq!((head, tail), (first, second), "text={text:?}");
670
671 let metrics = measurer.measure_wrapped(text, &style, Some(16.0), WrapMode::SvgLike);
672 assert_eq!(metrics.line_count, 2, "text={text:?}: {metrics:?}");
673 assert_eq!(metrics.width, 16.0, "text={text:?}: {metrics:?}");
674 }
675 }
676
677 #[test]
678 fn html_long_words_preserve_their_min_content_width() {
679 let measurer = DeterministicTextMeasurer::default();
680 let style = TextStyle {
681 font_size: 10.0,
682 ..TextStyle::default()
683 };
684
685 let metrics = measurer.measure_wrapped(
686 "Supercalifragilisticexpialidocious",
687 &style,
688 Some(20.0),
689 WrapMode::HtmlLike,
690 );
691
692 assert_eq!(metrics.line_count, 1);
693 assert!(metrics.width > 20.0, "{metrics:?}");
694 }
695
696 #[test]
697 fn html_wrapping_uses_unicode_soft_breaks_and_fixed_container_width() {
698 let measurer = DeterministicTextMeasurer::default();
699 let style = TextStyle {
700 font_size: 10.0,
701 ..TextStyle::default()
702 };
703
704 let cjk = measurer.measure_wrapped("่ด่ดฃไบบๅฎกๆน", &style, Some(25.0), WrapMode::HtmlLike);
705 assert_eq!(cjk.line_count, 3);
706 assert_eq!(cjk.width.to_bits(), 25.0_f64.to_bits());
707
708 let breakable =
709 measurer.measure_wrapped("alpha beta", &style, Some(40.0), WrapMode::HtmlLike);
710 assert_eq!(breakable.line_count, 2);
711 assert_eq!(breakable.width.to_bits(), 40.0_f64.to_bits());
712
713 let hyphenated =
714 measurer.measure_wrapped("half-rounded", &style, Some(21.5), WrapMode::HtmlLike);
715 assert_eq!(hyphenated.line_count, 2);
716 assert!(
717 hyphenated.width > 21.5,
718 "unbreakable segment keeps min-content width: {hyphenated:?}"
719 );
720
721 let preserved_spaces =
722 measurer.measure_wrapped("a b", &style, Some(7.0), WrapMode::HtmlLike);
723 assert_eq!(preserved_spaces.line_count, 3);
724 assert!(preserved_spaces.width > 7.0, "{preserved_spaces:?}");
725 }
726
727 #[test]
728 fn repeated_measurement_is_bitwise_deterministic() {
729 let measurer = DeterministicTextMeasurer::default();
730 let style = TextStyle {
731 font_size: 16.0,
732 ..TextStyle::default()
733 };
734 let first = measurer.measure_wrapped(
735 "Latin / ไธญๆ / ๐ / e\u{0301}",
736 &style,
737 Some(80.0),
738 WrapMode::SvgLike,
739 );
740 let second = measurer.measure_wrapped(
741 "Latin / ไธญๆ / ๐ / e\u{0301}",
742 &style,
743 Some(80.0),
744 WrapMode::SvgLike,
745 );
746
747 assert_eq!(first.width.to_bits(), second.width.to_bits());
748 assert_eq!(first.height.to_bits(), second.height.to_bits());
749 assert_eq!(first.line_count, second.line_count);
750 }
751
752 #[test]
753 fn exact_natural_width_does_not_trigger_a_spurious_wrap() {
754 let measurer = DeterministicTextMeasurer::default();
755 let style = TextStyle {
756 font_size: 16.0,
757 ..TextStyle::default()
758 };
759 let text = "FtQHasK pGRJ";
760
761 for mode in [WrapMode::SvgLike, WrapMode::HtmlLike] {
762 let natural = measurer.measure_wrapped(text, &style, None, mode);
763 let fitted = measurer.measure_wrapped(text, &style, Some(natural.width), mode);
764 assert_eq!(fitted.line_count, 1, "{mode:?}: {natural:?} -> {fitted:?}");
765 assert_eq!(fitted.width.to_bits(), natural.width.to_bits());
766 }
767 }
768
769 #[test]
770 fn non_additive_callback_fits_at_exact_natural_width() {
771 let measurer = DeterministicTextMeasurer::default().with_width_callback(|text, _| {
772 let count = text.chars().count();
773 count as f64 * 10.0 - count.saturating_sub(1) as f64
774 });
775 let style = TextStyle::default();
776 let text = "A V";
777
778 for mode in [WrapMode::SvgLike, WrapMode::HtmlLike] {
779 let natural = measurer.measure_wrapped(text, &style, None, mode);
780 assert_eq!(natural.width, 28.0);
781 let fitted = measurer.measure_wrapped(text, &style, Some(natural.width), mode);
782 assert_eq!(fitted.line_count, 1, "{mode:?}: {natural:?} -> {fitted:?}");
783 assert_eq!(fitted.width.to_bits(), natural.width.to_bits());
784 }
785 }
786
787 #[test]
788 fn callback_preserves_html_fixed_width_and_long_word_overflow() {
789 let measurer = DeterministicTextMeasurer::default()
790 .with_width_callback(|text, _| text.graphemes(true).count() as f64 * 10.0);
791 let style = TextStyle::default();
792
793 let breakable =
794 measurer.measure_wrapped("alpha beta", &style, Some(60.0), WrapMode::HtmlLike);
795 assert_eq!(breakable.line_count, 2);
796 assert_eq!(breakable.width, 60.0);
797
798 let long_word =
799 measurer.measure_wrapped("abcdefgh", &style, Some(30.0), WrapMode::HtmlLike);
800 assert_eq!(long_word.line_count, 1);
801 assert_eq!(long_word.width, 80.0);
802 }
803
804 #[test]
805 fn callback_svg_wrapping_splits_only_at_grapheme_boundaries() {
806 let measurer = DeterministicTextMeasurer::default()
807 .with_width_callback(|text, _| text.graphemes(true).count() as f64 * 10.0);
808
809 let metrics =
810 measurer.measure_wrapped("๐ฉโ๐ฌ๐จโ๐ฌ", &TextStyle::default(), Some(10.0), WrapMode::SvgLike);
811
812 assert_eq!(metrics.line_count, 2);
813 assert_eq!(metrics.width, 10.0);
814 }
815
816 #[test]
817 fn callback_receives_complete_text_and_returns_pixel_width() {
818 let calls = Arc::new(Mutex::new(Vec::new()));
819 let recorded = Arc::clone(&calls);
820 let measurer =
821 DeterministicTextMeasurer::default().with_width_callback(move |text, style| {
822 recorded
823 .lock()
824 .unwrap()
825 .push((text.to_string(), style.font_size));
826 37.0
827 });
828 let style = TextStyle {
829 font_size: 99.0,
830 ..TextStyle::default()
831 };
832
833 assert_eq!(measurer.measure("ffi", &style).width, 37.0);
834 assert_eq!(&*calls.lock().unwrap(), &[("ffi".to_string(), 99.0)]);
835 }
836}