Skip to main content

merman_render/text/
deterministic.rs

1//! Deterministic text measurement and wrapping fallback.
2
3use super::{TextMeasurer, TextMetrics, TextStyle, WrapMode, estimate_line_width_px};
4
5#[derive(Debug, Clone, Default)]
6pub struct DeterministicTextMeasurer {
7    pub char_width_factor: f64,
8    pub line_height_factor: f64,
9}
10
11impl DeterministicTextMeasurer {
12    fn replace_br_variants(text: &str) -> String {
13        let mut out = String::with_capacity(text.len());
14        let mut i = 0usize;
15        while i < text.len() {
16            let Some(rest) = text.get(i..) else {
17                break;
18            };
19
20            // Mirror Mermaid's `lineBreakRegex = /<br\\s*\\/?>/gi` behavior:
21            // - allow ASCII whitespace between `br` and the optional `/` or `>`
22            // - do NOT accept extra characters (e.g. `<br \\t/>` should *not* count as a break)
23            if rest.starts_with('<') {
24                let bytes = text.as_bytes();
25                if i + 3 < bytes.len()
26                    && matches!(bytes[i + 1], b'b' | b'B')
27                    && matches!(bytes[i + 2], b'r' | b'R')
28                {
29                    let mut j = i + 3;
30                    while j < bytes.len() && matches!(bytes[j], b' ' | b'\t' | b'\r' | b'\n') {
31                        j += 1;
32                    }
33                    if j < bytes.len() && bytes[j] == b'/' {
34                        j += 1;
35                    }
36                    if j < bytes.len() && bytes[j] == b'>' {
37                        out.push('\n');
38                        i = j + 1;
39                        continue;
40                    }
41                }
42            }
43
44            let Some(ch) = rest.chars().next() else {
45                break;
46            };
47            out.push(ch);
48            i += ch.len_utf8();
49        }
50        out
51    }
52
53    pub fn normalized_text_lines(text: &str) -> Vec<String> {
54        let t = Self::replace_br_variants(text);
55        let mut out = t.split('\n').map(|s| s.to_string()).collect::<Vec<_>>();
56
57        // Mermaid often produces labels with a trailing newline (e.g. YAML `|` block scalars from
58        // FlowDB). The rendered label does not keep an extra blank line at the end, so we trim
59        // trailing empty lines to keep height parity.
60        while out.len() > 1 && out.last().is_some_and(|s| s.trim().is_empty()) {
61            out.pop();
62        }
63
64        if out.is_empty() {
65            vec!["".to_string()]
66        } else {
67            out
68        }
69    }
70
71    pub(crate) fn split_line_to_words(text: &str) -> Vec<String> {
72        // Mirrors Mermaid's `splitLineToWords` fallback behavior when `Intl.Segmenter` is absent:
73        // split by spaces, then re-add the spaces as separate tokens (preserving multiple spaces).
74        let parts = text.split(' ').collect::<Vec<_>>();
75        let mut out: Vec<String> = Vec::new();
76        for part in parts {
77            if !part.is_empty() {
78                out.push(part.to_string());
79            }
80            out.push(" ".to_string());
81        }
82        while out.last().is_some_and(|s| s == " ") {
83            out.pop();
84        }
85        out
86    }
87
88    fn wrap_line(line: &str, max_chars: usize, break_long_words: bool) -> Vec<String> {
89        if max_chars == 0 {
90            return vec![line.to_string()];
91        }
92
93        let mut tokens = std::collections::VecDeque::from(Self::split_line_to_words(line));
94        let mut out: Vec<String> = Vec::new();
95        let mut cur = String::new();
96
97        while let Some(tok) = tokens.pop_front() {
98            if cur.is_empty() && tok == " " {
99                continue;
100            }
101
102            let candidate = format!("{cur}{tok}");
103            if candidate.chars().count() <= max_chars {
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            // `tok` itself does not fit on an empty line.
116            if tok == " " {
117                continue;
118            }
119            if !break_long_words {
120                out.push(tok);
121            } else {
122                // Split it by characters (Mermaid SVG text mode behavior).
123                let tok_chars = tok.chars().collect::<Vec<_>>();
124                let head: String = tok_chars.iter().take(max_chars.max(1)).collect();
125                let tail: String = tok_chars.iter().skip(max_chars.max(1)).collect();
126                out.push(head);
127                if !tail.is_empty() {
128                    tokens.push_front(tail);
129                }
130            }
131        }
132
133        if !cur.trim().is_empty() {
134            out.push(cur.trim_end().to_string());
135        }
136
137        if out.is_empty() {
138            vec!["".to_string()]
139        } else {
140            out
141        }
142    }
143}
144
145impl TextMeasurer for DeterministicTextMeasurer {
146    fn measure(&self, text: &str, style: &TextStyle) -> TextMetrics {
147        self.measure_wrapped(text, style, None, WrapMode::SvgLike)
148    }
149
150    fn measure_wrapped(
151        &self,
152        text: &str,
153        style: &TextStyle,
154        max_width: Option<f64>,
155        wrap_mode: WrapMode,
156    ) -> TextMetrics {
157        self.measure_wrapped_impl(text, style, max_width, wrap_mode, true)
158            .0
159    }
160
161    fn measure_wrapped_with_raw_width(
162        &self,
163        text: &str,
164        style: &TextStyle,
165        max_width: Option<f64>,
166        wrap_mode: WrapMode,
167    ) -> (TextMetrics, Option<f64>) {
168        self.measure_wrapped_impl(text, style, max_width, wrap_mode, true)
169    }
170
171    fn measure_svg_simple_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
172        let t = text.trim_end();
173        if t.is_empty() {
174            return 0.0;
175        }
176        (style.font_size.max(1.0) * 1.1).max(0.0)
177    }
178}
179
180impl DeterministicTextMeasurer {
181    fn measure_wrapped_impl(
182        &self,
183        text: &str,
184        style: &TextStyle,
185        max_width: Option<f64>,
186        wrap_mode: WrapMode,
187        clamp_html_width: bool,
188    ) -> (TextMetrics, Option<f64>) {
189        let uses_heuristic_widths = self.char_width_factor == 0.0;
190        let char_width_factor = if uses_heuristic_widths {
191            match wrap_mode {
192                WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => 0.6,
193                WrapMode::HtmlLike => 0.5,
194            }
195        } else {
196            self.char_width_factor
197        };
198        let default_line_height_factor = match wrap_mode {
199            WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => 1.1,
200            WrapMode::HtmlLike => 1.5,
201        };
202        let line_height_factor = if self.line_height_factor == 0.0 {
203            default_line_height_factor
204        } else {
205            self.line_height_factor
206        };
207
208        let font_size = style.font_size.max(1.0);
209        let max_width = max_width.filter(|w| w.is_finite() && *w > 0.0);
210        let break_long_words = matches!(wrap_mode, WrapMode::SvgLike | WrapMode::SvgLikeSingleRun);
211
212        let raw_lines = Self::normalized_text_lines(text);
213        let mut raw_width: f64 = 0.0;
214        for line in &raw_lines {
215            let w = if uses_heuristic_widths {
216                estimate_line_width_px(line, font_size)
217            } else {
218                line.chars().count() as f64 * font_size * char_width_factor
219            };
220            raw_width = raw_width.max(w);
221        }
222        let needs_wrap =
223            wrap_mode == WrapMode::HtmlLike && max_width.is_some_and(|w| raw_width > w);
224
225        let mut lines = Vec::new();
226        for line in raw_lines {
227            if let Some(w) = max_width {
228                let char_px = font_size * char_width_factor;
229                let max_chars = ((w / char_px).floor() as isize).max(1) as usize;
230                lines.extend(Self::wrap_line(&line, max_chars, break_long_words));
231            } else {
232                lines.push(line);
233            }
234        }
235
236        let mut width: f64 = 0.0;
237        for line in &lines {
238            let w = if uses_heuristic_widths {
239                estimate_line_width_px(line, font_size)
240            } else {
241                line.chars().count() as f64 * font_size * char_width_factor
242            };
243            width = width.max(w);
244        }
245        // Mermaid HTML labels use `max-width` and can visually overflow for long words, but their
246        // layout width is effectively clamped to the max width. Mirror this to avoid explosive
247        // headless widths when `htmlLabels=true`.
248        if clamp_html_width && wrap_mode == WrapMode::HtmlLike {
249            if let Some(w) = max_width {
250                if needs_wrap {
251                    width = w;
252                } else {
253                    width = width.min(w);
254                }
255            }
256        }
257        let height = lines.len() as f64 * font_size * line_height_factor;
258        let metrics = TextMetrics {
259            width,
260            height,
261            line_count: lines.len(),
262        };
263        let raw_width_px = if wrap_mode == WrapMode::HtmlLike {
264            Some(raw_width)
265        } else {
266            None
267        };
268        (metrics, raw_width_px)
269    }
270}