Skip to main content

merman_render/text/
wrap.rs

1//! Text wrapping helpers used across diagrams.
2//!
3//! This module intentionally mirrors Mermaid behavior (including quirks) for parity.
4
5use super::{
6    DeterministicTextMeasurer, TextMeasurer, TextStyle, trim_end_html_collapsible_ascii_whitespace,
7    trim_html_collapsible_ascii_whitespace,
8};
9use unicode_segmentation::UnicodeSegmentation;
10
11pub fn ceil_to_1_64_px(v: f64) -> f64 {
12    if !(v.is_finite() && v >= 0.0) {
13        return 0.0;
14    }
15    // Avoid "ceil to next 1/64" due to tiny FP drift (e.g. `...0000000002` over the exact
16    // lattice). Upstream Mermaid fixtures frequently land exactly on the 1/64px grid.
17    let x = v * 64.0;
18    let r = x.round();
19    if (x - r).abs() < 1e-4 {
20        return r / 64.0;
21    }
22    ((x) - 1e-5).ceil() / 64.0
23}
24
25pub fn round_to_1_64_px(v: f64) -> f64 {
26    if !(v.is_finite() && v >= 0.0) {
27        return 0.0;
28    }
29    let x = v * 64.0;
30    let r = (x + 0.5).floor();
31    r / 64.0
32}
33
34pub fn round_to_1_64_px_ties_to_even(v: f64) -> f64 {
35    if !(v.is_finite() && v >= 0.0) {
36        return 0.0;
37    }
38    let x = v * 64.0;
39    let f = x.floor();
40    let frac = x - f;
41    let i = if frac < 0.5 {
42        f
43    } else if frac > 0.5 {
44        f + 1.0
45    } else {
46        let fi = f as i64;
47        if fi % 2 == 0 { f } else { f + 1.0 }
48    };
49    let out = i / 64.0;
50    if out == -0.0 { 0.0 } else { out }
51}
52
53/// Splits at a grapheme boundary using complete candidates and their newly appended graphemes.
54pub(super) fn split_graphemes_to_width<E>(
55    token: &str,
56    max_width_px: f64,
57    mut measure_candidate: impl FnMut(&str, &str) -> Result<f64, E>,
58) -> Result<(&str, &str), E> {
59    let mut split_at = 0;
60    let mut width_px = 0.0;
61    let mut has_positive_advance = false;
62
63    for (index, grapheme) in token.grapheme_indices(true) {
64        let candidate_end = index + grapheme.len();
65        let candidate_width_px = measure_candidate(&token[..candidate_end], grapheme)?;
66        if has_positive_advance
67            && candidate_width_px > width_px
68            && candidate_width_px > max_width_px
69        {
70            break;
71        }
72
73        split_at = candidate_end;
74        width_px = candidate_width_px;
75        has_positive_advance |= candidate_width_px > 0.0;
76    }
77
78    // A visible grapheme wider than the whole line must still make progress. Leading
79    // zero-width graphemes stay attached to it instead of becoming an orphan line.
80    if split_at == 0 {
81        split_at = token.graphemes(true).next().map_or(0, str::len);
82    }
83    Ok(token.split_at(split_at))
84}
85
86/// Wraps SVG-like text into lines using the provided [`TextMeasurer`] for width decisions.
87///
88/// This mirrors Mermaid's `wrapLabel(...)` behavior at a high level (greedy word wrapping), but
89/// delegates width measurements to the active measurer so the operation-selected font backend and
90/// SVG measurement phase determine wrapping breakpoints.
91pub fn wrap_text_lines_measurer(
92    text: &str,
93    measurer: &dyn TextMeasurer,
94    style: &TextStyle,
95    max_width_px: Option<f64>,
96) -> Vec<String> {
97    fn wrap_line(
98        line: &str,
99        measurer: &dyn TextMeasurer,
100        style: &TextStyle,
101        max_width_px: f64,
102    ) -> Vec<String> {
103        use std::collections::VecDeque;
104
105        if !max_width_px.is_finite() || max_width_px <= 0.0 {
106            return vec![line.to_string()];
107        }
108
109        let mut tokens = VecDeque::from(DeterministicTextMeasurer::split_line_to_words(line));
110        let mut out: Vec<String> = Vec::new();
111        let mut cur = String::new();
112
113        while let Some(tok) = tokens.pop_front() {
114            if cur.is_empty() && tok == " " {
115                continue;
116            }
117
118            let candidate = format!("{cur}{tok}");
119            if measurer
120                .measure(
121                    trim_end_html_collapsible_ascii_whitespace(&candidate),
122                    style,
123                )
124                .width
125                <= max_width_px
126            {
127                cur = candidate;
128                continue;
129            }
130
131            if !trim_html_collapsible_ascii_whitespace(&cur).is_empty() {
132                out.push(trim_end_html_collapsible_ascii_whitespace(&cur).to_string());
133                cur.clear();
134                tokens.push_front(tok);
135                continue;
136            }
137
138            if tok == " " {
139                continue;
140            }
141
142            let (head, tail) = match split_graphemes_to_width(&tok, max_width_px, |candidate, _| {
143                Ok::<_, std::convert::Infallible>(measurer.measure(candidate, style).width)
144            }) {
145                Ok(parts) => parts,
146                Err(error) => match error {},
147            };
148            out.push(head.to_string());
149            if !tail.is_empty() {
150                tokens.push_front(tail.to_string());
151            }
152        }
153
154        if !trim_html_collapsible_ascii_whitespace(&cur).is_empty() {
155            out.push(trim_end_html_collapsible_ascii_whitespace(&cur).to_string());
156        }
157
158        if out.is_empty() {
159            vec!["".to_string()]
160        } else {
161            out
162        }
163    }
164
165    let mut out: Vec<String> = Vec::new();
166    for line in split_html_br_lines(text) {
167        if let Some(w) = max_width_px {
168            out.extend(wrap_line(line, measurer, style, w));
169        } else {
170            out.push(line.to_string());
171        }
172    }
173    if out.is_empty() {
174        vec!["".to_string()]
175    } else {
176        out
177    }
178}
179
180/// Splits a Mermaid label into lines using Mermaid's `<br>`-style line breaks.
181///
182/// Mirrors Mermaid's `lineBreakRegex = /<br\\s*\\/?>/gi` behavior:
183/// - allows ASCII whitespace between `br` and the optional `/` or `>`
184/// - does not accept extra characters (e.g. `<br \\t/>` with a literal backslash)
185pub fn split_html_br_lines(text: &str) -> Vec<&str> {
186    let b = text.as_bytes();
187    let mut parts: Vec<&str> = Vec::new();
188    let mut start = 0usize;
189    let mut i = 0usize;
190    while i + 3 < b.len() {
191        if b[i] != b'<' {
192            i += 1;
193            continue;
194        }
195        let b1 = b[i + 1];
196        let b2 = b[i + 2];
197        if !matches!(b1, b'b' | b'B') || !matches!(b2, b'r' | b'R') {
198            i += 1;
199            continue;
200        }
201        let mut j = i + 3;
202        while j < b.len() && matches!(b[j], b' ' | b'\t' | b'\r' | b'\n') {
203            j += 1;
204        }
205        if j < b.len() && b[j] == b'/' {
206            j += 1;
207        }
208        if j < b.len() && b[j] == b'>' {
209            parts.push(&text[start..i]);
210            start = j + 1;
211            i = start;
212            continue;
213        }
214        i += 1;
215    }
216    parts.push(&text[start..]);
217    parts
218}
219
220fn try_wrap_label_like_mermaid_lines<E>(
221    label: &str,
222    measurer: &dyn TextMeasurer,
223    style: &TextStyle,
224    max_width_px: f64,
225    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
226) -> std::result::Result<Vec<String>, E> {
227    checkpoint()?;
228    if label.is_empty() {
229        return Ok(Vec::new());
230    }
231    if !max_width_px.is_finite() || max_width_px <= 0.0 {
232        return Ok(vec![label.to_string()]);
233    }
234
235    // Mermaid short-circuits wrapping if the label already contains `<br>` breaks.
236    let explicit_lines = split_html_br_lines(label);
237    if explicit_lines.len() > 1 {
238        let mut lines = Vec::with_capacity(explicit_lines.len());
239        for line in explicit_lines {
240            checkpoint()?;
241            lines.push(line.to_string());
242        }
243        checkpoint()?;
244        return Ok(lines);
245    }
246    fn w_px<E>(
247        measurer: &dyn TextMeasurer,
248        style: &TextStyle,
249        s: &str,
250        checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
251    ) -> std::result::Result<f64, E> {
252        // Upstream uses `calculateTextWidth(...)` which rounds the SVG bbox width.
253        checkpoint()?;
254        let width = measurer
255            .measure_svg_simple_text_bbox_width_for_wrap_px(s, style)
256            .round();
257        checkpoint()?;
258        Ok(width)
259    }
260
261    fn break_string_like_mermaid<E>(
262        word: &str,
263        max_width_px: f64,
264        measurer: &dyn TextMeasurer,
265        style: &TextStyle,
266        checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
267    ) -> std::result::Result<(Vec<String>, String), E> {
268        let chars: Vec<char> = word.chars().collect();
269        let mut lines: Vec<String> = Vec::new();
270        let mut current = String::new();
271        for (idx, ch) in chars.iter().enumerate() {
272            checkpoint()?;
273            let next_line = format!("{current}{ch}");
274            let line_w = w_px(measurer, style, &next_line, checkpoint)?;
275            if line_w >= max_width_px {
276                let is_last = idx + 1 == chars.len();
277                if is_last {
278                    lines.push(next_line);
279                } else {
280                    lines.push(format!("{next_line}-"));
281                }
282                current.clear();
283            } else {
284                current = next_line;
285            }
286        }
287        Ok((lines, current))
288    }
289
290    // Mermaid splits on ASCII spaces and drops empty chunks (collapsing multiple spaces).
291    let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
292    if words.is_empty() {
293        return Ok(vec![label.to_string()]);
294    }
295
296    let mut completed: Vec<String> = Vec::new();
297    let mut next_line = String::new();
298    for (idx, word) in words.iter().enumerate() {
299        checkpoint()?;
300        let word_len = w_px(measurer, style, &format!("{word} "), checkpoint)?;
301        let next_len = w_px(measurer, style, &next_line, checkpoint)?;
302        if word_len > max_width_px {
303            let (hyphenated, remaining) =
304                break_string_like_mermaid(word, max_width_px, measurer, style, checkpoint)?;
305            completed.push(next_line.clone());
306            completed.extend(hyphenated);
307            next_line = remaining;
308        } else if next_len + word_len >= max_width_px {
309            completed.push(next_line.clone());
310            next_line = (*word).to_string();
311        } else if next_line.is_empty() {
312            next_line = (*word).to_string();
313        } else {
314            next_line.push(' ');
315            next_line.push_str(word);
316        }
317
318        let is_last = idx + 1 == words.len();
319        if is_last {
320            completed.push(next_line.clone());
321        }
322    }
323
324    checkpoint()?;
325    Ok(completed.into_iter().filter(|l| !l.is_empty()).collect())
326}
327
328/// Wraps a label using Mermaid's `wrapLabel(...)` logic, producing wrapped *lines*.
329///
330/// This is used by Sequence diagrams (Mermaid@11.x) when `wrap: true` is enabled and when actor
331/// descriptions are marked `wrap: true` by the DB layer.
332pub fn wrap_label_like_mermaid_lines(
333    label: &str,
334    measurer: &dyn TextMeasurer,
335    style: &TextStyle,
336    max_width_px: f64,
337) -> Vec<String> {
338    let mut checkpoint = || Ok::<(), std::convert::Infallible>(());
339    match try_wrap_label_like_mermaid_lines(label, measurer, style, max_width_px, &mut checkpoint) {
340        Ok(lines) => lines,
341        Err(error) => match error {},
342    }
343}
344
345pub(crate) fn wrap_label_like_mermaid_lines_with_checkpoint(
346    label: &str,
347    measurer: &dyn TextMeasurer,
348    style: &TextStyle,
349    max_width_px: f64,
350    mut checkpoint: impl FnMut() -> crate::Result<()>,
351) -> crate::Result<Vec<String>> {
352    try_wrap_label_like_mermaid_lines(label, measurer, style, max_width_px, &mut checkpoint)
353}
354
355#[cfg(test)]
356mod tests {
357    use super::{split_graphemes_to_width, wrap_text_lines_measurer};
358    use crate::text::{
359        DeterministicTextMeasurer, TextMeasurer, TextStyle, WrapMode, try_measure_wrapped_text,
360    };
361
362    #[test]
363    fn grapheme_splitting_keeps_non_increasing_overwide_candidates() {
364        let mut widths = [0.0, 8.0, 8.0, 7.0, 9.0].into_iter();
365        let parts = split_graphemes_to_width("\u{0301}Wabc", 1.0, |_, _| {
366            Ok::<_, ()>(widths.next().unwrap())
367        })
368        .unwrap();
369        assert_eq!(parts, ("\u{0301}Wab", "c"));
370    }
371
372    #[test]
373    fn grapheme_splitting_stops_on_measurement_errors() {
374        let mut probes = Vec::new();
375        let result =
376            split_graphemes_to_width("\u{0301}πŸ‘©β€πŸ”¬WZ", 1.0, |candidate, grapheme| {
377                probes.push((candidate.to_string(), grapheme.to_string()));
378                match grapheme {
379                    "\u{0301}" => Ok(0.0),
380                    "πŸ‘©β€πŸ”¬" => Ok(8.0),
381                    _ => Err("canceled"),
382                }
383            });
384        assert_eq!(result, Err("canceled"));
385        assert_eq!(
386            probes,
387            [
388                ("\u{0301}".to_string(), "\u{0301}".to_string()),
389                ("\u{0301}πŸ‘©β€πŸ”¬".to_string(), "πŸ‘©β€πŸ”¬".to_string()),
390                ("\u{0301}πŸ‘©β€πŸ”¬W".to_string(), "W".to_string()),
391            ],
392        );
393    }
394
395    #[test]
396    fn emitted_long_tokens_match_shared_grapheme_wrapping() {
397        let measurer = DeterministicTextMeasurer::default();
398        let style = TextStyle {
399            font_size: 16.0,
400            ..TextStyle::default()
401        };
402        for text in [
403            "W\u{0301}W",
404            "\u{0301}WW",
405            "πŸ‘©β€πŸ”¬πŸ‘¨β€πŸ”¬",
406            "πŸ‘πŸ½πŸ‘πŸ»",
407            "πŸ‡¨πŸ‡³πŸ‡ΊπŸ‡Έ",
408            "1️⃣2️⃣",
409            "a\u{200b}b",
410        ] {
411            for limit in [1.0, 8.0, 16.0, 25.0] {
412                let expected = try_measure_wrapped_text(
413                    text,
414                    &style,
415                    Some(limit),
416                    WrapMode::SvgLike,
417                    19.0,
418                    |candidate, style| Ok::<_, ()>(measurer.measure(candidate, style).width),
419                )
420                .unwrap();
421                let emitted = wrap_text_lines_measurer(text, &measurer, &style, Some(limit));
422                assert_eq!(emitted, expected.lines, "text={text:?}, limit={limit}");
423                assert_eq!(emitted.concat(), text);
424            }
425        }
426    }
427
428    #[test]
429    fn emitted_wrapping_keeps_leading_zero_width_text_with_overwide_grapheme() {
430        let measurer = DeterministicTextMeasurer::default();
431        let style = TextStyle {
432            font_size: 16.0,
433            ..TextStyle::default()
434        };
435        assert_eq!(
436            wrap_text_lines_measurer("\u{0301}WW", &measurer, &style, Some(1.0)),
437            ["\u{0301}W", "W"],
438        );
439        assert_eq!(
440            wrap_text_lines_measurer("πŸ‘©β€πŸ”¬πŸ‘¨β€πŸ”¬", &measurer, &style, Some(1.0)),
441            ["πŸ‘©β€πŸ”¬", "πŸ‘¨β€πŸ”¬"],
442        );
443    }
444}