Skip to main content

merman_render/text/
deterministic.rs

1//! Deterministic text measurement and wrapping fallback.
2
3use 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    /// Uses caller-supplied widths with Merman's wrapping behavior.
95    ///
96    /// Uses `width_px` to measure complete candidate strings while Merman retains its wrapping and
97    /// line-height behavior. Vertical metrics and baseline offsets remain deterministic
98    /// approximations; the callback does not provide glyph bounds or font ascent/descent.
99    ///
100    /// The callback must return a finite, non-negative width in CSS pixels. Use
101    /// [`crate::environment::HostTextMeasurer`] when measurement can fail or vary by operation.
102    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            // Mirror Mermaid's `lineBreakRegex = /<br\\s*\\/?>/gi` behavior:
140            // - allow ASCII whitespace between `br` and the optional `/` or `>`
141            // - do NOT accept extra characters (e.g. `<br \\t/>` should *not* count as a break)
142            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        // Mermaid often produces labels with a trailing newline (e.g. YAML `|` block scalars from
180        // FlowDB). The rendered label does not keep an extra blank line at the end, so we trim
181        // trailing empty lines to keep height parity.
182        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        // Mirrors Mermaid's `splitLineToWords` fallback behavior when `Intl.Segmenter` is absent:
201        // split by spaces, then re-add the spaces as separate tokens (preserving multiple spaces).
202        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        // A visible grapheme wider than the whole line must still make progress. Leading
248        // zero-width graphemes stay attached to it instead of becoming an orphan line.
249        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            // `tok` itself does not fit on an empty line.
313            if tok == " " && !html_break_spaces_active {
314                continue;
315            }
316            if !break_long_words {
317                out.push(tok);
318            } else {
319                // Split at the largest grapheme boundary that fits. Zero-width graphemes do not
320                // consume line capacity, and an over-wide grapheme still advances the queue.
321                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            // Mermaid switches overflowing HTML labels to a fixed-width table. Breakable text
562            // therefore occupies the configured width, while an unbreakable segment may still
563            // expand it through its min-content width.
564            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}