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, WrapMode, estimate_char_width_em,
7    estimate_line_width_px,
8};
9
10pub fn ceil_to_1_64_px(v: f64) -> f64 {
11    if !(v.is_finite() && v >= 0.0) {
12        return 0.0;
13    }
14    // Avoid "ceil to next 1/64" due to tiny FP drift (e.g. `...0000000002` over the exact
15    // lattice). Upstream Mermaid fixtures frequently land exactly on the 1/64px grid.
16    let x = v * 64.0;
17    let r = x.round();
18    if (x - r).abs() < 1e-4 {
19        return r / 64.0;
20    }
21    ((x) - 1e-5).ceil() / 64.0
22}
23
24pub fn round_to_1_64_px(v: f64) -> f64 {
25    if !(v.is_finite() && v >= 0.0) {
26        return 0.0;
27    }
28    let x = v * 64.0;
29    let r = (x + 0.5).floor();
30    r / 64.0
31}
32
33pub fn wrap_text_lines_px(
34    text: &str,
35    style: &TextStyle,
36    max_width_px: Option<f64>,
37    wrap_mode: WrapMode,
38) -> Vec<String> {
39    let font_size = style.font_size.max(1.0);
40    let max_width_px = max_width_px.filter(|w| w.is_finite() && *w > 0.0);
41    let break_long_words = wrap_mode == WrapMode::SvgLike;
42
43    fn split_token_to_width_px(tok: &str, max_width_px: f64, font_size: f64) -> (String, String) {
44        let max_em = max_width_px / font_size;
45        let mut em = 0.0;
46        let chars = tok.chars().collect::<Vec<_>>();
47        let mut split_at = 0usize;
48        for (idx, ch) in chars.iter().enumerate() {
49            em += estimate_char_width_em(*ch);
50            if em > max_em && idx > 0 {
51                break;
52            }
53            split_at = idx + 1;
54            if em >= max_em {
55                break;
56            }
57        }
58        if split_at == 0 {
59            split_at = 1.min(chars.len());
60        }
61        let head = chars.iter().take(split_at).collect::<String>();
62        let tail = chars.iter().skip(split_at).collect::<String>();
63        (head, tail)
64    }
65
66    fn wrap_line_to_width_px(
67        line: &str,
68        max_width_px: f64,
69        font_size: f64,
70        break_long_words: bool,
71    ) -> Vec<String> {
72        let mut tokens =
73            std::collections::VecDeque::from(DeterministicTextMeasurer::split_line_to_words(line));
74        let mut out: Vec<String> = Vec::new();
75        let mut cur = String::new();
76
77        while let Some(tok) = tokens.pop_front() {
78            if cur.is_empty() && tok == " " {
79                continue;
80            }
81
82            let candidate = format!("{cur}{tok}");
83            let candidate_trimmed = candidate.trim_end();
84            if estimate_line_width_px(candidate_trimmed, font_size) <= max_width_px {
85                cur = candidate;
86                continue;
87            }
88
89            if !cur.trim().is_empty() {
90                out.push(cur.trim_end().to_string());
91                cur.clear();
92                tokens.push_front(tok);
93                continue;
94            }
95
96            if tok == " " {
97                continue;
98            }
99
100            if !break_long_words {
101                out.push(tok);
102            } else {
103                let (head, tail) = split_token_to_width_px(&tok, max_width_px, font_size);
104                out.push(head);
105                if !tail.is_empty() {
106                    tokens.push_front(tail);
107                }
108            }
109        }
110
111        if !cur.trim().is_empty() {
112            out.push(cur.trim_end().to_string());
113        }
114
115        if out.is_empty() {
116            vec!["".to_string()]
117        } else {
118            out
119        }
120    }
121
122    let mut lines: Vec<String> = Vec::new();
123    for line in DeterministicTextMeasurer::normalized_text_lines(text) {
124        if let Some(w) = max_width_px {
125            lines.extend(wrap_line_to_width_px(&line, w, font_size, break_long_words));
126        } else {
127            lines.push(line);
128        }
129    }
130
131    if lines.is_empty() {
132        vec!["".to_string()]
133    } else {
134        lines
135    }
136}
137
138/// Wraps SVG-like text into lines using the provided [`TextMeasurer`] for width decisions.
139///
140/// This mirrors Mermaid's `wrapLabel(...)` behavior at a high level (greedy word wrapping), but
141/// delegates width measurements to the active measurer so diagram-specific SVG bbox overrides can
142/// affect wrapping breakpoints.
143pub fn wrap_text_lines_measurer(
144    text: &str,
145    measurer: &dyn TextMeasurer,
146    style: &TextStyle,
147    max_width_px: Option<f64>,
148) -> Vec<String> {
149    fn wrap_line(
150        line: &str,
151        measurer: &dyn TextMeasurer,
152        style: &TextStyle,
153        max_width_px: f64,
154    ) -> Vec<String> {
155        use std::collections::VecDeque;
156
157        if !max_width_px.is_finite() || max_width_px <= 0.0 {
158            return vec![line.to_string()];
159        }
160
161        let mut tokens = VecDeque::from(DeterministicTextMeasurer::split_line_to_words(line));
162        let mut out: Vec<String> = Vec::new();
163        let mut cur = String::new();
164
165        while let Some(tok) = tokens.pop_front() {
166            if cur.is_empty() && tok == " " {
167                continue;
168            }
169
170            let candidate = format!("{cur}{tok}");
171            if measurer.measure(candidate.trim_end(), style).width <= max_width_px {
172                cur = candidate;
173                continue;
174            }
175
176            if !cur.trim().is_empty() {
177                out.push(cur.trim_end().to_string());
178                cur.clear();
179                tokens.push_front(tok);
180                continue;
181            }
182
183            if tok == " " {
184                continue;
185            }
186
187            // Token itself does not fit on an empty line; split by characters.
188            let chars = tok.chars().collect::<Vec<_>>();
189            let mut cut = 1usize;
190            while cut < chars.len() {
191                let head: String = chars[..cut].iter().collect();
192                if measurer.measure(&head, style).width > max_width_px {
193                    break;
194                }
195                cut += 1;
196            }
197            cut = cut.saturating_sub(1).max(1);
198            let head: String = chars[..cut].iter().collect();
199            let tail: String = chars[cut..].iter().collect();
200            out.push(head);
201            if !tail.is_empty() {
202                tokens.push_front(tail);
203            }
204        }
205
206        if !cur.trim().is_empty() {
207            out.push(cur.trim_end().to_string());
208        }
209
210        if out.is_empty() {
211            vec!["".to_string()]
212        } else {
213            out
214        }
215    }
216
217    let mut out: Vec<String> = Vec::new();
218    for line in split_html_br_lines(text) {
219        if let Some(w) = max_width_px {
220            out.extend(wrap_line(line, measurer, style, w));
221        } else {
222            out.push(line.to_string());
223        }
224    }
225    if out.is_empty() {
226        vec!["".to_string()]
227    } else {
228        out
229    }
230}
231
232/// Splits a Mermaid label into lines using Mermaid's `<br>`-style line breaks.
233///
234/// Mirrors Mermaid's `lineBreakRegex = /<br\\s*\\/?>/gi` behavior:
235/// - allows ASCII whitespace between `br` and the optional `/` or `>`
236/// - does not accept extra characters (e.g. `<br \\t/>` with a literal backslash)
237pub fn split_html_br_lines(text: &str) -> Vec<&str> {
238    let b = text.as_bytes();
239    let mut parts: Vec<&str> = Vec::new();
240    let mut start = 0usize;
241    let mut i = 0usize;
242    while i + 3 < b.len() {
243        if b[i] != b'<' {
244            i += 1;
245            continue;
246        }
247        let b1 = b[i + 1];
248        let b2 = b[i + 2];
249        if !matches!(b1, b'b' | b'B') || !matches!(b2, b'r' | b'R') {
250            i += 1;
251            continue;
252        }
253        let mut j = i + 3;
254        while j < b.len() && matches!(b[j], b' ' | b'\t' | b'\r' | b'\n') {
255            j += 1;
256        }
257        if j < b.len() && b[j] == b'/' {
258            j += 1;
259        }
260        if j < b.len() && b[j] == b'>' {
261            parts.push(&text[start..i]);
262            start = j + 1;
263            i = start;
264            continue;
265        }
266        i += 1;
267    }
268    parts.push(&text[start..]);
269    parts
270}
271
272/// Wraps a label using Mermaid's `wrapLabel(...)` logic, producing wrapped *lines*.
273///
274/// This is used by Sequence diagrams (Mermaid@11.x) when `wrap: true` is enabled and when actor
275/// descriptions are marked `wrap: true` by the DB layer.
276pub fn wrap_label_like_mermaid_lines(
277    label: &str,
278    measurer: &dyn TextMeasurer,
279    style: &TextStyle,
280    max_width_px: f64,
281) -> Vec<String> {
282    if label.is_empty() {
283        return Vec::new();
284    }
285    if !max_width_px.is_finite() || max_width_px <= 0.0 {
286        return vec![label.to_string()];
287    }
288
289    // Mermaid short-circuits wrapping if the label already contains `<br>` breaks.
290    if split_html_br_lines(label).len() > 1 {
291        return split_html_br_lines(label)
292            .into_iter()
293            .map(|s| s.to_string())
294            .collect();
295    }
296
297    fn w_px(measurer: &dyn TextMeasurer, style: &TextStyle, s: &str) -> f64 {
298        // Upstream uses `calculateTextWidth(...)` which rounds the SVG bbox width.
299        measurer
300            .measure_svg_simple_text_bbox_width_px(s, style)
301            .round()
302    }
303
304    fn break_string_like_mermaid(
305        word: &str,
306        max_width_px: f64,
307        measurer: &dyn TextMeasurer,
308        style: &TextStyle,
309    ) -> (Vec<String>, String) {
310        let chars: Vec<char> = word.chars().collect();
311        let mut lines: Vec<String> = Vec::new();
312        let mut current = String::new();
313        for (idx, ch) in chars.iter().enumerate() {
314            let next_line = format!("{current}{ch}");
315            let line_w = w_px(measurer, style, &next_line);
316            if line_w >= max_width_px {
317                let is_last = idx + 1 == chars.len();
318                if is_last {
319                    lines.push(next_line);
320                } else {
321                    lines.push(format!("{next_line}-"));
322                }
323                current.clear();
324            } else {
325                current = next_line;
326            }
327        }
328        (lines, current)
329    }
330
331    // Mermaid splits on ASCII spaces and drops empty chunks (collapsing multiple spaces).
332    let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
333    if words.is_empty() {
334        return vec![label.to_string()];
335    }
336
337    let mut completed: Vec<String> = Vec::new();
338    let mut next_line = String::new();
339    for (idx, word) in words.iter().enumerate() {
340        let word_len = w_px(measurer, style, &format!("{word} "));
341        let next_len = w_px(measurer, style, &next_line);
342        if word_len > max_width_px {
343            let (hyphenated, remaining) =
344                break_string_like_mermaid(word, max_width_px, measurer, style);
345            completed.push(next_line.clone());
346            completed.extend(hyphenated);
347            next_line = remaining;
348        } else if next_len + word_len >= max_width_px {
349            completed.push(next_line.clone());
350            next_line = (*word).to_string();
351        } else if next_line.is_empty() {
352            next_line = (*word).to_string();
353        } else {
354            next_line.push(' ');
355            next_line.push_str(word);
356        }
357
358        let is_last = idx + 1 == words.len();
359        if is_last {
360            completed.push(next_line.clone());
361        }
362    }
363
364    completed.into_iter().filter(|l| !l.is_empty()).collect()
365}
366
367/// A variant of [`wrap_label_like_mermaid_lines`] that uses `TextMeasurer::measure(...)` widths
368/// (advance-like) rather than SVG bbox widths for wrap decisions.
369///
370/// This exists to match Mermaid Sequence message wrapping behavior in environments where SVG bbox
371/// measurements differ slightly from the vendored bbox tables.
372pub fn wrap_label_like_mermaid_lines_relaxed(
373    label: &str,
374    measurer: &dyn TextMeasurer,
375    style: &TextStyle,
376    max_width_px: f64,
377) -> Vec<String> {
378    if label.is_empty() {
379        return Vec::new();
380    }
381    if !max_width_px.is_finite() || max_width_px <= 0.0 {
382        return vec![label.to_string()];
383    }
384
385    if split_html_br_lines(label).len() > 1 {
386        return split_html_br_lines(label)
387            .into_iter()
388            .map(|s| s.to_string())
389            .collect();
390    }
391
392    fn w_px(measurer: &dyn TextMeasurer, style: &TextStyle, s: &str) -> f64 {
393        measurer.measure(s, style).width.round()
394    }
395
396    fn break_string_like_mermaid(
397        word: &str,
398        max_width_px: f64,
399        measurer: &dyn TextMeasurer,
400        style: &TextStyle,
401    ) -> (Vec<String>, String) {
402        let chars: Vec<char> = word.chars().collect();
403        let mut lines: Vec<String> = Vec::new();
404        let mut current = String::new();
405        for (idx, ch) in chars.iter().enumerate() {
406            let next_line = format!("{current}{ch}");
407            let line_w = w_px(measurer, style, &next_line);
408            if line_w >= max_width_px {
409                let is_last = idx + 1 == chars.len();
410                if is_last {
411                    lines.push(next_line);
412                } else {
413                    lines.push(format!("{next_line}-"));
414                }
415                current.clear();
416            } else {
417                current = next_line;
418            }
419        }
420        (lines, current)
421    }
422
423    let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
424    if words.is_empty() {
425        return vec![label.to_string()];
426    }
427
428    let mut completed: Vec<String> = Vec::new();
429    let mut next_line = String::new();
430    for (idx, word) in words.iter().enumerate() {
431        let word_len = w_px(measurer, style, &format!("{word} "));
432        let next_len = w_px(measurer, style, &next_line);
433        if word_len > max_width_px {
434            let (hyphenated, remaining) =
435                break_string_like_mermaid(word, max_width_px, measurer, style);
436            completed.push(next_line.clone());
437            completed.extend(hyphenated);
438            next_line = remaining;
439        } else if next_len + word_len >= max_width_px {
440            completed.push(next_line.clone());
441            next_line = (*word).to_string();
442        } else if next_line.is_empty() {
443            next_line = (*word).to_string();
444        } else {
445            next_line.push(' ');
446            next_line.push_str(word);
447        }
448
449        let is_last = idx + 1 == words.len();
450        if is_last {
451            completed.push(next_line.clone());
452        }
453    }
454
455    completed.into_iter().filter(|l| !l.is_empty()).collect()
456}
457
458/// A variant of [`wrap_label_like_mermaid_lines`] that floors width probes instead of rounding.
459///
460/// Mermaid uses `Math.round(getBBox().width)` for `calculateTextWidth(...)`, but flooring can be
461/// closer to upstream SVG baselines for some wrapped Sequence message labels when our vendored
462/// tables land slightly above the browser-reported integer width.
463pub fn wrap_label_like_mermaid_lines_floored_bbox(
464    label: &str,
465    measurer: &dyn TextMeasurer,
466    style: &TextStyle,
467    max_width_px: f64,
468) -> Vec<String> {
469    if label.is_empty() {
470        return Vec::new();
471    }
472    if !max_width_px.is_finite() || max_width_px <= 0.0 {
473        return vec![label.to_string()];
474    }
475
476    if split_html_br_lines(label).len() > 1 {
477        return split_html_br_lines(label)
478            .into_iter()
479            .map(|s| s.to_string())
480            .collect();
481    }
482
483    fn w_px(measurer: &dyn TextMeasurer, style: &TextStyle, s: &str) -> f64 {
484        measurer
485            .measure_svg_simple_text_bbox_width_px(s, style)
486            .floor()
487    }
488
489    fn break_string_like_mermaid(
490        word: &str,
491        max_width_px: f64,
492        measurer: &dyn TextMeasurer,
493        style: &TextStyle,
494    ) -> (Vec<String>, String) {
495        let chars: Vec<char> = word.chars().collect();
496        let mut lines: Vec<String> = Vec::new();
497        let mut current = String::new();
498        for (idx, ch) in chars.iter().enumerate() {
499            let next_line = format!("{current}{ch}");
500            let line_w = w_px(measurer, style, &next_line);
501            if line_w >= max_width_px {
502                let is_last = idx + 1 == chars.len();
503                if is_last {
504                    lines.push(next_line);
505                } else {
506                    lines.push(format!("{next_line}-"));
507                }
508                current.clear();
509            } else {
510                current = next_line;
511            }
512        }
513        (lines, current)
514    }
515
516    let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
517    if words.is_empty() {
518        return vec![label.to_string()];
519    }
520
521    let mut completed: Vec<String> = Vec::new();
522    let mut next_line = String::new();
523    for (idx, word) in words.iter().enumerate() {
524        let word_len = w_px(measurer, style, &format!("{word} "));
525        let next_len = w_px(measurer, style, &next_line);
526        if word_len > max_width_px {
527            let (hyphenated, remaining) =
528                break_string_like_mermaid(word, max_width_px, measurer, style);
529            completed.push(next_line.clone());
530            completed.extend(hyphenated);
531            next_line = remaining;
532        } else if next_len + word_len >= max_width_px {
533            completed.push(next_line.clone());
534            next_line = (*word).to_string();
535        } else if next_line.is_empty() {
536            next_line = (*word).to_string();
537        } else {
538            next_line.push(' ');
539            next_line.push_str(word);
540        }
541
542        let is_last = idx + 1 == words.len();
543        if is_last {
544            completed.push(next_line.clone());
545        }
546    }
547
548    completed.into_iter().filter(|l| !l.is_empty()).collect()
549}