1use super::heuristic::append_text_width_em;
4use super::html_wrap::{HtmlWhiteSpace, NormalizedHtmlText, plan_html_lines};
5use super::wrap::split_graphemes_to_width;
6use super::{
7 TextMeasurer, TextMetrics, TextStyle, WrapMode, trim_end_html_collapsible_ascii_whitespace,
8 trim_html_collapsible_ascii_whitespace,
9};
10use std::convert::Infallible;
11use std::sync::Arc;
12
13type TextWidthPxFn = Arc<dyn Fn(&str, &TextStyle) -> f64 + Send + Sync>;
14
15#[derive(Clone)]
16struct WidthCallbackTextMeasurer {
17 width_px: TextWidthPxFn,
18 line_height_factor: f64,
19}
20
21#[derive(Clone, Copy, Default)]
22struct LineWidthAccumulator {
23 heuristic_em: f64,
24 char_count: usize,
25}
26
27#[derive(Clone, Copy)]
28enum LineWidthSource<'a> {
29 Heuristic,
30 UniformAdvanceEm(f64),
31 Callback(&'a TextWidthPxFn),
32}
33
34#[derive(Clone, Copy)]
35struct LineWidthModel<'a> {
36 font_size: f64,
37 source: LineWidthSource<'a>,
38 style: &'a TextStyle,
39 html_text: bool,
40}
41
42impl LineWidthModel<'_> {
43 fn width_px(self, text: &str) -> f64 {
44 if let LineWidthSource::Callback(width_px) = self.source {
45 return width_px(text, self.style);
46 }
47
48 let mut width = LineWidthAccumulator::default();
49 self.append_builtin(&mut width, text);
50 self.finish_builtin(width)
51 }
52
53 fn candidate_width_px(
54 self,
55 width: LineWidthAccumulator,
56 candidate: &str,
57 appended: &str,
58 ) -> (LineWidthAccumulator, f64) {
59 if let LineWidthSource::Callback(width_px) = self.source {
60 return (width, width_px(candidate, self.style));
61 }
62
63 let mut width = width;
64 self.append_builtin(&mut width, appended);
65 (width, self.finish_builtin(width))
66 }
67
68 fn append_builtin(self, width: &mut LineWidthAccumulator, text: &str) {
69 match self.source {
70 LineWidthSource::Heuristic => append_text_width_em(&mut width.heuristic_em, text),
71 LineWidthSource::UniformAdvanceEm(_) => {
72 width.char_count = width.char_count.saturating_add(
73 text.chars()
74 .filter(|ch| !self.html_text || *ch != '\u{00ad}')
75 .count(),
76 );
77 }
78 LineWidthSource::Callback(_) => unreachable!("callback widths use complete strings"),
79 }
80 }
81
82 fn finish_builtin(self, width: LineWidthAccumulator) -> f64 {
83 match self.source {
84 LineWidthSource::Heuristic => width.heuristic_em * self.font_size,
85 LineWidthSource::UniformAdvanceEm(advance_em) => {
86 width.char_count as f64 * self.font_size * advance_em
87 }
88 LineWidthSource::Callback(_) => unreachable!("callback widths use complete strings"),
89 }
90 }
91}
92
93trait LineWidthProvider {
94 type Error;
95
96 fn width_px(&mut self, text: &str) -> Result<f64, Self::Error>;
97
98 fn begin_html_line_width(&self) -> Option<crate::environment::BuiltinHtmlLineWidth> {
99 None
100 }
101
102 fn final_width_px(&mut self, text: &str) -> Result<f64, Self::Error> {
103 self.width_px(text)
104 }
105
106 fn candidate_width_px(
107 &mut self,
108 width: LineWidthAccumulator,
109 candidate: &str,
110 appended: &str,
111 ) -> Result<(LineWidthAccumulator, f64), Self::Error>;
112}
113
114impl LineWidthProvider for LineWidthModel<'_> {
115 type Error = Infallible;
116
117 fn begin_html_line_width(&self) -> Option<crate::environment::BuiltinHtmlLineWidth> {
118 let factor = match self.source {
119 LineWidthSource::Heuristic => 0.0,
120 LineWidthSource::UniformAdvanceEm(factor) => factor,
121 LineWidthSource::Callback(_) => return None,
122 };
123 Some(crate::environment::BuiltinHtmlLineWidth::deterministic(
124 self.style, factor,
125 ))
126 }
127
128 fn width_px(&mut self, text: &str) -> Result<f64, Infallible> {
129 Ok((*self).width_px(text))
130 }
131
132 fn candidate_width_px(
133 &mut self,
134 width: LineWidthAccumulator,
135 candidate: &str,
136 appended: &str,
137 ) -> Result<(LineWidthAccumulator, f64), Infallible> {
138 Ok((*self).candidate_width_px(width, candidate, appended))
139 }
140}
141
142struct FallibleLineWidth<'a, F> {
143 width_px: F,
144 style: &'a TextStyle,
145}
146
147struct MeasuredLineWidth<'a, F, M> {
148 measure: F,
149 style: &'a TextStyle,
150 final_metrics: Vec<M>,
151}
152
153impl<E, F, M> LineWidthProvider for MeasuredLineWidth<'_, F, M>
154where
155 F: FnMut(&str, &TextStyle) -> Result<(f64, M), E>,
156{
157 type Error = E;
158
159 fn width_px(&mut self, text: &str) -> Result<f64, E> {
160 (self.measure)(text, self.style).map(|(width, _)| width)
161 }
162
163 fn final_width_px(&mut self, text: &str) -> Result<f64, E> {
164 let (width, metrics) = (self.measure)(text, self.style)?;
165 self.final_metrics.push(metrics);
166 Ok(width)
167 }
168
169 fn candidate_width_px(
170 &mut self,
171 width: LineWidthAccumulator,
172 candidate: &str,
173 _appended: &str,
174 ) -> Result<(LineWidthAccumulator, f64), E> {
175 self.width_px(candidate).map(|width_px| (width, width_px))
176 }
177}
178
179impl<E, F> LineWidthProvider for FallibleLineWidth<'_, F>
180where
181 F: FnMut(&str, &TextStyle) -> Result<f64, E>,
182{
183 type Error = E;
184
185 fn width_px(&mut self, text: &str) -> Result<f64, E> {
186 (self.width_px)(text, self.style)
187 }
188
189 fn candidate_width_px(
190 &mut self,
191 width: LineWidthAccumulator,
192 candidate: &str,
193 _appended: &str,
194 ) -> Result<(LineWidthAccumulator, f64), E> {
195 self.width_px(candidate).map(|width_px| (width, width_px))
196 }
197}
198
199#[derive(Debug, Clone)]
201pub struct WrappedTextLayout {
202 pub metrics: TextMetrics,
203 pub lines: Vec<String>,
204 pub raw_width: Option<f64>,
206}
207
208pub fn try_measure_wrapped_text<E>(
216 text: &str,
217 style: &TextStyle,
218 max_width: Option<f64>,
219 wrap_mode: WrapMode,
220 line_height_px: f64,
221 width_px: impl FnMut(&str, &TextStyle) -> Result<f64, E>,
222) -> Result<WrappedTextLayout, E> {
223 let mut width_model = FallibleLineWidth { width_px, style };
224 let mut layout = wrapped_text_layout(text, max_width, wrap_mode, &mut width_model)?;
225 layout.metrics.height = layout.metrics.line_count as f64 * line_height_px;
226 Ok(layout)
227}
228
229pub fn try_measure_wrapped_text_with_line_metrics<E, M>(
235 text: &str,
236 style: &TextStyle,
237 max_width: Option<f64>,
238 wrap_mode: WrapMode,
239 measure: impl FnMut(&str, &TextStyle) -> Result<(f64, M), E>,
240) -> Result<(WrappedTextLayout, Vec<M>), E> {
241 let mut width_model = MeasuredLineWidth {
242 measure,
243 style,
244 final_metrics: Vec::new(),
245 };
246 let layout = wrapped_text_layout(text, max_width, wrap_mode, &mut width_model)?;
247 Ok((layout, width_model.final_metrics))
248}
249
250#[derive(Debug, Clone, Default)]
251pub struct DeterministicTextMeasurer {
252 pub char_width_factor: f64,
253 pub line_height_factor: f64,
254}
255
256impl DeterministicTextMeasurer {
257 pub fn with_width_callback(
268 self,
269 width_px: impl Fn(&str, &TextStyle) -> f64 + Send + Sync + 'static,
270 ) -> Arc<dyn TextMeasurer + Send + Sync> {
271 Arc::new(WidthCallbackTextMeasurer {
272 width_px: Arc::new(width_px),
273 line_height_factor: self.line_height_factor,
274 })
275 }
276
277 fn collapse_svg_text_whitespace(text: &str) -> String {
278 let mut collapsed = String::with_capacity(text.len());
279 let mut pending_space = false;
280
281 for ch in text.chars() {
282 if matches!(ch, ' ' | '\t' | '\r' | '\n') {
283 pending_space = !collapsed.is_empty();
284 } else {
285 if pending_space {
286 collapsed.push(' ');
287 pending_space = false;
288 }
289 collapsed.push(ch);
290 }
291 }
292
293 collapsed
294 }
295
296 fn replace_br_variants(text: &str) -> String {
297 let mut out = String::with_capacity(text.len());
298 let mut i = 0usize;
299 while i < text.len() {
300 let Some(rest) = text.get(i..) else {
301 break;
302 };
303
304 if rest.starts_with('<') {
308 let bytes = text.as_bytes();
309 if i + 3 < bytes.len()
310 && matches!(bytes[i + 1], b'b' | b'B')
311 && matches!(bytes[i + 2], b'r' | b'R')
312 {
313 let mut j = i + 3;
314 while j < bytes.len() && matches!(bytes[j], b' ' | b'\t' | b'\r' | b'\n') {
315 j += 1;
316 }
317 if j < bytes.len() && bytes[j] == b'/' {
318 j += 1;
319 }
320 if j < bytes.len() && bytes[j] == b'>' {
321 out.push('\n');
322 i = j + 1;
323 continue;
324 }
325 }
326 }
327
328 let Some(ch) = rest.chars().next() else {
329 break;
330 };
331 out.push(ch);
332 i += ch.len_utf8();
333 }
334 out
335 }
336
337 fn normalized_text_lines_with(
338 text: &str,
339 trailing_line_is_empty: impl Fn(&str) -> bool,
340 ) -> Vec<String> {
341 let t = Self::replace_br_variants(text);
342 let mut out = t.split('\n').map(|s| s.to_string()).collect::<Vec<_>>();
343
344 while out.len() > 1 && out.last().is_some_and(|s| trailing_line_is_empty(s)) {
348 out.pop();
349 }
350
351 if out.is_empty() {
352 vec!["".to_string()]
353 } else {
354 out
355 }
356 }
357
358 pub fn normalized_text_lines(text: &str) -> Vec<String> {
359 Self::normalized_text_lines_with(text, |line| {
360 trim_html_collapsible_ascii_whitespace(line).is_empty()
361 })
362 }
363
364 pub(crate) fn split_line_to_words(text: &str) -> Vec<String> {
365 let parts = text.split(' ').collect::<Vec<_>>();
368 let mut out: Vec<String> = Vec::new();
369 for part in parts {
370 if !part.is_empty() {
371 out.push(part.to_string());
372 }
373 out.push(" ".to_string());
374 }
375 while out.last().is_some_and(|s| s == " ") {
376 out.pop();
377 }
378 out
379 }
380
381 fn wrapped_line_has_visible_content(text: &str) -> bool {
382 !trim_html_collapsible_ascii_whitespace(text).is_empty()
383 }
384
385 fn split_token_to_width<'t, W: LineWidthProvider>(
386 token: &'t str,
387 max_width_px: f64,
388 width_model: &mut W,
389 ) -> Result<(&'t str, &'t str), W::Error> {
390 let mut width = LineWidthAccumulator::default();
391 split_graphemes_to_width(token, max_width_px, |candidate, grapheme| {
392 let (candidate_width, candidate_width_px) =
393 width_model.candidate_width_px(width, candidate, grapheme)?;
394 width = candidate_width;
395 Ok(candidate_width_px)
396 })
397 }
398
399 fn wrap_line<W: LineWidthProvider>(
400 line: &str,
401 max_width_px: f64,
402 break_long_words: bool,
403 width_model: &mut W,
404 ) -> Result<Vec<String>, W::Error> {
405 if !max_width_px.is_finite() || max_width_px <= 0.0 {
406 return Ok(vec![line.to_string()]);
407 }
408
409 let tokens = Self::split_line_to_words(line);
410 let mut tokens = std::collections::VecDeque::from(tokens);
411 let mut out: Vec<String> = Vec::new();
412 let mut cur = String::new();
413 let mut cur_width = LineWidthAccumulator::default();
414
415 while let Some(tok) = tokens.pop_front() {
416 if cur.is_empty() && tok == " " {
417 continue;
418 }
419
420 let previous_len = cur.len();
421 cur.push_str(&tok);
422 let (candidate_width, candidate_width_px) =
423 width_model.candidate_width_px(cur_width, &cur, &tok)?;
424 if candidate_width_px <= max_width_px {
425 cur_width = candidate_width;
426 continue;
427 }
428 cur.truncate(previous_len);
429
430 if Self::wrapped_line_has_visible_content(&cur) {
431 let line = trim_end_html_collapsible_ascii_whitespace(&cur).to_string();
432 cur.clear();
433 out.push(line);
434 cur_width = LineWidthAccumulator::default();
435 tokens.push_front(tok);
436 continue;
437 }
438
439 if tok == " " {
441 continue;
442 }
443 if !break_long_words {
444 out.push(tok);
445 } else {
446 let (head, tail) = Self::split_token_to_width(&tok, max_width_px, width_model)?;
449 out.push(head.to_string());
450 if !tail.is_empty() {
451 tokens.push_front(tail.to_string());
452 }
453 }
454 }
455
456 if Self::wrapped_line_has_visible_content(&cur) {
457 out.push(trim_end_html_collapsible_ascii_whitespace(&cur).to_string());
458 }
459
460 if out.is_empty() {
461 Ok(vec!["".to_string()])
462 } else {
463 Ok(out)
464 }
465 }
466}
467
468impl TextMeasurer for DeterministicTextMeasurer {
469 fn measure_html_line_width_px(&self, text: &str, style: &TextStyle) -> f64 {
470 let source = if self.char_width_factor == 0.0 {
471 LineWidthSource::Heuristic
472 } else {
473 LineWidthSource::UniformAdvanceEm(self.char_width_factor)
474 };
475 let model = LineWidthModel {
476 font_size: style.font_size.max(1.0),
477 source,
478 style,
479 html_text: true,
480 };
481 model.width_px(text).max(0.0)
482 }
483
484 #[allow(private_interfaces)]
485 fn begin_html_line_width(
486 &self,
487 style: &TextStyle,
488 ) -> Option<crate::environment::BuiltinHtmlLineWidth> {
489 Some(crate::environment::BuiltinHtmlLineWidth::deterministic(
490 style,
491 self.char_width_factor,
492 ))
493 }
494
495 #[allow(private_interfaces)]
496 fn begin_svg_text_computed_length(
497 &self,
498 style: &TextStyle,
499 ) -> Option<crate::environment::BuiltinSvgComputedLength> {
500 (self.char_width_factor == 0.0)
501 .then(|| crate::environment::BuiltinSvgComputedLength::deterministic(style))
502 }
503
504 fn measure(&self, text: &str, style: &TextStyle) -> TextMetrics {
505 self.measure_wrapped(text, style, None, WrapMode::SvgLike)
506 }
507
508 fn measure_wrapped(
509 &self,
510 text: &str,
511 style: &TextStyle,
512 max_width: Option<f64>,
513 wrap_mode: WrapMode,
514 ) -> TextMetrics {
515 self.measure_wrapped_impl(text, style, max_width, wrap_mode)
516 .0
517 }
518
519 fn measure_wrapped_with_raw_width(
520 &self,
521 text: &str,
522 style: &TextStyle,
523 max_width: Option<f64>,
524 wrap_mode: WrapMode,
525 ) -> (TextMetrics, Option<f64>) {
526 self.measure_wrapped_impl(text, style, max_width, wrap_mode)
527 }
528
529 fn measure_mermaid_calculate_text_dimensions(
530 &self,
531 text: &str,
532 style: &TextStyle,
533 ) -> TextMetrics {
534 let collapsed = Self::collapse_svg_text_whitespace(text);
535 TextMetrics {
536 width: self.measure_svg_simple_text_bbox_width_for_wrap_px(&collapsed, style),
537 height: self.measure_svg_simple_text_bbox_height_px(&collapsed, style),
538 line_count: 1,
539 }
540 }
541
542 fn measure_svg_simple_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
543 let t = trim_end_html_collapsible_ascii_whitespace(text);
544 if t.is_empty() {
545 return 0.0;
546 }
547 (style.font_size.max(1.0) * 1.1).max(0.0)
548 }
549
550 fn measure_svg_tspan_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
551 if trim_end_html_collapsible_ascii_whitespace(text).is_empty() {
552 0.0
553 } else {
554 super::svg_wrapped_first_line_bbox_height_px(style)
555 }
556 }
557}
558
559impl TextMeasurer for WidthCallbackTextMeasurer {
560 fn measure_html_line_width_px(&self, text: &str, style: &TextStyle) -> f64 {
561 (self.width_px)(text, style)
562 }
563
564 fn measure(&self, text: &str, style: &TextStyle) -> TextMetrics {
565 self.measure_wrapped(text, style, None, WrapMode::SvgLike)
566 }
567
568 fn measure_wrapped(
569 &self,
570 text: &str,
571 style: &TextStyle,
572 max_width: Option<f64>,
573 wrap_mode: WrapMode,
574 ) -> TextMetrics {
575 DeterministicTextMeasurer::measure_wrapped_impl_with_width(
576 text,
577 style,
578 max_width,
579 wrap_mode,
580 LineWidthSource::Callback(&self.width_px),
581 self.line_height_factor,
582 )
583 .0
584 }
585
586 fn measure_wrapped_with_raw_width(
587 &self,
588 text: &str,
589 style: &TextStyle,
590 max_width: Option<f64>,
591 wrap_mode: WrapMode,
592 ) -> (TextMetrics, Option<f64>) {
593 DeterministicTextMeasurer::measure_wrapped_impl_with_width(
594 text,
595 style,
596 max_width,
597 wrap_mode,
598 LineWidthSource::Callback(&self.width_px),
599 self.line_height_factor,
600 )
601 }
602
603 fn measure_mermaid_calculate_text_dimensions(
604 &self,
605 text: &str,
606 style: &TextStyle,
607 ) -> TextMetrics {
608 let collapsed = DeterministicTextMeasurer::collapse_svg_text_whitespace(text);
609 TextMetrics {
610 width: self.measure_svg_simple_text_bbox_width_for_wrap_px(&collapsed, style),
611 height: self.measure_svg_simple_text_bbox_height_px(&collapsed, style),
612 line_count: 1,
613 }
614 }
615
616 fn measure_svg_simple_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
617 let text = trim_end_html_collapsible_ascii_whitespace(text);
618 if text.is_empty() {
619 return 0.0;
620 }
621 (style.font_size.max(1.0) * 1.1).max(0.0)
622 }
623
624 fn measure_svg_tspan_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
625 if trim_end_html_collapsible_ascii_whitespace(text).is_empty() {
626 0.0
627 } else {
628 super::svg_wrapped_first_line_bbox_height_px(style)
629 }
630 }
631}
632
633impl DeterministicTextMeasurer {
634 fn measure_wrapped_impl(
635 &self,
636 text: &str,
637 style: &TextStyle,
638 max_width: Option<f64>,
639 wrap_mode: WrapMode,
640 ) -> (TextMetrics, Option<f64>) {
641 let width_source = if self.char_width_factor == 0.0 {
642 LineWidthSource::Heuristic
643 } else {
644 LineWidthSource::UniformAdvanceEm(self.char_width_factor)
645 };
646 Self::measure_wrapped_impl_with_width(
647 text,
648 style,
649 max_width,
650 wrap_mode,
651 width_source,
652 self.line_height_factor,
653 )
654 }
655
656 fn measure_wrapped_impl_with_width(
657 text: &str,
658 style: &TextStyle,
659 max_width: Option<f64>,
660 wrap_mode: WrapMode,
661 width_source: LineWidthSource<'_>,
662 configured_line_height_factor: f64,
663 ) -> (TextMetrics, Option<f64>) {
664 let default_line_height_factor = match wrap_mode {
665 WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => 1.1,
666 WrapMode::HtmlLike => 1.5,
667 };
668 let line_height_factor = if configured_line_height_factor == 0.0 {
669 default_line_height_factor
670 } else {
671 configured_line_height_factor
672 };
673
674 let font_size = style.font_size.max(1.0);
675 let mut width_model = LineWidthModel {
676 font_size,
677 source: width_source,
678 style,
679 html_text: wrap_mode == WrapMode::HtmlLike,
680 };
681 let mut layout = match wrapped_text_layout(text, max_width, wrap_mode, &mut width_model) {
682 Ok(layout) => layout,
683 Err(error) => match error {},
684 };
685 layout.metrics.height = layout.metrics.line_count as f64 * font_size * line_height_factor;
686 (layout.metrics, layout.raw_width)
687 }
688}
689
690fn wrapped_text_layout<W: LineWidthProvider>(
691 text: &str,
692 max_width: Option<f64>,
693 wrap_mode: WrapMode,
694 width_model: &mut W,
695) -> Result<WrappedTextLayout, W::Error> {
696 let max_width = max_width.filter(|width| width.is_finite() && *width > 0.0);
697 let break_long_words = matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun);
698 let raw_lines = DeterministicTextMeasurer::normalized_text_lines(text);
699 let raw_width = if wrap_mode == WrapMode::HtmlLike || max_width.is_none() {
700 let mut width = 0.0_f64;
701 for line in &raw_lines {
702 width = width.max(if max_width.is_none() {
703 width_model.final_width_px(line)?
704 } else {
705 width_model.width_px(line)?
706 });
707 }
708 Some(width)
709 } else {
710 None
711 };
712 let html_break_spaces_active = wrap_mode == WrapMode::HtmlLike
713 && max_width.is_some_and(|width| raw_width.is_some_and(|raw| raw > width));
714 let mut lines = Vec::new();
715 for line in raw_lines {
716 if wrap_mode == WrapMode::HtmlLike
717 && let Some(width) = max_width
718 {
719 let line = if html_break_spaces_active {
720 line.as_str()
721 } else {
722 trim_html_collapsible_ascii_whitespace(&line)
723 };
724 let normalized = NormalizedHtmlText::single_mode(line, HtmlWhiteSpace::BreakSpaces);
725 let builtin = width_model.begin_html_line_width();
726 let planned = plan_html_lines(
727 &normalized,
728 Some(width),
729 builtin,
730 &mut |text| width_model.width_px(text),
731 &mut || Ok(()),
732 )?;
733 lines.extend(planned.into_iter().map(|line| line.text));
734 } else if let Some(width) = max_width {
735 lines.extend(DeterministicTextMeasurer::wrap_line(
736 &line,
737 width,
738 break_long_words,
739 width_model,
740 )?);
741 } else {
742 lines.push(line);
743 }
744 }
745 let mut width = if max_width.is_none() {
746 raw_width.unwrap_or_default()
747 } else {
748 let mut width = 0.0_f64;
749 for line in &lines {
750 width = width.max(width_model.final_width_px(line)?);
751 }
752 width
753 };
754 if html_break_spaces_active && let Some(max_width) = max_width {
755 width = width.max(max_width);
757 }
758 Ok(WrappedTextLayout {
759 metrics: TextMetrics {
760 width,
761 height: 0.0,
762 line_count: lines.len(),
763 },
764 lines,
765 raw_width: if wrap_mode == WrapMode::HtmlLike {
766 raw_width
767 } else {
768 None
769 },
770 })
771}
772
773#[cfg(test)]
774mod tests {
775 #[test]
776 fn final_row_payloads_match_emitted_rows_without_additional_probes() {
777 let style = super::TextStyle::default();
778 for mode in [
779 super::WrapMode::SvgLike,
780 super::WrapMode::SvgLikeSingleRun,
781 super::WrapMode::HtmlLike,
782 ] {
783 for limit in [None, Some(45.0), Some(0.0), Some(f64::NAN)] {
784 for label in [
785 "alpha beta<br>last",
786 "",
787 " ",
788 "A<br><br>B",
789 "alpha beta ",
790 "\u{a0}A\u{a0}",
791 "👩🔬👨🔬",
792 ] {
793 let mut expected_calls = Vec::new();
794 let expected = super::try_measure_wrapped_text(
795 label,
796 &style,
797 limit,
798 mode,
799 0.0,
800 |text, _| {
801 expected_calls.push(text.to_owned());
802 Ok::<_, ()>(text.len() as f64 * 10.0)
803 },
804 )
805 .unwrap();
806 let mut actual_calls = Vec::new();
807 let (actual, payloads) = super::try_measure_wrapped_text_with_line_metrics(
808 label,
809 &style,
810 limit,
811 mode,
812 |text, _| {
813 actual_calls.push(text.to_owned());
814 Ok::<_, ()>((text.len() as f64 * 10.0, text.to_owned()))
815 },
816 )
817 .unwrap();
818 assert_eq!(actual_calls, expected_calls);
819 assert_eq!(payloads, actual.lines);
820 assert_eq!(actual.lines, expected.lines);
821 assert_eq!(actual.metrics.width, expected.metrics.width);
822 assert_eq!(actual.raw_width, expected.raw_width);
823 }
824 }
825 }
826 }
827
828 #[test]
829 fn fallible_html_wrapping_preserves_unicode_and_discretionary_breaks() {
830 let measurer = super::DeterministicTextMeasurer {
831 char_width_factor: 1.0,
832 ..super::DeterministicTextMeasurer::default()
833 };
834 let style = super::TextStyle {
835 font_size: 10.0,
836 ..super::TextStyle::default()
837 };
838 for label in [
839 "负责人审批",
840 "alpha beta",
841 "half-rounded",
842 "inter\u{00ad}face",
843 " A B\u{00a0} ",
844 "a b",
845 ] {
846 for limit in [Some(25.0), Some(50.0), Some(100.0), None] {
847 let expected = super::TextMeasurer::measure_wrapped_with_raw_width(
848 &measurer,
849 label,
850 &style,
851 limit,
852 super::WrapMode::HtmlLike,
853 );
854 let actual = super::try_measure_wrapped_text(
855 label,
856 &style,
857 limit,
858 super::WrapMode::HtmlLike,
859 15.0,
860 |text, _| {
861 Ok::<_, ()>(
862 text.chars().filter(|ch| *ch != '\u{00ad}').count() as f64 * 10.0,
863 )
864 },
865 )
866 .unwrap();
867 assert_eq!(
868 actual.metrics.width, expected.0.width,
869 "{label:?}, {limit:?}"
870 );
871 assert_eq!(
872 actual.metrics.height, expected.0.height,
873 "{label:?}, {limit:?}"
874 );
875 assert_eq!(
876 actual.metrics.line_count, expected.0.line_count,
877 "{label:?}, {limit:?}"
878 );
879 assert_eq!(actual.raw_width, expected.1, "{label:?}, {limit:?}");
880 }
881 }
882 }
883
884 #[test]
885 fn final_row_payload_errors_stop_measurement_immediately() {
886 let mut calls = 0;
887 let result = super::try_measure_wrapped_text_with_line_metrics(
888 "first<br>second",
889 &super::TextStyle::default(),
890 None,
891 super::WrapMode::SvgLike,
892 |text, _| {
893 calls += 1;
894 if text == "second" {
895 Err("canceled")
896 } else {
897 Ok((1.0, ()))
898 }
899 },
900 );
901 assert_eq!(result.unwrap_err(), "canceled");
902 assert_eq!(calls, 2);
903 }
904
905 use super::{
906 DeterministicTextMeasurer, LineWidthModel, LineWidthSource, try_measure_wrapped_text,
907 };
908 use crate::text::{TextMeasurer, TextStyle, WrapMode};
909 use std::sync::{Arc, Mutex};
910 use unicode_segmentation::UnicodeSegmentation;
911
912 #[test]
913 fn wrapping_uses_estimated_width_instead_of_character_count() {
914 let measurer = DeterministicTextMeasurer::default();
915 let metrics = measurer.measure_wrapped(
916 "iiii WWWW",
917 &TextStyle {
918 font_size: 10.0,
919 ..TextStyle::default()
920 },
921 Some(20.0),
922 WrapMode::SvgLike,
923 );
924
925 assert_eq!(metrics.line_count, 3);
926 assert!((metrics.width - 17.0).abs() < f64::EPSILON, "{metrics:?}");
927 }
928
929 #[test]
930 fn long_word_splitting_keeps_zero_width_scalars_with_visible_text() {
931 let (head, tail) = DeterministicTextMeasurer::split_token_to_width(
932 "W\u{0301}W",
933 8.0,
934 &mut LineWidthModel {
935 font_size: 10.0,
936 source: LineWidthSource::Heuristic,
937 style: &TextStyle::default(),
938 html_text: false,
939 },
940 )
941 .unwrap();
942 assert_eq!(head, "W\u{0301}");
943 assert_eq!(tail, "W");
944
945 let metrics = DeterministicTextMeasurer::default().measure_wrapped(
946 "\u{0301}WW",
947 &TextStyle {
948 font_size: 10.0,
949 ..TextStyle::default()
950 },
951 Some(8.5),
952 WrapMode::SvgLike,
953 );
954
955 assert_eq!(metrics.line_count, 2);
956 assert!((metrics.width - 8.5).abs() < f64::EPSILON, "{metrics:?}");
957 }
958
959 #[test]
960 fn default_width_uses_sequence_aware_unicode_display_width() {
961 let measurer = DeterministicTextMeasurer::default();
962 let style = TextStyle {
963 font_size: 16.0,
964 ..TextStyle::default()
965 };
966
967 for sequence in ["👩🔬", "👨👩👧👦", "👍🏽", "🇨🇳", "1️⃣"]
968 {
969 let metrics = measurer.measure(sequence, &style);
970 assert_eq!(metrics.width, 16.0, "sequence={sequence:?}");
971 }
972 }
973
974 #[test]
975 fn wrapping_splits_long_emoji_runs_only_at_grapheme_boundaries() {
976 let measurer = DeterministicTextMeasurer::default();
977 let style = TextStyle {
978 font_size: 16.0,
979 ..TextStyle::default()
980 };
981 let mut width_model = LineWidthModel {
982 font_size: 16.0,
983 source: LineWidthSource::Heuristic,
984 style: &style,
985 html_text: false,
986 };
987
988 for (text, first, second) in [
989 ("👩🔬👨🔬", "👩🔬", "👨🔬"),
990 ("👍🏽👍🏻", "👍🏽", "👍🏻"),
991 ("🇨🇳🇺🇸", "🇨🇳", "🇺🇸"),
992 ("1️⃣2️⃣", "1️⃣", "2️⃣"),
993 ] {
994 let (head, tail) =
995 DeterministicTextMeasurer::split_token_to_width(text, 16.0, &mut width_model)
996 .unwrap();
997 assert_eq!((head, tail), (first, second), "text={text:?}");
998
999 let metrics = measurer.measure_wrapped(text, &style, Some(16.0), WrapMode::SvgLike);
1000 assert_eq!(metrics.line_count, 2, "text={text:?}: {metrics:?}");
1001 assert_eq!(metrics.width, 16.0, "text={text:?}: {metrics:?}");
1002 }
1003 }
1004
1005 #[test]
1006 fn html_long_words_preserve_their_min_content_width() {
1007 let measurer = DeterministicTextMeasurer::default();
1008 let style = TextStyle {
1009 font_size: 10.0,
1010 ..TextStyle::default()
1011 };
1012
1013 let metrics = measurer.measure_wrapped(
1014 "Supercalifragilisticexpialidocious",
1015 &style,
1016 Some(20.0),
1017 WrapMode::HtmlLike,
1018 );
1019
1020 assert_eq!(metrics.line_count, 1);
1021 assert!(metrics.width > 20.0, "{metrics:?}");
1022 }
1023
1024 #[test]
1025 fn html_wrapping_uses_unicode_soft_breaks_and_fixed_container_width() {
1026 let measurer = DeterministicTextMeasurer::default();
1027 let style = TextStyle {
1028 font_size: 10.0,
1029 ..TextStyle::default()
1030 };
1031
1032 let cjk = measurer.measure_wrapped("负责人审批", &style, Some(25.0), WrapMode::HtmlLike);
1033 assert_eq!(cjk.line_count, 3);
1034 assert_eq!(cjk.width.to_bits(), 25.0_f64.to_bits());
1035
1036 let breakable =
1037 measurer.measure_wrapped("alpha beta", &style, Some(40.0), WrapMode::HtmlLike);
1038 assert_eq!(breakable.line_count, 2);
1039 assert_eq!(breakable.width.to_bits(), 40.0_f64.to_bits());
1040
1041 let hyphenated =
1042 measurer.measure_wrapped("half-rounded", &style, Some(21.5), WrapMode::HtmlLike);
1043 assert_eq!(hyphenated.line_count, 2);
1044 assert!(
1045 hyphenated.width > 21.5,
1046 "unbreakable segment keeps min-content width: {hyphenated:?}"
1047 );
1048
1049 let preserved_spaces =
1050 measurer.measure_wrapped("a b", &style, Some(7.0), WrapMode::HtmlLike);
1051 assert_eq!(preserved_spaces.line_count, 3);
1052 assert!(preserved_spaces.width > 7.0, "{preserved_spaces:?}");
1053 }
1054
1055 #[test]
1056 fn html_soft_hyphen_is_hidden_until_selected_and_contributes_its_visible_width() {
1057 let measurer = DeterministicTextMeasurer {
1058 char_width_factor: 1.0,
1059 ..DeterministicTextMeasurer::default()
1060 };
1061 let style = TextStyle {
1062 font_size: 10.0,
1063 ..TextStyle::default()
1064 };
1065 let text = "alpha\u{00ad}beta";
1066
1067 let natural = measurer.measure_wrapped(text, &style, None, WrapMode::HtmlLike);
1068 assert_eq!(natural.width, 90.0);
1069 assert_eq!(natural.line_count, 1);
1070 let fitted = measurer.measure_wrapped(text, &style, Some(90.0), WrapMode::HtmlLike);
1071 assert_eq!(fitted.width, 90.0);
1072 assert_eq!(fitted.line_count, 1);
1073
1074 let wrapped = measurer.measure_wrapped(text, &style, Some(60.0), WrapMode::HtmlLike);
1075 assert_eq!(wrapped.line_count, 2);
1076 assert_eq!(wrapped.width, 60.0);
1077 let (wrapped, raw_width) =
1078 measurer.measure_wrapped_with_raw_width(text, &style, Some(50.0), WrapMode::HtmlLike);
1079 assert_eq!(wrapped.line_count, 2);
1080 assert_eq!(
1081 wrapped.width, 60.0,
1082 "the selected hyphen contributes min-content width"
1083 );
1084 assert_eq!(raw_width, Some(90.0));
1085 }
1086
1087 #[test]
1088 fn html_inactive_break_spaces_trims_only_collapsible_edges_and_keeps_raw_width() {
1089 let measurer = DeterministicTextMeasurer {
1090 char_width_factor: 1.0,
1091 ..DeterministicTextMeasurer::default()
1092 };
1093 let style = TextStyle {
1094 font_size: 10.0,
1095 ..TextStyle::default()
1096 };
1097 let text = " A B\u{00a0} ";
1098 let natural = measurer.measure_wrapped(text, &style, None, WrapMode::HtmlLike);
1099 assert_eq!(natural.width, 70.0);
1100 let (fitted, raw) =
1101 measurer.measure_wrapped_with_raw_width(text, &style, Some(70.0), WrapMode::HtmlLike);
1102 assert_eq!(fitted.width, 50.0);
1103 assert_eq!(fitted.line_count, 1);
1104 assert_eq!(raw, Some(70.0));
1105
1106 let (active, raw) =
1107 measurer.measure_wrapped_with_raw_width(text, &style, Some(60.0), WrapMode::HtmlLike);
1108 assert_eq!(active.line_count, 2);
1109 assert_eq!(active.width, 60.0);
1110 assert_eq!(raw, Some(70.0));
1111 }
1112
1113 #[test]
1114 fn html_soft_hyphen_callback_preserves_shaping_controls_in_complete_candidates() {
1115 let calls = Arc::new(Mutex::new(Vec::new()));
1116 let recorded = Arc::clone(&calls);
1117 let measurer = DeterministicTextMeasurer::default().with_width_callback(move |text, _| {
1118 recorded.lock().unwrap().push(text.to_string());
1119 match text {
1120 "f\u{00ad}fi" => 18.0,
1121 "f-" => 16.0,
1122 "fi" => 11.0,
1123 _ => text.chars().filter(|ch| *ch != '\u{00ad}').count() as f64 * 10.0,
1124 }
1125 });
1126 let style = TextStyle::default();
1127 let text = "f\u{00ad}fi";
1128
1129 let natural = measurer.measure_wrapped(text, &style, None, WrapMode::HtmlLike);
1130 assert_eq!(natural.width, 18.0);
1131 assert_eq!(&*calls.lock().unwrap(), &["f\u{00ad}fi"]);
1132 calls.lock().unwrap().clear();
1133
1134 let wrapped = measurer.measure_wrapped(text, &style, Some(16.0), WrapMode::HtmlLike);
1135 assert_eq!(wrapped.line_count, 2);
1136 assert_eq!(wrapped.width, 16.0);
1137 let calls = calls.lock().unwrap();
1138 assert!(calls.iter().any(|candidate| candidate == "f\u{00ad}fi"));
1139 assert!(calls.iter().any(|candidate| candidate == "f-"));
1140 assert!(calls.iter().any(|candidate| candidate == "fi"));
1141 }
1142
1143 #[test]
1144 fn decoded_html_line_width_keeps_literal_markup_and_matches_streamed_candidates() {
1145 let style = TextStyle {
1146 font_size: 10.0,
1147 ..TextStyle::default()
1148 };
1149 for char_width_factor in [0.0, 1.0] {
1150 let measurer = DeterministicTextMeasurer {
1151 char_width_factor,
1152 ..DeterministicTextMeasurer::default()
1153 };
1154 let text = "left<br>👩🔬A\u{301}right";
1155 let expected = measurer.measure_html_line_width_px(text, &style);
1156 let mut streamed = measurer
1157 .begin_html_line_width(&style)
1158 .expect("built-in line width");
1159 for part in ["left<", "br>", "👩", "\u{200d}", "🔬A", "\u{301}right"] {
1160 streamed.push_text(part);
1161 }
1162 assert_eq!(streamed.width_px().to_bits(), expected.to_bits());
1163 assert!(
1164 expected
1165 > measurer
1166 .measure_wrapped(text, &style, None, WrapMode::HtmlLike)
1167 .width
1168 );
1169 assert_eq!(
1170 measurer
1171 .measure_html_line_width_px("a\u{00ad}b", &style)
1172 .to_bits(),
1173 measurer.measure_html_line_width_px("ab", &style).to_bits()
1174 );
1175 }
1176
1177 let calls = Arc::new(Mutex::new(Vec::new()));
1178 let recorded = Arc::clone(&calls);
1179 let callback = DeterministicTextMeasurer::default().with_width_callback(move |text, _| {
1180 recorded.lock().unwrap().push(text.to_string());
1181 23.5
1182 });
1183 assert_eq!(callback.measure_html_line_width_px("a<br>b", &style), 23.5);
1184 assert_eq!(&*calls.lock().unwrap(), &["a<br>b"]);
1185 }
1186
1187 #[test]
1188 fn repeated_measurement_is_bitwise_deterministic() {
1189 let measurer = DeterministicTextMeasurer::default();
1190 let style = TextStyle {
1191 font_size: 16.0,
1192 ..TextStyle::default()
1193 };
1194 let first = measurer.measure_wrapped(
1195 "Latin / 中文 / 🙂 / e\u{0301}",
1196 &style,
1197 Some(80.0),
1198 WrapMode::SvgLike,
1199 );
1200 let second = measurer.measure_wrapped(
1201 "Latin / 中文 / 🙂 / e\u{0301}",
1202 &style,
1203 Some(80.0),
1204 WrapMode::SvgLike,
1205 );
1206
1207 assert_eq!(first.width.to_bits(), second.width.to_bits());
1208 assert_eq!(first.height.to_bits(), second.height.to_bits());
1209 assert_eq!(first.line_count, second.line_count);
1210 }
1211
1212 #[test]
1213 fn exact_natural_width_does_not_trigger_a_spurious_wrap() {
1214 let measurer = DeterministicTextMeasurer::default();
1215 let style = TextStyle {
1216 font_size: 16.0,
1217 ..TextStyle::default()
1218 };
1219 let text = "FtQHasK pGRJ";
1220
1221 for mode in [WrapMode::SvgLike, WrapMode::HtmlLike] {
1222 let natural = measurer.measure_wrapped(text, &style, None, mode);
1223 let fitted = measurer.measure_wrapped(text, &style, Some(natural.width), mode);
1224 assert_eq!(fitted.line_count, 1, "{mode:?}: {natural:?} -> {fitted:?}");
1225 assert_eq!(fitted.width.to_bits(), natural.width.to_bits());
1226 }
1227 }
1228
1229 #[test]
1230 fn non_additive_callback_fits_at_exact_natural_width() {
1231 let measurer = DeterministicTextMeasurer::default().with_width_callback(|text, _| {
1232 let count = text.chars().count();
1233 count as f64 * 10.0 - count.saturating_sub(1) as f64
1234 });
1235 let style = TextStyle::default();
1236 let text = "A V";
1237
1238 for mode in [WrapMode::SvgLike, WrapMode::HtmlLike] {
1239 let natural = measurer.measure_wrapped(text, &style, None, mode);
1240 assert_eq!(natural.width, 28.0);
1241 let fitted = measurer.measure_wrapped(text, &style, Some(natural.width), mode);
1242 assert_eq!(fitted.line_count, 1, "{mode:?}: {natural:?} -> {fitted:?}");
1243 assert_eq!(fitted.width.to_bits(), natural.width.to_bits());
1244 }
1245 }
1246
1247 #[test]
1248 fn callback_preserves_html_fixed_width_and_long_word_overflow() {
1249 let measurer = DeterministicTextMeasurer::default()
1250 .with_width_callback(|text, _| text.graphemes(true).count() as f64 * 10.0);
1251 let style = TextStyle::default();
1252
1253 let breakable =
1254 measurer.measure_wrapped("alpha beta", &style, Some(60.0), WrapMode::HtmlLike);
1255 assert_eq!(breakable.line_count, 2);
1256 assert_eq!(breakable.width, 60.0);
1257
1258 let long_word =
1259 measurer.measure_wrapped("abcdefgh", &style, Some(30.0), WrapMode::HtmlLike);
1260 assert_eq!(long_word.line_count, 1);
1261 assert_eq!(long_word.width, 80.0);
1262 }
1263
1264 #[test]
1265 fn callback_svg_wrapping_splits_only_at_grapheme_boundaries() {
1266 let measurer = DeterministicTextMeasurer::default()
1267 .with_width_callback(|text, _| text.graphemes(true).count() as f64 * 10.0);
1268
1269 let metrics =
1270 measurer.measure_wrapped("👩🔬👨🔬", &TextStyle::default(), Some(10.0), WrapMode::SvgLike);
1271
1272 assert_eq!(metrics.line_count, 2);
1273 assert_eq!(metrics.width, 10.0);
1274 }
1275
1276 #[test]
1277 fn callback_receives_complete_text_and_returns_pixel_width() {
1278 let calls = Arc::new(Mutex::new(Vec::new()));
1279 let recorded = Arc::clone(&calls);
1280 let measurer =
1281 DeterministicTextMeasurer::default().with_width_callback(move |text, style| {
1282 recorded
1283 .lock()
1284 .unwrap()
1285 .push((text.to_string(), style.font_size));
1286 37.0
1287 });
1288 let style = TextStyle {
1289 font_size: 99.0,
1290 ..TextStyle::default()
1291 };
1292
1293 assert_eq!(measurer.measure("ffi", &style).width, 37.0);
1294 assert_eq!(&*calls.lock().unwrap(), &[("ffi".to_string(), 99.0)]);
1295 }
1296
1297 #[test]
1298 fn shared_wrapping_matches_existing_modes_and_preserves_native_line_height() {
1299 let width = |text: &str| {
1300 let count = text.graphemes(true).count();
1301 count as f64 * 10.0 - count.saturating_sub(1) as f64
1302 };
1303 let measurer =
1304 DeterministicTextMeasurer::default().with_width_callback(move |text, _| width(text));
1305 let style = TextStyle::default();
1306
1307 for mode in [
1308 WrapMode::SvgLike,
1309 WrapMode::SvgLikeSingleRun,
1310 WrapMode::HtmlLike,
1311 ] {
1312 for text in [
1313 "",
1314 "a b",
1315 "alpha beta",
1316 "abcdefgh",
1317 "甲乙丙",
1318 "👩🔬👨🔬",
1319 "a<BR/>b\n",
1320 ] {
1321 for limit in [None, Some(20.0), Some(60.0), Some(f64::NAN)] {
1322 let expected =
1323 measurer.measure_wrapped_with_raw_width(text, &style, limit, mode);
1324 let actual =
1325 try_measure_wrapped_text(text, &style, limit, mode, 23.75, |text, _| {
1326 Ok::<_, ()>(width(text))
1327 })
1328 .unwrap();
1329 assert_eq!(actual.metrics.width.to_bits(), expected.0.width.to_bits());
1330 assert_eq!(actual.metrics.line_count, expected.0.line_count);
1331 assert_eq!(actual.raw_width, expected.1);
1332 assert_eq!(actual.lines.len(), actual.metrics.line_count);
1333 assert_eq!(actual.metrics.height, actual.lines.len() as f64 * 23.75);
1334 }
1335 }
1336 }
1337 }
1338
1339 #[test]
1340 fn shared_wrapping_returns_emitted_grapheme_lines_and_html_raw_width() {
1341 let style = TextStyle::default();
1342 let svg = try_measure_wrapped_text(
1343 "👩🔬👨🔬",
1344 &style,
1345 Some(10.0),
1346 WrapMode::SvgLike,
1347 17.0,
1348 |text, _| Ok::<_, ()>(text.graphemes(true).count() as f64 * 10.0),
1349 )
1350 .unwrap();
1351 assert_eq!(svg.lines, ["👩🔬", "👨🔬"]);
1352 assert_eq!(svg.metrics.height, 34.0);
1353 assert_eq!(svg.raw_width, None);
1354
1355 let html = try_measure_wrapped_text(
1356 "alpha beta",
1357 &style,
1358 Some(60.0),
1359 WrapMode::HtmlLike,
1360 19.0,
1361 |text, _| Ok::<_, ()>(text.len() as f64 * 10.0),
1362 )
1363 .unwrap();
1364 assert_eq!(html.lines, ["alpha ", "beta"]);
1365 assert_eq!(html.metrics.width, 60.0);
1366 assert_eq!(html.metrics.height, 38.0);
1367 assert_eq!(html.raw_width, Some(100.0));
1368 }
1369
1370 #[test]
1371 fn shared_wrapping_propagates_errors_without_further_probes() {
1372 let style = TextStyle::default();
1373 for mode in [WrapMode::SvgLike, WrapMode::HtmlLike] {
1374 let mut probes = Vec::new();
1375 let result = try_measure_wrapped_text(
1376 "alpha beta",
1377 &style,
1378 Some(40.0),
1379 mode,
1380 19.0,
1381 |text, _| {
1382 probes.push(text.to_string());
1383 Err::<f64, _>("canceled")
1384 },
1385 );
1386 assert_eq!(result.unwrap_err(), "canceled");
1387 assert_eq!(probes.len(), 1);
1388 }
1389 }
1390}