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