Skip to main content

merman_render/text/
measure.rs

1//! Text measurement trait shared by renderers and wrapping helpers.
2
3use super::{TextMetrics, TextStyle, WrapMode};
4
5pub trait TextMeasurer {
6    fn measure(&self, text: &str, style: &TextStyle) -> TextMetrics;
7
8    /// Measures SVG `<tspan>.getComputedTextLength()`-like widths (advance length along the
9    /// baseline).
10    ///
11    /// Mermaid's Timeline diagram uses `getComputedTextLength()` to decide when to wrap tokens
12    /// into additional `<tspan>` lines. This length can differ meaningfully from `getBBox().width`
13    /// (which includes glyph overhang), especially near wrapping boundaries.
14    ///
15    /// Default implementation falls back to bbox-derived widths.
16    fn measure_svg_text_computed_length_px(&self, text: &str, style: &TextStyle) -> f64 {
17        self.measure_svg_simple_text_bbox_width_px(text, style)
18    }
19
20    /// Measures the horizontal extents of an SVG `<text>` element relative to its anchor `x`.
21    ///
22    /// Mermaid's flowchart-v2 viewport sizing uses `getBBox()` on the rendered SVG. For `<text>`
23    /// elements this bbox can be slightly asymmetric around the anchor due to glyph overhangs.
24    ///
25    /// Default implementation assumes a symmetric bbox: `left = right = width/2`.
26    fn measure_svg_text_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
27        let m = self.measure(text, style);
28        let half = (m.width.max(0.0)) / 2.0;
29        (half, half)
30    }
31
32    /// Measures SVG `<text>.getBBox()` horizontal extents while including ASCII overhang.
33    ///
34    /// Upstream Mermaid bbox behavior can be asymmetric even for ASCII strings due to glyph
35    /// outlines and hinting. Most diagrams in this codebase intentionally ignore ASCII overhang
36    /// to avoid systematic `viewBox` drift, but some diagrams (notably `timeline`) rely on the
37    /// actual `getBBox()` extents when labels can overflow node shapes.
38    ///
39    /// Default implementation falls back to the symmetric bbox measurement.
40    fn measure_svg_text_bbox_x_with_ascii_overhang(
41        &self,
42        text: &str,
43        style: &TextStyle,
44    ) -> (f64, f64) {
45        self.measure_svg_text_bbox_x(text, style)
46    }
47
48    /// Measures the horizontal extents for Mermaid diagram titles rendered as a single `<text>`
49    /// node (no whitespace-tokenized `<tspan>` runs).
50    ///
51    /// Mermaid flowchart-v2 uses this style for `flowchartTitleText`, and the bbox impacts the
52    /// final `viewBox` / `max-width` computed via `getBBox()`.
53    fn measure_svg_title_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
54        self.measure_svg_text_bbox_x(text, style)
55    }
56
57    /// Measures the bbox width for Mermaid `drawSimpleText(...).getBBox().width`-style probes
58    /// (used by upstream `calculateTextWidth`).
59    ///
60    /// This should reflect actual glyph outline extents (including ASCII overhang where present),
61    /// rather than the symmetric/center-anchored title bbox approximation.
62    fn measure_svg_simple_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
63        let (l, r) = self.measure_svg_title_bbox_x(text, style);
64        (l + r).max(0.0)
65    }
66
67    /// Measures the bbox height for Mermaid `drawSimpleText(...).getBBox().height`-style probes.
68    ///
69    /// Upstream Mermaid uses `<text>.getBBox()` for some diagrams (notably `gitGraph` commit/tag
70    /// labels). Those `<text>` nodes are not split into `<tspan>` runs, and empirically their
71    /// bbox height behaves closer to ~`1.1em` than the slightly taller first-line heuristic used
72    /// by `measure_wrapped(..., WrapMode::SvgLike)`.
73    ///
74    /// Default implementation falls back to `measure(...).height`.
75    fn measure_svg_simple_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
76        let m = self.measure(text, style);
77        m.height.max(0.0)
78    }
79
80    fn measure_wrapped(
81        &self,
82        text: &str,
83        style: &TextStyle,
84        max_width: Option<f64>,
85        wrap_mode: WrapMode,
86    ) -> TextMetrics {
87        let _ = max_width;
88        let _ = wrap_mode;
89        self.measure(text, style)
90    }
91
92    /// Measures wrapped text and (optionally) returns the unwrapped width for the same payload.
93    ///
94    /// This exists mainly to avoid redundant measurement passes in diagrams that need both:
95    /// - wrapped metrics (for height/line breaks), and
96    /// - a raw "overflow width" probe (for sizing containers that can visually overflow).
97    ///
98    /// Default implementation returns `None` for `raw_width_px` and callers may fall back to an
99    /// explicit second measurement if needed.
100    fn measure_wrapped_with_raw_width(
101        &self,
102        text: &str,
103        style: &TextStyle,
104        max_width: Option<f64>,
105        wrap_mode: WrapMode,
106    ) -> (TextMetrics, Option<f64>) {
107        (
108            self.measure_wrapped(text, style, max_width, wrap_mode),
109            None,
110        )
111    }
112
113    /// Measures wrapped text while disabling any implementation-specific HTML overrides.
114    ///
115    /// This is primarily used for Markdown labels measured via DOM in upstream Mermaid, where we
116    /// want a raw regular-weight baseline before applying `<strong>/<em>` deltas.
117    fn measure_wrapped_raw(
118        &self,
119        text: &str,
120        style: &TextStyle,
121        max_width: Option<f64>,
122        wrap_mode: WrapMode,
123    ) -> TextMetrics {
124        self.measure_wrapped(text, style, max_width, wrap_mode)
125    }
126}