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
423const EXACT_SVG_WRAP_SINGLE_LINE_GUARD_PX: f64 = 4.0;
424
425fn exact_svg_single_line_evidence_fits(
426    label: &str,
427    measurer: &dyn TextMeasurer,
428    style: &TextStyle,
429    max_width_px: f64,
430) -> bool {
431    if label.is_empty() || !max_width_px.is_finite() || max_width_px <= 0.0 {
432        return false;
433    }
434
435    let exact_w = measurer
436        .measure_svg_simple_text_bbox_width_px(label, style)
437        .floor()
438        .max(0.0);
439    let probe_w = measurer
440        .measure_svg_simple_text_bbox_width_for_wrap_px(label, style)
441        .floor()
442        .max(0.0);
443
444    exact_w + EXACT_SVG_WRAP_SINGLE_LINE_GUARD_PX <= max_width_px
445        && exact_w + EXACT_SVG_WRAP_SINGLE_LINE_GUARD_PX < probe_w
446}
447
448/// Wraps a label using Mermaid's `wrapLabel(...)` logic, producing wrapped *lines*.
449///
450/// This is used by Sequence diagrams (Mermaid@11.x) when `wrap: true` is enabled and when actor
451/// descriptions are marked `wrap: true` by the DB layer.
452pub fn wrap_label_like_mermaid_lines(
453    label: &str,
454    measurer: &dyn TextMeasurer,
455    style: &TextStyle,
456    max_width_px: f64,
457) -> Vec<String> {
458    if label.is_empty() {
459        return Vec::new();
460    }
461    if !max_width_px.is_finite() || max_width_px <= 0.0 {
462        return vec![label.to_string()];
463    }
464
465    // Mermaid short-circuits wrapping if the label already contains `<br>` breaks.
466    if split_html_br_lines(label).len() > 1 {
467        return split_html_br_lines(label)
468            .into_iter()
469            .map(|s| s.to_string())
470            .collect();
471    }
472    if exact_svg_single_line_evidence_fits(label, measurer, style, max_width_px) {
473        return vec![label.to_string()];
474    }
475
476    fn w_px(measurer: &dyn TextMeasurer, style: &TextStyle, s: &str) -> f64 {
477        // Upstream uses `calculateTextWidth(...)` which rounds the SVG bbox width.
478        measurer
479            .measure_svg_simple_text_bbox_width_for_wrap_px(s, style)
480            .round()
481    }
482
483    fn break_string_like_mermaid(
484        word: &str,
485        max_width_px: f64,
486        measurer: &dyn TextMeasurer,
487        style: &TextStyle,
488    ) -> (Vec<String>, String) {
489        let chars: Vec<char> = word.chars().collect();
490        let mut lines: Vec<String> = Vec::new();
491        let mut current = String::new();
492        for (idx, ch) in chars.iter().enumerate() {
493            let next_line = format!("{current}{ch}");
494            let line_w = w_px(measurer, style, &next_line);
495            if line_w >= max_width_px {
496                let is_last = idx + 1 == chars.len();
497                if is_last {
498                    lines.push(next_line);
499                } else {
500                    lines.push(format!("{next_line}-"));
501                }
502                current.clear();
503            } else {
504                current = next_line;
505            }
506        }
507        (lines, current)
508    }
509
510    // Mermaid splits on ASCII spaces and drops empty chunks (collapsing multiple spaces).
511    let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
512    if words.is_empty() {
513        return vec![label.to_string()];
514    }
515
516    let mut completed: Vec<String> = Vec::new();
517    let mut next_line = String::new();
518    for (idx, word) in words.iter().enumerate() {
519        let word_len = w_px(measurer, style, &format!("{word} "));
520        let next_len = w_px(measurer, style, &next_line);
521        if word_len > max_width_px {
522            let (hyphenated, remaining) =
523                break_string_like_mermaid(word, max_width_px, measurer, style);
524            completed.push(next_line.clone());
525            completed.extend(hyphenated);
526            next_line = remaining;
527        } else if next_len + word_len >= max_width_px {
528            completed.push(next_line.clone());
529            next_line = (*word).to_string();
530        } else if next_line.is_empty() {
531            next_line = (*word).to_string();
532        } else {
533            next_line.push(' ');
534            next_line.push_str(word);
535        }
536
537        let is_last = idx + 1 == words.len();
538        if is_last {
539            completed.push(next_line.clone());
540        }
541    }
542
543    completed.into_iter().filter(|l| !l.is_empty()).collect()
544}
545
546/// A variant of [`wrap_label_like_mermaid_lines`] that uses `TextMeasurer::measure(...)` widths
547/// (advance-like) rather than SVG bbox widths for wrap decisions.
548///
549/// This exists to match Mermaid Sequence message wrapping behavior in environments where SVG bbox
550/// measurements differ slightly from the vendored bbox tables.
551pub fn wrap_label_like_mermaid_lines_relaxed(
552    label: &str,
553    measurer: &dyn TextMeasurer,
554    style: &TextStyle,
555    max_width_px: f64,
556) -> Vec<String> {
557    if label.is_empty() {
558        return Vec::new();
559    }
560    if !max_width_px.is_finite() || max_width_px <= 0.0 {
561        return vec![label.to_string()];
562    }
563
564    if split_html_br_lines(label).len() > 1 {
565        return split_html_br_lines(label)
566            .into_iter()
567            .map(|s| s.to_string())
568            .collect();
569    }
570    if exact_svg_single_line_evidence_fits(label, measurer, style, max_width_px) {
571        return vec![label.to_string()];
572    }
573
574    fn w_px(measurer: &dyn TextMeasurer, style: &TextStyle, s: &str) -> f64 {
575        measurer.measure(s, style).width.round()
576    }
577
578    fn break_string_like_mermaid(
579        word: &str,
580        max_width_px: f64,
581        measurer: &dyn TextMeasurer,
582        style: &TextStyle,
583    ) -> (Vec<String>, String) {
584        let chars: Vec<char> = word.chars().collect();
585        let mut lines: Vec<String> = Vec::new();
586        let mut current = String::new();
587        for (idx, ch) in chars.iter().enumerate() {
588            let next_line = format!("{current}{ch}");
589            let line_w = w_px(measurer, style, &next_line);
590            if line_w >= max_width_px {
591                let is_last = idx + 1 == chars.len();
592                if is_last {
593                    lines.push(next_line);
594                } else {
595                    lines.push(format!("{next_line}-"));
596                }
597                current.clear();
598            } else {
599                current = next_line;
600            }
601        }
602        (lines, current)
603    }
604
605    let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
606    if words.is_empty() {
607        return vec![label.to_string()];
608    }
609
610    let mut completed: Vec<String> = Vec::new();
611    let mut next_line = String::new();
612    for (idx, word) in words.iter().enumerate() {
613        let word_len = w_px(measurer, style, &format!("{word} "));
614        let next_len = w_px(measurer, style, &next_line);
615        if word_len > max_width_px {
616            let (hyphenated, remaining) =
617                break_string_like_mermaid(word, max_width_px, measurer, style);
618            completed.push(next_line.clone());
619            completed.extend(hyphenated);
620            next_line = remaining;
621        } else if next_len + word_len >= max_width_px {
622            completed.push(next_line.clone());
623            next_line = (*word).to_string();
624        } else if next_line.is_empty() {
625            next_line = (*word).to_string();
626        } else {
627            next_line.push(' ');
628            next_line.push_str(word);
629        }
630
631        let is_last = idx + 1 == words.len();
632        if is_last {
633            completed.push(next_line.clone());
634        }
635    }
636
637    completed.into_iter().filter(|l| !l.is_empty()).collect()
638}
639
640/// A variant of [`wrap_label_like_mermaid_lines`] that floors width probes instead of rounding.
641///
642/// Mermaid uses `Math.round(getBBox().width)` for `calculateTextWidth(...)`, but flooring can be
643/// closer to upstream SVG baselines for some wrapped Sequence message labels when our vendored
644/// tables land slightly above the browser-reported integer width.
645pub fn wrap_label_like_mermaid_lines_floored_bbox(
646    label: &str,
647    measurer: &dyn TextMeasurer,
648    style: &TextStyle,
649    max_width_px: f64,
650) -> Vec<String> {
651    if label.is_empty() {
652        return Vec::new();
653    }
654    if !max_width_px.is_finite() || max_width_px <= 0.0 {
655        return vec![label.to_string()];
656    }
657
658    if split_html_br_lines(label).len() > 1 {
659        return split_html_br_lines(label)
660            .into_iter()
661            .map(|s| s.to_string())
662            .collect();
663    }
664    if exact_svg_single_line_evidence_fits(label, measurer, style, max_width_px) {
665        return vec![label.to_string()];
666    }
667
668    fn w_px(measurer: &dyn TextMeasurer, style: &TextStyle, s: &str) -> f64 {
669        measurer
670            .measure_svg_simple_text_bbox_width_for_wrap_px(s, style)
671            .floor()
672    }
673
674    fn break_string_like_mermaid(
675        word: &str,
676        max_width_px: f64,
677        measurer: &dyn TextMeasurer,
678        style: &TextStyle,
679    ) -> (Vec<String>, String) {
680        let chars: Vec<char> = word.chars().collect();
681        let mut lines: Vec<String> = Vec::new();
682        let mut current = String::new();
683        for (idx, ch) in chars.iter().enumerate() {
684            let next_line = format!("{current}{ch}");
685            let line_w = w_px(measurer, style, &next_line);
686            if line_w >= max_width_px {
687                let is_last = idx + 1 == chars.len();
688                if is_last {
689                    lines.push(next_line);
690                } else {
691                    lines.push(format!("{next_line}-"));
692                }
693                current.clear();
694            } else {
695                current = next_line;
696            }
697        }
698        (lines, current)
699    }
700
701    let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
702    if words.is_empty() {
703        return vec![label.to_string()];
704    }
705
706    let mut completed: Vec<String> = Vec::new();
707    let mut next_line = String::new();
708    for (idx, word) in words.iter().enumerate() {
709        let word_len = w_px(measurer, style, &format!("{word} "));
710        let next_len = w_px(measurer, style, &next_line);
711        if word_len > max_width_px {
712            let (hyphenated, remaining) =
713                break_string_like_mermaid(word, max_width_px, measurer, style);
714            completed.push(next_line.clone());
715            completed.extend(hyphenated);
716            next_line = remaining;
717        } else if next_len + word_len >= max_width_px {
718            completed.push(next_line.clone());
719            next_line = (*word).to_string();
720        } else if next_line.is_empty() {
721            next_line = (*word).to_string();
722        } else {
723            next_line.push(' ');
724            next_line.push_str(word);
725        }
726
727        let is_last = idx + 1 == words.len();
728        if is_last {
729            completed.push(next_line.clone());
730        }
731    }
732
733    completed.into_iter().filter(|l| !l.is_empty()).collect()
734}