Skip to main content

rusty_mermaid_core/
text.rs

1use crate::TextStyle;
2
3/// Measured text dimensions.
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub struct TextSize {
6    pub width: f64,
7    pub height: f64,
8}
9
10/// Measure text dimensions for layout purposes.
11/// Generic parameter on dagre::layout — no vtable overhead.
12pub trait TextMeasure {
13    fn measure(&self, text: &str, style: &TextStyle) -> TextSize;
14}
15
16/// Monospace character width at the reference font size (14px).
17/// Measured from Intel One Mono Regular: 8.596px at 14px.
18/// Rounded up to ensure node boxes never undersize the text.
19const MONOSPACE_CHAR_WIDTH_14PX: f64 = 8.6;
20const MONOSPACE_LINE_HEIGHT_14PX: f64 = 16.8;
21
22/// Monospace text measurer using fixed character advance width.
23/// Uses accurate metrics for the default monospace font stack.
24pub struct SimpleTextMeasure {
25    pub avg_char_width: f64,
26    pub line_height: f64,
27}
28
29impl Default for SimpleTextMeasure {
30    fn default() -> Self {
31        Self {
32            avg_char_width: MONOSPACE_CHAR_WIDTH_14PX,
33            line_height: MONOSPACE_LINE_HEIGHT_14PX,
34        }
35    }
36}
37
38impl SimpleTextMeasure {
39    pub fn new(avg_char_width: f64, line_height: f64) -> Self {
40        debug_assert!(avg_char_width > 0.0, "char width must be positive");
41        debug_assert!(line_height > 0.0, "line height must be positive");
42        Self {
43            avg_char_width,
44            line_height,
45        }
46    }
47}
48
49use crate::font_fallback::{FontSlot, font_for_char};
50
51/// Width multiplier per FontSlot relative to Intel One Mono's advance.
52/// These ratios account for the actual advance widths of each fallback font.
53pub const fn char_width_ratio(ch: char) -> f64 {
54    match font_for_char(ch) {
55        FontSlot::Primary => 1.0,       // Intel One Mono: 0.6em monospace
56        FontSlot::ExtendedText => 0.85, // Noto Sans: proportional, narrower than mono
57        FontSlot::Monospace => 1.0,     // Noto Sans Mono: same width as primary
58        FontSlot::Dingbats => 1.4,      // Noto Sans Symbols 2: wider symbols
59        FontSlot::Arabic => 0.8,        // Noto Naskh Arabic: proportional, varies
60        FontSlot::Cjk => 1.8,           // CJK: wide but proportional
61        FontSlot::Emoji => 2.0,         // Color emoji: ~2x Latin mono width
62    }
63}
64
65impl SimpleTextMeasure {
66    /// Measure text width/height WITHOUT stripping HTML/markdown markup.
67    /// Use for text that contains literal `<` `>` characters (e.g. `<<interface>>`).
68    pub fn measure_raw(text: &str, style: &TextStyle) -> TextSize {
69        let defaults = Self::default();
70        let scale = style.font_size / crate::constants::REFERENCE_FONT_SIZE;
71        let mut max_width: f64 = 0.0;
72        let mut line_count: usize = 0;
73        for line in text.split('\n') {
74            line_count += 1;
75            let w: f64 = line.chars().map(char_width_ratio).sum();
76            max_width = max_width.max(w);
77        }
78        TextSize {
79            width: max_width * defaults.avg_char_width * scale,
80            height: line_count as f64 * defaults.line_height * scale,
81        }
82    }
83}
84
85impl TextMeasure for SimpleTextMeasure {
86    fn measure(&self, text: &str, style: &TextStyle) -> TextSize {
87        let scale = style.font_size / crate::constants::REFERENCE_FONT_SIZE;
88        let stripped = strip_markup(text);
89        let mut max_width: f64 = 0.0;
90        let mut line_count: usize = 0;
91        for line in stripped.split('\n') {
92            line_count += 1;
93            let w: f64 = line.chars().map(char_width_ratio).sum();
94            max_width = max_width.max(w);
95        }
96
97        TextSize {
98            width: max_width * self.avg_char_width * scale,
99            height: line_count as f64 * self.line_height * scale,
100        }
101    }
102}
103
104/// Compute the baseline Y for vertically centered text.
105///
106/// Given a target center Y position, returns the Y coordinate for the
107/// **first line's baseline** so that the text block is visually centered.
108///
109/// SVG does this with `dominant-baseline: central`. Non-SVG backends
110/// (raster, gpui, vello) call this to compute the baseline position.
111///
112/// Usage: `baseline_y = center_y + text_baseline_y_offset(font_size, line_count)`
113///
114/// Based on Intel One Mono metrics: ascent ≈ 0.8em, descent ≈ 0.2em.
115/// Visual center above baseline = (ascent - |descent|) / 2 ≈ 0.3em.
116pub fn text_baseline_y_offset(font_size: f64, line_count: usize) -> f64 {
117    // For a single line, baseline should be below center by 0.3 * font_size
118    // because glyphs extend more above baseline (ascent) than below (descent).
119    let baseline_from_center = font_size * crate::constants::BASELINE_ASCENT_RATIO;
120    // For multi-line, shift up by half the total block height (from first to last baseline).
121    let line_height = font_size * crate::constants::LINE_HEIGHT_MULTIPLIER;
122    let block_offset = (line_count as f64 - 1.0) * line_height / 2.0;
123    baseline_from_center - block_offset
124}
125
126/// A text span with inline markdown formatting.
127#[derive(Debug, Clone, PartialEq)]
128pub struct MdSpan {
129    pub text: String,
130    pub bold: bool,
131    pub italic: bool,
132}
133
134/// Parse inline markdown (`**bold**`, `*italic*`, `***both***`) into styled spans.
135/// Returns `None` if the text contains no formatting markers.
136pub fn parse_inline_markdown(text: &str) -> Option<Vec<MdSpan>> {
137    if !text.contains('*') {
138        return None;
139    }
140
141    let mut spans = Vec::new();
142    let mut bold = false;
143    let mut italic = false;
144    let mut buf = String::new();
145    let mut chars = text.chars().peekable();
146
147    while let Some(c) = chars.next() {
148        if c == '*' && chars.peek() == Some(&'*') {
149            chars.next();
150            if !buf.is_empty() {
151                spans.push(MdSpan {
152                    text: std::mem::take(&mut buf),
153                    bold,
154                    italic,
155                });
156            }
157            bold = !bold;
158        } else if c == '*' {
159            if !buf.is_empty() {
160                spans.push(MdSpan {
161                    text: std::mem::take(&mut buf),
162                    bold,
163                    italic,
164                });
165            }
166            italic = !italic;
167        } else {
168            buf.push(c);
169        }
170    }
171    if !buf.is_empty() {
172        spans.push(MdSpan {
173            text: buf,
174            bold,
175            italic,
176        });
177    }
178
179    if spans.iter().any(|s| s.bold || s.italic) {
180        Some(spans)
181    } else {
182        None
183    }
184}
185
186/// Strip HTML tags from text in a single pass.
187/// Replaces `<br/>` variants with newlines; strips other tags.
188/// When `include_markdown` is true, also removes `**` and `*` markers.
189fn strip_tags(text: &str, include_markdown: bool) -> String {
190    let mut result = String::with_capacity(text.len());
191    let mut in_tag = false;
192    let mut tag_buf = String::with_capacity(8);
193    let mut chars = text.chars().peekable();
194
195    while let Some(ch) = chars.next() {
196        if in_tag {
197            if ch == '>' {
198                in_tag = false;
199                if tag_buf.eq_ignore_ascii_case("br")
200                    || tag_buf.eq_ignore_ascii_case("br/")
201                    || tag_buf.eq_ignore_ascii_case("br /")
202                {
203                    result.push('\n');
204                }
205            } else {
206                tag_buf.push(ch);
207            }
208        } else if ch == '<' {
209            in_tag = true;
210            tag_buf.clear();
211        } else if include_markdown && ch == '*' {
212            if chars.peek() == Some(&'*') {
213                chars.next();
214            }
215        } else {
216            result.push(ch);
217        }
218    }
219    result
220}
221
222/// Strip HTML tags and markdown markers (`*`, `**`).
223fn strip_markup(text: &str) -> String {
224    strip_tags(text, true)
225}
226
227/// Strip HTML tags only (no markdown removal).
228#[cfg(test)]
229fn strip_html_tags(text: &str) -> String {
230    strip_tags(text, false)
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    const W: f64 = MONOSPACE_CHAR_WIDTH_14PX;
237    const LH: f64 = MONOSPACE_LINE_HEIGHT_14PX;
238
239    fn default_style() -> TextStyle {
240        TextStyle::default()
241    }
242
243    #[test]
244    fn simple_measure_basic() {
245        let m = SimpleTextMeasure::default();
246        let s = m.measure("hello", &default_style());
247        assert!((s.width - 5.0 * W).abs() < f64::EPSILON);
248        assert!((s.height - LH).abs() < f64::EPSILON);
249    }
250
251    #[test]
252    fn simple_measure_empty() {
253        let m = SimpleTextMeasure::default();
254        let s = m.measure("", &default_style());
255        assert!((s.width - 0.0).abs() < f64::EPSILON);
256        assert!((s.height - LH).abs() < f64::EPSILON);
257    }
258
259    #[test]
260    fn simple_measure_strips_html() {
261        let m = SimpleTextMeasure::default();
262        let s = m.measure("<b>bold</b>", &default_style());
263        assert!((s.width - 4.0 * W).abs() < f64::EPSILON);
264    }
265
266    #[test]
267    fn simple_measure_br_adds_lines() {
268        let m = SimpleTextMeasure::default();
269        let s = m.measure("line1<br/>line2", &default_style());
270        assert!((s.height - 2.0 * LH).abs() < f64::EPSILON);
271    }
272
273    #[test]
274    fn simple_measure_font_size_scales() {
275        let m = SimpleTextMeasure::default();
276        let mut style = default_style();
277        style.font_size = 28.0; // 2x default
278        let s = m.measure("ab", &style);
279        assert!((s.width - 2.0 * W * 2.0).abs() < f64::EPSILON);
280        assert!((s.height - LH * 2.0).abs() < f64::EPSILON);
281    }
282
283    #[test]
284    fn simple_measure_custom_char_width() {
285        let m = SimpleTextMeasure::new(10.0, 20.0);
286        let s = m.measure("abc", &default_style());
287        assert!((s.width - 30.0).abs() < f64::EPSILON);
288        assert!((s.height - 20.0).abs() < f64::EPSILON);
289    }
290
291    #[test]
292    fn measure_strips_markdown() {
293        let m = SimpleTextMeasure::default();
294        let w_plain = m.measure("bold", &default_style()).width;
295        let w_md = m.measure("**bold**", &default_style()).width;
296        assert!(
297            (w_plain - w_md).abs() < f64::EPSILON,
298            "markdown markers should be stripped: plain={w_plain} md={w_md}"
299        );
300    }
301
302    #[test]
303    fn strip_html_basic() {
304        assert_eq!(strip_html_tags("<b>bold</b>"), "bold");
305        assert_eq!(strip_html_tags("<i>italic</i>"), "italic");
306        assert_eq!(strip_html_tags("no tags"), "no tags");
307    }
308
309    #[test]
310    fn strip_html_br_to_newline() {
311        assert_eq!(strip_html_tags("a<br/>b"), "a\nb");
312        assert_eq!(strip_html_tags("a<br>b"), "a\nb");
313        assert_eq!(strip_html_tags("a<br />b"), "a\nb");
314    }
315
316    #[test]
317    fn strip_html_nested() {
318        assert_eq!(strip_html_tags("<b><i>text</i></b>"), "text");
319    }
320
321    #[test]
322    fn default_trait() {
323        let m = SimpleTextMeasure::default();
324        assert!((m.avg_char_width - W).abs() < f64::EPSILON);
325        assert!((m.line_height - LH).abs() < f64::EPSILON);
326    }
327
328    #[test]
329    fn cjk_chars_wider_than_latin() {
330        let m = SimpleTextMeasure::default();
331        let w = m.measure("你好世界", &default_style()).width;
332        assert!((w - 4.0 * 1.8 * W).abs() < 1e-10);
333    }
334
335    #[test]
336    fn japanese_kana_wider_than_latin() {
337        let m = SimpleTextMeasure::default();
338        let w = m.measure("こんにちは世界", &default_style()).width;
339        assert!((w - 7.0 * 1.8 * W).abs() < 1e-10);
340    }
341
342    #[test]
343    fn mixed_latin_cjk() {
344        let m = SimpleTextMeasure::default();
345        let w = m.measure("Hi你好", &default_style()).width;
346        assert!((w - (2.0 + 2.0 * 1.8) * W).abs() < 1e-10);
347    }
348
349    #[test]
350    fn latin_and_cyrillic_widths() {
351        let m = SimpleTextMeasure::default();
352        let w_latin = m.measure("hello", &default_style()).width;
353        let w_cyrillic = m.measure("приве", &default_style()).width;
354        assert!((w_latin - 5.0 * W).abs() < 1e-10);
355        assert!((w_cyrillic - 5.0 * 0.85 * W).abs() < 1e-10);
356    }
357
358    #[test]
359    fn char_width_ratios() {
360        assert!((char_width_ratio('A') - 1.0).abs() < f64::EPSILON); // Primary
361        assert!((char_width_ratio('你') - 1.8).abs() < f64::EPSILON); // CJK
362        assert!((char_width_ratio('α') - 0.85).abs() < f64::EPSILON); // ExtendedText
363        assert!((char_width_ratio('★') - 1.4).abs() < f64::EPSILON); // Dingbats
364        assert!((char_width_ratio('→') - 1.0).abs() < f64::EPSILON); // Monospace
365        assert!((char_width_ratio('م') - 0.8).abs() < f64::EPSILON); // Arabic
366    }
367
368    // ── Text measurement property tests (13.10) ──
369
370    use proptest::prelude::*;
371
372    proptest! {
373        #[test]
374        fn char_width_ratio_always_positive(c in proptest::char::any()) {
375            let r = char_width_ratio(c);
376            prop_assert!(r > 0.0, "char_width_ratio({c:?}) = {r}, must be > 0");
377        }
378
379        #[test]
380        fn measure_width_positive_for_nonempty(
381            text in "[a-zA-Z0-9]{1,20}",
382        ) {
383            let m = SimpleTextMeasure::default();
384            let s = m.measure(&text, &default_style());
385            prop_assert!(s.width > 0.0, "width must be > 0 for non-empty text, got {}", s.width);
386            prop_assert!(s.height > 0.0, "height must be > 0, got {}", s.height);
387        }
388
389        #[test]
390        fn measure_scales_linearly_with_font_size(
391            text in "[a-z]{1,10}",
392            scale in 0.5..4.0f64,
393        ) {
394            let m = SimpleTextMeasure::default();
395            let base_style = default_style();
396            let mut scaled_style = base_style.clone();
397            scaled_style.font_size = base_style.font_size * scale;
398
399            let s1 = m.measure(&text, &base_style);
400            let s2 = m.measure(&text, &scaled_style);
401
402            prop_assert!((s2.width / s1.width - scale).abs() < 1e-10,
403                "width should scale by {scale}: w1={}, w2={}", s1.width, s2.width);
404            prop_assert!((s2.height / s1.height - scale).abs() < 1e-10,
405                "height should scale by {scale}: h1={}, h2={}", s1.height, s2.height);
406        }
407    }
408}