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::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/// Shared wrapping output with the exact lines used to calculate its metrics.
200#[derive(Debug, Clone)]
201pub struct WrappedTextLayout {
202    pub metrics: TextMetrics,
203    pub lines: Vec<String>,
204    /// Natural pre-wrap width for HTML-like labels; absent for SVG-like labels.
205    pub raw_width: Option<f64>,
206}
207
208/// Measures and wraps text using Merman's existing whitespace and line-break rules.
209///
210/// The callback receives complete candidate strings, preserving shaping and kerning. It must
211/// return finite, non-negative CSS pixel widths. `line_height_px` is supplied by the active font
212/// backend rather than estimated from font size and must be finite and non-negative.
213/// Callback errors abort immediately, including
214/// errors raised while measuring the natural width or final emitted lines.
215pub 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
229/// Wraps with the shared policy and returns each emitted row's measured payload in row order.
230/// Candidate payloads are discarded; final payloads can supply height without another probe.
231/// No text or measurement cache is introduced, and callback errors abort immediately.
232/// Widths must be finite and non-negative. Height remains zero for the caller to derive from
233/// the payloads. Temporary payload storage is linear in the number of emitted rows.
234pub 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    /// Uses caller-supplied widths with Merman's wrapping behavior.
258    ///
259    /// Uses `width_px` to measure complete candidate strings while Merman retains its wrapping and
260    /// line-height behavior. Vertical metrics and baseline offsets remain deterministic
261    /// approximations; the callback does not provide glyph bounds or font ascent/descent.
262    /// HTML candidates retain unselected U+00AD controls for shaping. The callback must measure
263    /// their zero glyph advance without deleting the input controls before shaping.
264    ///
265    /// The callback must return a finite, non-negative width in CSS pixels. Use
266    /// [`crate::environment::HostTextMeasurer`] when measurement can fail or vary by operation.
267    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            // Mirror Mermaid's `lineBreakRegex = /<br\\s*\\/?>/gi` behavior:
305            // - allow ASCII whitespace between `br` and the optional `/` or `>`
306            // - do NOT accept extra characters (e.g. `<br \\t/>` should *not* count as a break)
307            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        // Mermaid often produces labels with a trailing newline (e.g. YAML `|` block scalars from
345        // FlowDB). The rendered label does not keep an extra blank line at the end, so we trim
346        // trailing empty lines to keep height parity.
347        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        // Mirrors Mermaid's `splitLineToWords` fallback behavior when `Intl.Segmenter` is absent:
366        // split by spaces, then re-add the spaces as separate tokens (preserving multiple spaces).
367        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            // `tok` itself does not fit on an empty line.
440            if tok == " " {
441                continue;
442            }
443            if !break_long_words {
444                out.push(tok);
445            } else {
446                // Split at the largest grapheme boundary that fits. Zero-width graphemes do not
447                // consume line capacity, and an over-wide grapheme still advances the queue.
448                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        // Overflowing HTML labels occupy the table width unless min-content is wider.
756        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}