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};
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
52/// Wraps SVG-like text into lines using the provided [`TextMeasurer`] for width decisions.
53///
54/// This mirrors Mermaid's `wrapLabel(...)` behavior at a high level (greedy word wrapping), but
55/// delegates width measurements to the active measurer so the operation-selected font backend and
56/// SVG measurement phase determine wrapping breakpoints.
57pub fn wrap_text_lines_measurer(
58    text: &str,
59    measurer: &dyn TextMeasurer,
60    style: &TextStyle,
61    max_width_px: Option<f64>,
62) -> Vec<String> {
63    fn wrap_line(
64        line: &str,
65        measurer: &dyn TextMeasurer,
66        style: &TextStyle,
67        max_width_px: f64,
68    ) -> Vec<String> {
69        use std::collections::VecDeque;
70
71        if !max_width_px.is_finite() || max_width_px <= 0.0 {
72            return vec![line.to_string()];
73        }
74
75        let mut tokens = VecDeque::from(DeterministicTextMeasurer::split_line_to_words(line));
76        let mut out: Vec<String> = Vec::new();
77        let mut cur = String::new();
78
79        while let Some(tok) = tokens.pop_front() {
80            if cur.is_empty() && tok == " " {
81                continue;
82            }
83
84            let candidate = format!("{cur}{tok}");
85            if measurer
86                .measure(
87                    trim_end_html_collapsible_ascii_whitespace(&candidate),
88                    style,
89                )
90                .width
91                <= max_width_px
92            {
93                cur = candidate;
94                continue;
95            }
96
97            if !trim_html_collapsible_ascii_whitespace(&cur).is_empty() {
98                out.push(trim_end_html_collapsible_ascii_whitespace(&cur).to_string());
99                cur.clear();
100                tokens.push_front(tok);
101                continue;
102            }
103
104            if tok == " " {
105                continue;
106            }
107
108            // Token itself does not fit on an empty line; split by characters.
109            let chars = tok.chars().collect::<Vec<_>>();
110            let mut cut = 1usize;
111            while cut < chars.len() {
112                let head: String = chars[..cut].iter().collect();
113                if measurer.measure(&head, style).width > max_width_px {
114                    break;
115                }
116                cut += 1;
117            }
118            cut = cut.saturating_sub(1).max(1);
119            let head: String = chars[..cut].iter().collect();
120            let tail: String = chars[cut..].iter().collect();
121            out.push(head);
122            if !tail.is_empty() {
123                tokens.push_front(tail);
124            }
125        }
126
127        if !trim_html_collapsible_ascii_whitespace(&cur).is_empty() {
128            out.push(trim_end_html_collapsible_ascii_whitespace(&cur).to_string());
129        }
130
131        if out.is_empty() {
132            vec!["".to_string()]
133        } else {
134            out
135        }
136    }
137
138    let mut out: Vec<String> = Vec::new();
139    for line in split_html_br_lines(text) {
140        if let Some(w) = max_width_px {
141            out.extend(wrap_line(line, measurer, style, w));
142        } else {
143            out.push(line.to_string());
144        }
145    }
146    if out.is_empty() {
147        vec!["".to_string()]
148    } else {
149        out
150    }
151}
152
153/// Splits a Mermaid label into lines using Mermaid's `<br>`-style line breaks.
154///
155/// Mirrors Mermaid's `lineBreakRegex = /<br\\s*\\/?>/gi` behavior:
156/// - allows ASCII whitespace between `br` and the optional `/` or `>`
157/// - does not accept extra characters (e.g. `<br \\t/>` with a literal backslash)
158pub fn split_html_br_lines(text: &str) -> Vec<&str> {
159    let b = text.as_bytes();
160    let mut parts: Vec<&str> = Vec::new();
161    let mut start = 0usize;
162    let mut i = 0usize;
163    while i + 3 < b.len() {
164        if b[i] != b'<' {
165            i += 1;
166            continue;
167        }
168        let b1 = b[i + 1];
169        let b2 = b[i + 2];
170        if !matches!(b1, b'b' | b'B') || !matches!(b2, b'r' | b'R') {
171            i += 1;
172            continue;
173        }
174        let mut j = i + 3;
175        while j < b.len() && matches!(b[j], b' ' | b'\t' | b'\r' | b'\n') {
176            j += 1;
177        }
178        if j < b.len() && b[j] == b'/' {
179            j += 1;
180        }
181        if j < b.len() && b[j] == b'>' {
182            parts.push(&text[start..i]);
183            start = j + 1;
184            i = start;
185            continue;
186        }
187        i += 1;
188    }
189    parts.push(&text[start..]);
190    parts
191}
192
193fn try_wrap_label_like_mermaid_lines<E>(
194    label: &str,
195    measurer: &dyn TextMeasurer,
196    style: &TextStyle,
197    max_width_px: f64,
198    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
199) -> std::result::Result<Vec<String>, E> {
200    checkpoint()?;
201    if label.is_empty() {
202        return Ok(Vec::new());
203    }
204    if !max_width_px.is_finite() || max_width_px <= 0.0 {
205        return Ok(vec![label.to_string()]);
206    }
207
208    // Mermaid short-circuits wrapping if the label already contains `<br>` breaks.
209    let explicit_lines = split_html_br_lines(label);
210    if explicit_lines.len() > 1 {
211        let mut lines = Vec::with_capacity(explicit_lines.len());
212        for line in explicit_lines {
213            checkpoint()?;
214            lines.push(line.to_string());
215        }
216        checkpoint()?;
217        return Ok(lines);
218    }
219    fn w_px<E>(
220        measurer: &dyn TextMeasurer,
221        style: &TextStyle,
222        s: &str,
223        checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
224    ) -> std::result::Result<f64, E> {
225        // Upstream uses `calculateTextWidth(...)` which rounds the SVG bbox width.
226        checkpoint()?;
227        let width = measurer
228            .measure_svg_simple_text_bbox_width_for_wrap_px(s, style)
229            .round();
230        checkpoint()?;
231        Ok(width)
232    }
233
234    fn break_string_like_mermaid<E>(
235        word: &str,
236        max_width_px: f64,
237        measurer: &dyn TextMeasurer,
238        style: &TextStyle,
239        checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
240    ) -> std::result::Result<(Vec<String>, String), E> {
241        let chars: Vec<char> = word.chars().collect();
242        let mut lines: Vec<String> = Vec::new();
243        let mut current = String::new();
244        for (idx, ch) in chars.iter().enumerate() {
245            checkpoint()?;
246            let next_line = format!("{current}{ch}");
247            let line_w = w_px(measurer, style, &next_line, checkpoint)?;
248            if line_w >= max_width_px {
249                let is_last = idx + 1 == chars.len();
250                if is_last {
251                    lines.push(next_line);
252                } else {
253                    lines.push(format!("{next_line}-"));
254                }
255                current.clear();
256            } else {
257                current = next_line;
258            }
259        }
260        Ok((lines, current))
261    }
262
263    // Mermaid splits on ASCII spaces and drops empty chunks (collapsing multiple spaces).
264    let words: Vec<&str> = label.split(' ').filter(|w| !w.is_empty()).collect();
265    if words.is_empty() {
266        return Ok(vec![label.to_string()]);
267    }
268
269    let mut completed: Vec<String> = Vec::new();
270    let mut next_line = String::new();
271    for (idx, word) in words.iter().enumerate() {
272        checkpoint()?;
273        let word_len = w_px(measurer, style, &format!("{word} "), checkpoint)?;
274        let next_len = w_px(measurer, style, &next_line, checkpoint)?;
275        if word_len > max_width_px {
276            let (hyphenated, remaining) =
277                break_string_like_mermaid(word, max_width_px, measurer, style, checkpoint)?;
278            completed.push(next_line.clone());
279            completed.extend(hyphenated);
280            next_line = remaining;
281        } else if next_len + word_len >= max_width_px {
282            completed.push(next_line.clone());
283            next_line = (*word).to_string();
284        } else if next_line.is_empty() {
285            next_line = (*word).to_string();
286        } else {
287            next_line.push(' ');
288            next_line.push_str(word);
289        }
290
291        let is_last = idx + 1 == words.len();
292        if is_last {
293            completed.push(next_line.clone());
294        }
295    }
296
297    checkpoint()?;
298    Ok(completed.into_iter().filter(|l| !l.is_empty()).collect())
299}
300
301/// Wraps a label using Mermaid's `wrapLabel(...)` logic, producing wrapped *lines*.
302///
303/// This is used by Sequence diagrams (Mermaid@11.x) when `wrap: true` is enabled and when actor
304/// descriptions are marked `wrap: true` by the DB layer.
305pub fn wrap_label_like_mermaid_lines(
306    label: &str,
307    measurer: &dyn TextMeasurer,
308    style: &TextStyle,
309    max_width_px: f64,
310) -> Vec<String> {
311    let mut checkpoint = || Ok::<(), std::convert::Infallible>(());
312    match try_wrap_label_like_mermaid_lines(label, measurer, style, max_width_px, &mut checkpoint) {
313        Ok(lines) => lines,
314        Err(error) => match error {},
315    }
316}
317
318pub(crate) fn wrap_label_like_mermaid_lines_with_checkpoint(
319    label: &str,
320    measurer: &dyn TextMeasurer,
321    style: &TextStyle,
322    max_width_px: f64,
323    mut checkpoint: impl FnMut() -> crate::Result<()>,
324) -> crate::Result<Vec<String>> {
325    try_wrap_label_like_mermaid_lines(label, measurer, style, max_width_px, &mut checkpoint)
326}