Skip to main content

merman_render/text/
measure.rs

1//! Text measurement trait shared by renderers and wrapping helpers.
2//!
3//! Headless Mermaid layout has to size labels before there is a browser DOM. The built-in
4//! measurers are therefore compatibility profiles, not a promise that every host browser will pick
5//! the same font fallback at display time. Hosts that already own a text stack can implement this
6//! trait and pass it through `LayoutOptions::with_text_measurer` or
7//! `merman::render::HeadlessRenderer::with_text_measurer`.
8
9use super::{TextMetrics, TextStyle, WrapMode};
10
11/// Measures label text for layout decisions.
12///
13/// `TextMeasurer` is the extension point for editors and other hosts that need layout to match
14/// their own font system. Implementations should cache aggressively: flowchart/class/sequence
15/// layout can ask for the same label in several wrap modes while computing nodes, edges, and final
16/// SVG.
17///
18/// The default vendored measurer optimizes for Mermaid fixture parity and a light dependency graph.
19/// A host-provided measurer can instead use platform text APIs, a UI toolkit text system, or an
20/// optional font engine while preserving the rest of merman's parser/layout/render pipeline.
21pub trait TextMeasurer {
22    fn measure(&self, text: &str, style: &TextStyle) -> TextMetrics;
23
24    /// Measures SVG `<tspan>.getComputedTextLength()`-like widths (advance length along the
25    /// baseline).
26    ///
27    /// Mermaid's Timeline diagram uses `getComputedTextLength()` to decide when to wrap tokens
28    /// into additional `<tspan>` lines. This length can differ meaningfully from `getBBox().width`
29    /// (which includes glyph overhang), especially near wrapping boundaries.
30    ///
31    /// Default implementation falls back to bbox-derived widths.
32    fn measure_svg_text_computed_length_px(&self, text: &str, style: &TextStyle) -> f64 {
33        self.measure_svg_simple_text_bbox_width_px(text, style)
34    }
35
36    /// Measures the horizontal extents of an SVG `<text>` element relative to its anchor `x`.
37    ///
38    /// Mermaid's flowchart-v2 viewport sizing uses `getBBox()` on the rendered SVG. For `<text>`
39    /// elements this bbox can be slightly asymmetric around the anchor due to glyph overhangs.
40    ///
41    /// Default implementation assumes a symmetric bbox: `left = right = width/2`.
42    fn measure_svg_text_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
43        let m = self.measure(text, style);
44        let half = (m.width.max(0.0)) / 2.0;
45        (half, half)
46    }
47
48    /// Measures SVG `<text>.getBBox()` horizontal extents while including ASCII overhang.
49    ///
50    /// Upstream Mermaid bbox behavior can be asymmetric even for ASCII strings due to glyph
51    /// outlines and hinting. Most diagrams in this codebase intentionally ignore ASCII overhang
52    /// to avoid systematic `viewBox` drift, but some diagrams (notably `timeline`) rely on the
53    /// actual `getBBox()` extents when labels can overflow node shapes.
54    ///
55    /// Default implementation falls back to the symmetric bbox measurement.
56    fn measure_svg_text_bbox_x_with_ascii_overhang(
57        &self,
58        text: &str,
59        style: &TextStyle,
60    ) -> (f64, f64) {
61        self.measure_svg_text_bbox_x(text, style)
62    }
63
64    /// Measures the horizontal extents for Mermaid diagram titles rendered as a single `<text>`
65    /// node (no whitespace-tokenized `<tspan>` runs).
66    ///
67    /// Mermaid flowchart-v2 uses this style for `flowchartTitleText`, and the bbox impacts the
68    /// final `viewBox` / `max-width` computed via `getBBox()`.
69    fn measure_svg_title_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
70        self.measure_svg_text_bbox_x(text, style)
71    }
72
73    /// Measures the bbox width for Mermaid `drawSimpleText(...).getBBox().width`-style probes
74    /// (used by upstream `calculateTextWidth`).
75    ///
76    /// This should reflect actual glyph outline extents (including ASCII overhang where present),
77    /// rather than the symmetric/center-anchored title bbox approximation.
78    fn measure_svg_simple_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
79        let (l, r) = self.measure_svg_title_bbox_x(text, style);
80        (l + r).max(0.0)
81    }
82
83    /// Measures raw SVG `<text>.getBBox().width` for diagram renderers that append text directly.
84    ///
85    /// Unlike [`TextMeasurer::measure_svg_simple_text_bbox_width_px`], this intentionally avoids
86    /// diagram-specific `drawSimpleText(...)` compatibility overrides.
87    fn measure_svg_raw_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
88        self.measure_svg_simple_text_bbox_width_px(text, style)
89    }
90
91    /// Measures simple SVG text for wrap decisions.
92    ///
93    /// Some implementations carry fixture-derived exact text-width overrides for final layout
94    /// sizing. Those can be too sharp for incremental `wrapLabel(...)` probes, where changing one
95    /// candidate prefix width changes the emitted DOM line structure. Implementations may override
96    /// this to use their smoother base font model for wrap decisions.
97    fn measure_svg_simple_text_bbox_width_for_wrap_px(&self, text: &str, style: &TextStyle) -> f64 {
98        self.measure_svg_simple_text_bbox_width_px(text, style)
99    }
100
101    /// Measures the bbox height for Mermaid `drawSimpleText(...).getBBox().height`-style probes.
102    ///
103    /// Upstream Mermaid uses `<text>.getBBox()` for some diagrams (notably `gitGraph` commit/tag
104    /// labels). Those `<text>` nodes are not split into `<tspan>` runs, and empirically their
105    /// bbox height behaves closer to ~`1.1em` than the slightly taller first-line heuristic used
106    /// by `measure_wrapped(..., WrapMode::SvgLike)`.
107    ///
108    /// Default implementation falls back to `measure(...).height`.
109    fn measure_svg_simple_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
110        let m = self.measure(text, style);
111        m.height.max(0.0)
112    }
113
114    fn measure_wrapped(
115        &self,
116        text: &str,
117        style: &TextStyle,
118        max_width: Option<f64>,
119        wrap_mode: WrapMode,
120    ) -> TextMetrics {
121        let _ = max_width;
122        let _ = wrap_mode;
123        self.measure(text, style)
124    }
125
126    /// Measures wrapped text and (optionally) returns the unwrapped width for the same payload.
127    ///
128    /// This exists mainly to avoid redundant measurement passes in diagrams that need both:
129    /// - wrapped metrics (for height/line breaks), and
130    /// - a raw "overflow width" probe (for sizing containers that can visually overflow).
131    ///
132    /// Default implementation returns `None` for `raw_width_px` and callers may fall back to an
133    /// explicit second measurement if needed.
134    fn measure_wrapped_with_raw_width(
135        &self,
136        text: &str,
137        style: &TextStyle,
138        max_width: Option<f64>,
139        wrap_mode: WrapMode,
140    ) -> (TextMetrics, Option<f64>) {
141        (
142            self.measure_wrapped(text, style, max_width, wrap_mode),
143            None,
144        )
145    }
146
147    /// Measures wrapped text while disabling any implementation-specific HTML overrides.
148    ///
149    /// This is primarily used for Markdown labels measured via DOM in upstream Mermaid, where we
150    /// want a raw regular-weight baseline before applying `<strong>/<em>` deltas.
151    fn measure_wrapped_raw(
152        &self,
153        text: &str,
154        style: &TextStyle,
155        max_width: Option<f64>,
156        wrap_mode: WrapMode,
157    ) -> TextMetrics {
158        self.measure_wrapped(text, style, max_width, wrap_mode)
159    }
160}