Skip to main content

merman_render/text/
measure.rs

1//! Infallible 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 with a complete, infallible text stack can
6//! implement this trait, wrap it in a [`crate::environment::TextMeasurementProfile`], and install
7//! a [`crate::environment::TextMeasurementPolicy`] on the operation-owned
8//! [`crate::environment::RenderEnvironment`]. Fallible host callbacks should implement
9//! [`crate::environment::HostTextMeasurer`] instead; the environment's phase policy owns their
10//! deterministic fallback and records its provenance.
11
12use super::{TextMetrics, TextStyle, WrapMode};
13
14pub(crate) const MERMAID_CREATE_TEXT_DEFAULT_WIDTH_PX: f64 = 200.0;
15
16/// Measures label text for layout decisions.
17///
18/// This trait is deliberately infallible. Implementations must not call a host and silently choose
19/// their own fallback; that would bypass the operation's phase routing. Use
20/// [`crate::environment::HostTextMeasurer`] for host callbacks that can decline or fail.
21///
22/// `TextMeasurer` is the extension point for editors and other hosts that need layout to match
23/// their own font system. Implementations should cache aggressively: flowchart/class/sequence
24/// layout can ask for the same label in several wrap modes while computing nodes, edges, and final
25/// SVG.
26///
27/// The default deterministic measurer provides portable, bounded heuristics without font assets.
28/// A host-provided measurer can instead use platform text APIs, a UI toolkit text system, or an
29/// optional font engine while preserving the rest of merman's parser/layout/render pipeline.
30pub trait TextMeasurer {
31    /// Reports whether the owning render operation has been cancelled.
32    ///
33    /// The measurement trait remains infallible for custom hosts. The routed operation measurer
34    /// uses this hook so long-running built-in wrapping probes can stop promptly and let the next
35    /// fallible layout boundary replay the structured cancellation error.
36    #[doc(hidden)]
37    fn cancellation_requested(&self) -> bool {
38        false
39    }
40
41    /// Returns crate-private authority for reusing a measurement within the same routed operation.
42    ///
43    /// This hook is deliberately unnameable outside `merman-render`: external custom measurers use
44    /// the default `None`, while the operation-owned routed facade can validate an exact built-in
45    /// profile, phase, and operation without exposing a replayable public token.
46    #[doc(hidden)]
47    #[allow(private_interfaces)]
48    fn builtin_operation_carrier(
49        &self,
50        _operation: crate::environment::TextMeasurementOperation,
51    ) -> Option<crate::environment::BuiltinTextMeasurementOperationCarrier> {
52        None
53    }
54
55    /// Starts an exact streaming computed-length probe when this measurer is a built-in profile.
56    ///
57    /// The return type is crate-private so external measurers cannot bypass their observable host
58    /// callback order. They use the default `None` and continue receiving every complete request.
59    #[doc(hidden)]
60    #[allow(private_interfaces)]
61    fn begin_svg_text_computed_length(
62        &self,
63        _style: &TextStyle,
64    ) -> Option<crate::environment::BuiltinSvgComputedLength> {
65        None
66    }
67
68    fn measure(&self, text: &str, style: &TextStyle) -> TextMetrics;
69
70    /// Measures SVG `<tspan>.getComputedTextLength()`-like widths (advance length along the
71    /// baseline).
72    ///
73    /// Mermaid's Timeline diagram uses `getComputedTextLength()` to decide when to wrap tokens
74    /// into additional `<tspan>` lines. This length can differ meaningfully from `getBBox().width`
75    /// (which includes glyph overhang), especially near wrapping boundaries.
76    ///
77    /// Default implementation falls back to bbox-derived widths.
78    fn measure_svg_text_computed_length_px(&self, text: &str, style: &TextStyle) -> f64 {
79        self.measure_svg_simple_text_bbox_width_px(text, style)
80    }
81
82    /// Measures the horizontal extents of an SVG `<text>` element relative to its anchor `x`.
83    ///
84    /// Mermaid's flowchart-v2 viewport sizing uses `getBBox()` on the rendered SVG. For `<text>`
85    /// elements this bbox can be slightly asymmetric around the anchor due to glyph overhangs.
86    ///
87    /// Default implementation assumes a symmetric bbox: `left = right = width/2`.
88    fn measure_svg_text_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
89        let m = self.measure(text, style);
90        let half = (m.width.max(0.0)) / 2.0;
91        (half, half)
92    }
93
94    /// Measures SVG `<text>.getBBox()` horizontal extents while including ASCII overhang.
95    ///
96    /// Upstream Mermaid bbox behavior can be asymmetric even for ASCII strings due to glyph
97    /// outlines and hinting. Most diagrams in this codebase intentionally ignore ASCII overhang
98    /// to avoid systematic `viewBox` drift, but some diagrams (notably `timeline`) rely on the
99    /// actual `getBBox()` extents when labels can overflow node shapes.
100    ///
101    /// Default implementation falls back to the symmetric bbox measurement.
102    fn measure_svg_text_bbox_x_with_ascii_overhang(
103        &self,
104        text: &str,
105        style: &TextStyle,
106    ) -> (f64, f64) {
107        self.measure_svg_text_bbox_x(text, style)
108    }
109
110    /// Measures the horizontal extents for Mermaid diagram titles rendered as a single `<text>`
111    /// node (no whitespace-tokenized `<tspan>` runs).
112    ///
113    /// Mermaid flowchart-v2 uses this style for `flowchartTitleText`, and the bbox impacts the
114    /// final `viewBox` / `max-width` computed via `getBBox()`.
115    fn measure_svg_title_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
116        self.measure_svg_text_bbox_x(text, style)
117    }
118
119    /// Measures the bbox width for Mermaid `drawSimpleText(...).getBBox().width`-style probes
120    /// (used by upstream `calculateTextWidth`).
121    ///
122    /// This should reflect actual glyph outline extents (including ASCII overhang where present),
123    /// rather than the symmetric/center-anchored title bbox approximation.
124    fn measure_svg_simple_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
125        let (l, r) = self.measure_svg_title_bbox_x(text, style);
126        (l + r).max(0.0)
127    }
128
129    /// Measures raw SVG `<text>.getBBox().width` for diagram renderers that append text directly.
130    ///
131    /// Unlike [`TextMeasurer::measure_svg_simple_text_bbox_width_px`], this models the DOM shape
132    /// directly and does not inherit `drawSimpleText(...)`-specific behavior.
133    fn measure_svg_raw_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
134        self.measure_svg_simple_text_bbox_width_px(text, style)
135    }
136
137    /// Measures raw SVG `<text>.getBBox().height` for direct text content.
138    ///
139    /// This stays distinct from tspan and Mermaid helper probes so browser hosts can preserve the
140    /// exact DOM shape used by renderers such as TreeView.
141    fn measure_svg_raw_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
142        // This is an explicitly approximate compatibility default. Exact profiles override the
143        // raw-text operation instead of borrowing another DOM shape's result.
144        self.measure(text, style).height.max(0.0)
145    }
146
147    /// Measures the CSS pixel width returned by SVG `<text>.getBoundingClientRect().width`.
148    ///
149    /// This is a distinct browser primitive from `getBBox().width`: transforms, CSS pixel
150    /// quantization, and layout-engine rounding can make their results diverge. Mermaid uses the
151    /// client rect directly for Journey actor labels and Pie legend/title sizing. Headless
152    /// profiles fall back to the closest raw SVG text width; browser hosts should implement the
153    /// exact DOM operation.
154    fn measure_svg_text_bounding_client_rect_width_px(&self, text: &str, style: &TextStyle) -> f64 {
155        self.measure_svg_raw_text_bbox_width_px(text, style)
156    }
157
158    /// Measures a `<text>` node whose visible rows are emitted as child `<tspan>` elements.
159    ///
160    /// Browsers can apply different hinting and endpoint extents to this DOM shape than to a
161    /// direct text node. The default keeps custom measurers source-compatible by falling back to
162    /// raw SVG text measurement.
163    fn measure_svg_tspan_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
164        self.measure_svg_raw_text_bbox_width_px(text, style)
165    }
166
167    /// Measures the bbox height of a `<text>` node whose visible rows are child `<tspan>` nodes.
168    fn measure_svg_tspan_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
169        // This is an explicitly approximate compatibility default. Exact profiles override the
170        // single-tspan operation independently from raw `<text>` measurement.
171        self.measure(text, style).height.max(0.0)
172    }
173
174    /// Measures the `getBBox().y` offset of Mermaid's `createFormattedText(...)` SVG label.
175    ///
176    /// That DOM shape positions an outer `<tspan>` with `y="-0.1em"` and `dy="1.1em"` under a
177    /// `<text y="-10.1">` element. The resulting offset depends on the selected font and may be
178    /// negative, so browser-backed hosts should implement this operation directly.
179    fn measure_svg_create_text_bbox_y_offset_px(&self, _text: &str, _style: &TextStyle) -> f64 {
180        // No font-independent y offset exists. Profiles without this operation return the neutral
181        // baseline; operation-owned deterministic and host profiles provide the real DOM fact.
182        0.0
183    }
184
185    /// Measures the same `createFormattedText(...)` bbox y offset after the outer Architecture
186    /// label container applies inherited `dominant-baseline: middle`.
187    ///
188    /// SVG middle-baseline positioning depends on the selected font's x-height and is distinct
189    /// from the raw createText bbox operation. Browser-backed hosts should measure this DOM shape
190    /// directly. Profiles without this operation return a neutral value rather than reusing the
191    /// ordinary formatted-text result or guessing an x-height from an unrelated font.
192    fn measure_svg_create_text_middle_bbox_y_offset_px(
193        &self,
194        _text: &str,
195        _style: &TextStyle,
196    ) -> f64 {
197        0.0
198    }
199
200    /// Measures simple SVG text for wrap decisions.
201    ///
202    /// Incremental `wrapLabel(...)` probes and final rendered text can use different DOM shapes.
203    /// Implementations may specialize this method when the wrap probe's browser API differs from
204    /// the final node's bbox API.
205    fn measure_svg_simple_text_bbox_width_for_wrap_px(&self, text: &str, style: &TextStyle) -> f64 {
206        self.measure_svg_simple_text_bbox_width_px(text, style)
207    }
208
209    /// Measures one SVG row using Mermaid's body-attached `calculateTextDimensions(...)` probe.
210    ///
211    /// The probe's CSSOM assignment and DOM attachment can affect both dimensions, so this stays a
212    /// single metrics operation rather than combining an operation-specific width with a generic
213    /// height. Profiles may specialize it when invalid CSS falls back to the host's default font.
214    fn measure_mermaid_calculate_text_dimensions(
215        &self,
216        text: &str,
217        style: &TextStyle,
218    ) -> TextMetrics {
219        TextMetrics {
220            width: self.measure_svg_simple_text_bbox_width_for_wrap_px(text, style),
221            height: self.measure_svg_simple_text_bbox_height_px(text, style),
222            line_count: 1,
223        }
224    }
225
226    /// Measures Canvas2D `measureText(...).width` for canvas-backed layout engines.
227    ///
228    /// Canvas text advance is a distinct host primitive from SVG bbox and computed-length APIs.
229    /// The default uses the closest baseline-advance operation so existing custom profiles remain
230    /// source-compatible; browser-backed hosts should implement the exact Canvas2D operation.
231    fn measure_canvas_text_width_px(&self, text: &str, style: &TextStyle) -> f64 {
232        self.measure_svg_text_computed_length_px(text, style)
233    }
234
235    /// Measures the bbox height for Mermaid `drawSimpleText(...).getBBox().height`-style probes.
236    ///
237    /// Despite this method's historical "simple text" name, upstream `drawSimpleText(...)`
238    /// appends one child `<tspan>` to the `<text>` node. Exact implementations therefore route it
239    /// through their single-tspan DOM-shape profile, not raw direct text content.
240    ///
241    /// Default implementation falls back to `measure(...).height`.
242    fn measure_svg_simple_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
243        let m = self.measure(text, style);
244        m.height.max(0.0)
245    }
246
247    fn measure_wrapped(
248        &self,
249        text: &str,
250        style: &TextStyle,
251        max_width: Option<f64>,
252        wrap_mode: WrapMode,
253    ) -> TextMetrics {
254        let _ = max_width;
255        let _ = wrap_mode;
256        self.measure(text, style)
257    }
258
259    /// Measures wrapped text and (optionally) returns the unwrapped width for the same payload.
260    ///
261    /// This exists mainly to avoid redundant measurement passes in diagrams that need both:
262    /// - wrapped metrics (for height/line breaks), and
263    /// - a raw "overflow width" probe (for sizing containers that can visually overflow).
264    ///
265    /// Default implementation returns `None` for `raw_width_px` and callers may fall back to an
266    /// explicit second measurement if needed.
267    fn measure_wrapped_with_raw_width(
268        &self,
269        text: &str,
270        style: &TextStyle,
271        max_width: Option<f64>,
272        wrap_mode: WrapMode,
273    ) -> (TextMetrics, Option<f64>) {
274        (
275            self.measure_wrapped(text, style, max_width, wrap_mode),
276            None,
277        )
278    }
279}
280
281#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
282pub(crate) struct MermaidTextDimensions {
283    pub(crate) width: i64,
284    pub(crate) height: i64,
285    pub(crate) line_height: i64,
286}
287
288fn try_measure_mermaid_text_dimensions_for_family<E>(
289    measurer: &dyn TextMeasurer,
290    text: &str,
291    style: &TextStyle,
292    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
293) -> std::result::Result<MermaidTextDimensions, E> {
294    if text.is_empty() {
295        return Ok(MermaidTextDimensions::default());
296    }
297
298    let mut dimensions = MermaidTextDimensions::default();
299    for line in super::split_html_br_lines(text) {
300        checkpoint()?;
301        let measured_line = if line.is_empty() { "\u{200b}" } else { line };
302        checkpoint()?;
303        let measured = measurer.measure_mermaid_calculate_text_dimensions(measured_line, style);
304        checkpoint()?;
305        let width = measured.width.max(0.0).round() as i64;
306        let height = measured.height.max(0.0).round() as i64;
307        dimensions.width = dimensions.width.max(width);
308        dimensions.height += height;
309        dimensions.line_height = dimensions.line_height.max(height);
310    }
311    Ok(dimensions)
312}
313
314fn try_measure_mermaid_text_dimensions<E>(
315    measurer: &dyn TextMeasurer,
316    text: &str,
317    configured_style: &TextStyle,
318    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
319) -> std::result::Result<MermaidTextDimensions, E> {
320    let mut sans_style = configured_style.clone();
321    sans_style.font_family = Some("sans-serif".to_string());
322
323    let sans =
324        try_measure_mermaid_text_dimensions_for_family(measurer, text, &sans_style, checkpoint)?;
325    let configured = try_measure_mermaid_text_dimensions_for_family(
326        measurer,
327        text,
328        configured_style,
329        checkpoint,
330    )?;
331
332    if sans.width > configured.width
333        && sans.height > configured.height
334        && sans.line_height > configured.line_height
335    {
336        Ok(sans)
337    } else {
338        Ok(configured)
339    }
340}
341
342/// Mirrors Mermaid's shared `calculateTextDimensions` utility.
343pub(crate) fn measure_mermaid_text_dimensions(
344    measurer: &dyn TextMeasurer,
345    text: &str,
346    configured_style: &TextStyle,
347) -> MermaidTextDimensions {
348    let mut checkpoint = || Ok::<(), std::convert::Infallible>(());
349    match try_measure_mermaid_text_dimensions(measurer, text, configured_style, &mut checkpoint) {
350        Ok(dimensions) => dimensions,
351        Err(error) => match error {},
352    }
353}
354
355pub(crate) fn measure_mermaid_text_dimensions_with_checkpoint(
356    measurer: &dyn TextMeasurer,
357    text: &str,
358    configured_style: &TextStyle,
359    mut checkpoint: impl FnMut() -> crate::Result<()>,
360) -> crate::Result<MermaidTextDimensions> {
361    try_measure_mermaid_text_dimensions(measurer, text, configured_style, &mut checkpoint)
362}