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/// Wraps flowchart-style SVG text using the same width probe Mermaid uses for emitted `<text>`.
252///
253/// The helper is shared by layout and SVG emission so wrapped cluster titles, node labels, and edge
254/// labels agree on both line breaks and the width used for root-bounds derivation.
255pub(crate) fn wrap_svg_text_lines_by_measurement(
256    measurer: &dyn TextMeasurer,
257    text: &str,
258    style: &TextStyle,
259    max_width_px: Option<f64>,
260    break_long_words: bool,
261) -> Vec<String> {
262    const EPS_PX: f64 = 0.125;
263    let max_width_px = max_width_px.filter(|w| w.is_finite() && *w > 0.0);
264
265    fn measure_w_px(measurer: &dyn TextMeasurer, style: &TextStyle, s: &str) -> f64 {
266        measurer.measure(s, style).width
267    }
268
269    fn split_token_to_width_px(
270        measurer: &dyn TextMeasurer,
271        style: &TextStyle,
272        tok: &str,
273        max_width_px: f64,
274    ) -> (String, String) {
275        if max_width_px <= 0.0 {
276            return (tok.to_string(), String::new());
277        }
278        let chars = tok.chars().collect::<Vec<_>>();
279        if chars.is_empty() {
280            return (String::new(), String::new());
281        }
282
283        let mut split_at = 1usize;
284        for i in 1..=chars.len() {
285            let head = chars[..i].iter().collect::<String>();
286            let w = measure_w_px(measurer, style, &head);
287            if w.is_finite() && w <= max_width_px + EPS_PX {
288                split_at = i;
289            } else {
290                break;
291            }
292        }
293        let head = chars[..split_at].iter().collect::<String>();
294        let tail = chars[split_at..].iter().collect::<String>();
295        (head, tail)
296    }
297
298    fn wrap_line_to_width_px(
299        measurer: &dyn TextMeasurer,
300        style: &TextStyle,
301        line: &str,
302        max_width_px: f64,
303        break_long_words: bool,
304    ) -> Vec<String> {
305        let mut tokens =
306            std::collections::VecDeque::from(DeterministicTextMeasurer::split_line_to_words(line));
307        let mut out: Vec<String> = Vec::new();
308        let mut cur = String::new();
309
310        while let Some(tok) = tokens.pop_front() {
311            if cur.is_empty() && tok == " " {
312                continue;
313            }
314
315            let candidate = format!("{cur}{tok}");
316            let candidate_trimmed = candidate.trim_end();
317            if measure_w_px(measurer, style, candidate_trimmed) <= max_width_px + EPS_PX {
318                cur = candidate;
319                continue;
320            }
321
322            if !cur.trim().is_empty() {
323                out.push(cur.trim_end().to_string());
324                cur.clear();
325                tokens.push_front(tok);
326                continue;
327            }
328
329            if tok == " " {
330                continue;
331            }
332
333            if measure_w_px(measurer, style, tok.as_str()) <= max_width_px + EPS_PX {
334                cur = tok;
335                continue;
336            }
337
338            if !break_long_words {
339                out.push(tok);
340                continue;
341            }
342
343            let (head, tail) = split_token_to_width_px(measurer, style, &tok, max_width_px);
344            out.push(head);
345            if !tail.is_empty() {
346                tokens.push_front(tail);
347            }
348        }
349
350        if !cur.trim().is_empty() {
351            out.push(cur.trim_end().to_string());
352        }
353
354        if out.is_empty() {
355            vec!["".to_string()]
356        } else {
357            out
358        }
359    }
360
361    let mut lines = Vec::new();
362    for line in DeterministicTextMeasurer::normalized_text_lines(text) {
363        if let Some(w) = max_width_px {
364            lines.extend(wrap_line_to_width_px(
365                measurer,
366                style,
367                &line,
368                w,
369                break_long_words,
370            ));
371        } else {
372            lines.push(line);
373        }
374    }
375
376    if lines.is_empty() {
377        vec!["".to_string()]
378    } else {
379        lines
380    }
381}
382
383/// Splits a Mermaid label into lines using Mermaid's `<br>`-style line breaks.
384///
385/// Mirrors Mermaid's `lineBreakRegex = /<br\\s*\\/?>/gi` behavior:
386/// - allows ASCII whitespace between `br` and the optional `/` or `>`
387/// - does not accept extra characters (e.g. `<br \\t/>` with a literal backslash)
388pub fn split_html_br_lines(text: &str) -> Vec<&str> {
389    let b = text.as_bytes();
390    let mut parts: Vec<&str> = Vec::new();
391    let mut start = 0usize;
392    let mut i = 0usize;
393    while i + 3 < b.len() {
394        if b[i] != b'<' {
395            i += 1;
396            continue;
397        }
398        let b1 = b[i + 1];
399        let b2 = b[i + 2];
400        if !matches!(b1, b'b' | b'B') || !matches!(b2, b'r' | b'R') {
401            i += 1;
402            continue;
403        }
404        let mut j = i + 3;
405        while j < b.len() && matches!(b[j], b' ' | b'\t' | b'\r' | b'\n') {
406            j += 1;
407        }
408        if j < b.len() && b[j] == b'/' {
409            j += 1;
410        }
411        if j < b.len() && b[j] == b'>' {
412            parts.push(&text[start..i]);
413            start = j + 1;
414            i = start;
415            continue;
416        }
417        i += 1;
418    }
419    parts.push(&text[start..]);
420    parts
421}
422
423/// Wraps a label using Mermaid's `wrapLabel(...)` logic, producing wrapped *lines*.
424///
425/// This is used by Sequence diagrams (Mermaid@11.x) when `wrap: true` is enabled and when actor
426/// descriptions are marked `wrap: true` by the DB layer.
427pub fn wrap_label_like_mermaid_lines(
428    label: &str,
429    measurer: &dyn TextMeasurer,
430    style: &TextStyle,
431    max_width_px: f64,
432) -> Vec<String> {
433    if label.is_empty() {
434        return Vec::new();
435    }
436    if !max_width_px.is_finite() || max_width_px <= 0.0 {
437        return vec![label.to_string()];
438    }
439
440    // Mermaid short-circuits wrapping if the label already contains `<br>` breaks.
441    if split_html_br_lines(label).len() > 1 {
442        return split_html_br_lines(label)
443            .into_iter()
444            .map(|s| s.to_string())
445            .collect();
446    }
447
448    fn w_px(measurer: &dyn TextMeasurer, style: &TextStyle, s: &str) -> f64 {
449        // Upstream uses `calculateTextWidth(...)` which rounds the SVG bbox width.
450        measurer
451            .measure_svg_simple_text_bbox_width_px(s, style)
452            .round()
453    }
454
455    fn break_string_like_mermaid(
456        word: &str,
457        max_width_px: f64,
458        measurer: &dyn TextMeasurer,
459        style: &TextStyle,
460    ) -> (Vec<String>, String) {
461        let chars: Vec<char> = word.chars().collect();
462        let mut lines: Vec<String> = Vec::new();
463        let mut current = String::new();
464        for (idx, ch) in chars.iter().enumerate() {
465            let next_line = format!("{current}{ch}");
466            let line_w = w_px(measurer, style, &next_line);
467            if line_w >= max_width_px {
468                let is_last = idx + 1 == chars.len();
469                if is_last {
470                    lines.push(next_line);
471                } else {
472                    lines.push(format!("{next_line}-"));
473                }
474                current.clear();
475            } else {
476                current = next_line;
477            }
478        }
479        (lines, current)
480    }
481
482    // Mermaid splits on ASCII spaces and drops empty chunks (collapsing multiple spaces).
483    let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
484    if words.is_empty() {
485        return vec![label.to_string()];
486    }
487
488    let mut completed: Vec<String> = Vec::new();
489    let mut next_line = String::new();
490    for (idx, word) in words.iter().enumerate() {
491        let word_len = w_px(measurer, style, &format!("{word} "));
492        let next_len = w_px(measurer, style, &next_line);
493        if word_len > max_width_px {
494            let (hyphenated, remaining) =
495                break_string_like_mermaid(word, max_width_px, measurer, style);
496            completed.push(next_line.clone());
497            completed.extend(hyphenated);
498            next_line = remaining;
499        } else if next_len + word_len >= max_width_px {
500            completed.push(next_line.clone());
501            next_line = (*word).to_string();
502        } else if next_line.is_empty() {
503            next_line = (*word).to_string();
504        } else {
505            next_line.push(' ');
506            next_line.push_str(word);
507        }
508
509        let is_last = idx + 1 == words.len();
510        if is_last {
511            completed.push(next_line.clone());
512        }
513    }
514
515    completed.into_iter().filter(|l| !l.is_empty()).collect()
516}
517
518/// A variant of [`wrap_label_like_mermaid_lines`] that uses `TextMeasurer::measure(...)` widths
519/// (advance-like) rather than SVG bbox widths for wrap decisions.
520///
521/// This exists to match Mermaid Sequence message wrapping behavior in environments where SVG bbox
522/// measurements differ slightly from the vendored bbox tables.
523pub fn wrap_label_like_mermaid_lines_relaxed(
524    label: &str,
525    measurer: &dyn TextMeasurer,
526    style: &TextStyle,
527    max_width_px: f64,
528) -> Vec<String> {
529    if label.is_empty() {
530        return Vec::new();
531    }
532    if !max_width_px.is_finite() || max_width_px <= 0.0 {
533        return vec![label.to_string()];
534    }
535
536    if split_html_br_lines(label).len() > 1 {
537        return split_html_br_lines(label)
538            .into_iter()
539            .map(|s| s.to_string())
540            .collect();
541    }
542
543    fn w_px(measurer: &dyn TextMeasurer, style: &TextStyle, s: &str) -> f64 {
544        measurer.measure(s, style).width.round()
545    }
546
547    fn break_string_like_mermaid(
548        word: &str,
549        max_width_px: f64,
550        measurer: &dyn TextMeasurer,
551        style: &TextStyle,
552    ) -> (Vec<String>, String) {
553        let chars: Vec<char> = word.chars().collect();
554        let mut lines: Vec<String> = Vec::new();
555        let mut current = String::new();
556        for (idx, ch) in chars.iter().enumerate() {
557            let next_line = format!("{current}{ch}");
558            let line_w = w_px(measurer, style, &next_line);
559            if line_w >= max_width_px {
560                let is_last = idx + 1 == chars.len();
561                if is_last {
562                    lines.push(next_line);
563                } else {
564                    lines.push(format!("{next_line}-"));
565                }
566                current.clear();
567            } else {
568                current = next_line;
569            }
570        }
571        (lines, current)
572    }
573
574    let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
575    if words.is_empty() {
576        return vec![label.to_string()];
577    }
578
579    let mut completed: Vec<String> = Vec::new();
580    let mut next_line = String::new();
581    for (idx, word) in words.iter().enumerate() {
582        let word_len = w_px(measurer, style, &format!("{word} "));
583        let next_len = w_px(measurer, style, &next_line);
584        if word_len > max_width_px {
585            let (hyphenated, remaining) =
586                break_string_like_mermaid(word, max_width_px, measurer, style);
587            completed.push(next_line.clone());
588            completed.extend(hyphenated);
589            next_line = remaining;
590        } else if next_len + word_len >= max_width_px {
591            completed.push(next_line.clone());
592            next_line = (*word).to_string();
593        } else if next_line.is_empty() {
594            next_line = (*word).to_string();
595        } else {
596            next_line.push(' ');
597            next_line.push_str(word);
598        }
599
600        let is_last = idx + 1 == words.len();
601        if is_last {
602            completed.push(next_line.clone());
603        }
604    }
605
606    completed.into_iter().filter(|l| !l.is_empty()).collect()
607}
608
609/// A variant of [`wrap_label_like_mermaid_lines`] that floors width probes instead of rounding.
610///
611/// Mermaid uses `Math.round(getBBox().width)` for `calculateTextWidth(...)`, but flooring can be
612/// closer to upstream SVG baselines for some wrapped Sequence message labels when our vendored
613/// tables land slightly above the browser-reported integer width.
614pub fn wrap_label_like_mermaid_lines_floored_bbox(
615    label: &str,
616    measurer: &dyn TextMeasurer,
617    style: &TextStyle,
618    max_width_px: f64,
619) -> Vec<String> {
620    if label.is_empty() {
621        return Vec::new();
622    }
623    if !max_width_px.is_finite() || max_width_px <= 0.0 {
624        return vec![label.to_string()];
625    }
626
627    if split_html_br_lines(label).len() > 1 {
628        return split_html_br_lines(label)
629            .into_iter()
630            .map(|s| s.to_string())
631            .collect();
632    }
633
634    fn w_px(measurer: &dyn TextMeasurer, style: &TextStyle, s: &str) -> f64 {
635        measurer
636            .measure_svg_simple_text_bbox_width_px(s, style)
637            .floor()
638    }
639
640    fn break_string_like_mermaid(
641        word: &str,
642        max_width_px: f64,
643        measurer: &dyn TextMeasurer,
644        style: &TextStyle,
645    ) -> (Vec<String>, String) {
646        let chars: Vec<char> = word.chars().collect();
647        let mut lines: Vec<String> = Vec::new();
648        let mut current = String::new();
649        for (idx, ch) in chars.iter().enumerate() {
650            let next_line = format!("{current}{ch}");
651            let line_w = w_px(measurer, style, &next_line);
652            if line_w >= max_width_px {
653                let is_last = idx + 1 == chars.len();
654                if is_last {
655                    lines.push(next_line);
656                } else {
657                    lines.push(format!("{next_line}-"));
658                }
659                current.clear();
660            } else {
661                current = next_line;
662            }
663        }
664        (lines, current)
665    }
666
667    let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
668    if words.is_empty() {
669        return vec![label.to_string()];
670    }
671
672    let mut completed: Vec<String> = Vec::new();
673    let mut next_line = String::new();
674    for (idx, word) in words.iter().enumerate() {
675        let word_len = w_px(measurer, style, &format!("{word} "));
676        let next_len = w_px(measurer, style, &next_line);
677        if word_len > max_width_px {
678            let (hyphenated, remaining) =
679                break_string_like_mermaid(word, max_width_px, measurer, style);
680            completed.push(next_line.clone());
681            completed.extend(hyphenated);
682            next_line = remaining;
683        } else if next_len + word_len >= max_width_px {
684            completed.push(next_line.clone());
685            next_line = (*word).to_string();
686        } else if next_line.is_empty() {
687            next_line = (*word).to_string();
688        } else {
689            next_line.push(' ');
690            next_line.push_str(word);
691        }
692
693        let is_last = idx + 1 == words.len();
694        if is_last {
695            completed.push(next_line.clone());
696        }
697    }
698
699    completed.into_iter().filter(|l| !l.is_empty()).collect()
700}