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.
30/// HTML measurement requests preserve unselected U+00AD controls for shaping. They have zero
31/// glyph advance; deleting them before shaping can change adjacent presentation selectors and
32/// combining sequences. A selected discretionary break replaces only its final U+00AD with `-`.
33pub trait TextMeasurer {
34    /// Reports whether the owning render operation has been cancelled.
35    ///
36    /// The measurement trait remains infallible for custom hosts. The routed operation measurer
37    /// uses this hook so long-running built-in wrapping probes can stop promptly and let the next
38    /// fallible layout boundary replay the structured cancellation error.
39    #[doc(hidden)]
40    fn cancellation_requested(&self) -> bool {
41        false
42    }
43
44    /// Returns crate-private authority for reusing a measurement within the same routed operation.
45    ///
46    /// This hook is deliberately unnameable outside `merman-render`: external custom measurers use
47    /// the default `None`, while the operation-owned routed facade can validate an exact built-in
48    /// profile, phase, and operation without exposing a replayable public token.
49    #[doc(hidden)]
50    #[allow(private_interfaces)]
51    fn builtin_operation_carrier(
52        &self,
53        _operation: crate::environment::TextMeasurementOperation,
54    ) -> Option<crate::environment::BuiltinTextMeasurementOperationCarrier> {
55        None
56    }
57
58    /// Starts an exact streaming computed-length probe when this measurer is a built-in profile.
59    ///
60    /// The return type is crate-private so external measurers cannot bypass their observable host
61    /// callback order. They use the default `None` and continue receiving every complete request.
62    #[doc(hidden)]
63    #[allow(private_interfaces)]
64    fn begin_svg_text_computed_length(
65        &self,
66        _style: &TextStyle,
67    ) -> Option<crate::environment::BuiltinSvgComputedLength> {
68        None
69    }
70
71    /// Starts an exact streaming width probe for one decoded HTML text line.
72    ///
73    /// The crate-private state is available only to qualified built-in wrapping routes. External
74    /// custom and host-backed measurers retain the default `None`, so each complete candidate
75    /// remains an observable measurement request and may use non-additive text shaping.
76    #[doc(hidden)]
77    #[allow(private_interfaces)]
78    fn begin_html_line_width(
79        &self,
80        _style: &TextStyle,
81    ) -> Option<crate::environment::BuiltinHtmlLineWidth> {
82        None
83    }
84
85    /// Measures one decoded HTML text line without interpreting literal markup spellings.
86    ///
87    /// Callers have already normalized whitespace and selected discretionary glyphs. The default
88    /// preserves the existing complete wrapped-measurement contract for custom implementations.
89    #[doc(hidden)]
90    fn measure_html_line_width_px(&self, text: &str, style: &TextStyle) -> f64 {
91        self.measure_wrapped(text, style, None, WrapMode::HtmlLike)
92            .width
93    }
94
95    fn measure(&self, text: &str, style: &TextStyle) -> TextMetrics;
96
97    /// Measures SVG `<tspan>.getComputedTextLength()`-like widths (advance length along the
98    /// baseline).
99    ///
100    /// Mermaid's Timeline diagram uses `getComputedTextLength()` to decide when to wrap tokens
101    /// into additional `<tspan>` lines. This length can differ meaningfully from `getBBox().width`
102    /// (which includes glyph overhang), especially near wrapping boundaries.
103    ///
104    /// Default implementation falls back to bbox-derived widths.
105    fn measure_svg_text_computed_length_px(&self, text: &str, style: &TextStyle) -> f64 {
106        self.measure_svg_simple_text_bbox_width_px(text, style)
107    }
108
109    /// Measures the horizontal extents of an SVG `<text>` element relative to its anchor `x`.
110    ///
111    /// Mermaid's flowchart-v2 viewport sizing uses `getBBox()` on the rendered SVG. For `<text>`
112    /// elements this bbox can be slightly asymmetric around the anchor due to glyph overhangs.
113    ///
114    /// Default implementation assumes a symmetric bbox: `left = right = width/2`.
115    fn measure_svg_text_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
116        let m = self.measure(text, style);
117        let half = (m.width.max(0.0)) / 2.0;
118        (half, half)
119    }
120
121    /// Measures SVG `<text>.getBBox()` horizontal extents while including ASCII overhang.
122    ///
123    /// Upstream Mermaid bbox behavior can be asymmetric even for ASCII strings due to glyph
124    /// outlines and hinting. Most diagrams in this codebase intentionally ignore ASCII overhang
125    /// to avoid systematic `viewBox` drift, but some diagrams (notably `timeline`) rely on the
126    /// actual `getBBox()` extents when labels can overflow node shapes.
127    ///
128    /// Default implementation falls back to the symmetric bbox measurement.
129    fn measure_svg_text_bbox_x_with_ascii_overhang(
130        &self,
131        text: &str,
132        style: &TextStyle,
133    ) -> (f64, f64) {
134        self.measure_svg_text_bbox_x(text, style)
135    }
136
137    /// Measures the horizontal extents for Mermaid diagram titles rendered as a single `<text>`
138    /// node (no whitespace-tokenized `<tspan>` runs).
139    ///
140    /// Mermaid flowchart-v2 uses this style for `flowchartTitleText`, and the bbox impacts the
141    /// final `viewBox` / `max-width` computed via `getBBox()`.
142    fn measure_svg_title_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
143        self.measure_svg_text_bbox_x(text, style)
144    }
145
146    /// Measures the bbox width for Mermaid `drawSimpleText(...).getBBox().width`-style probes
147    /// (used by upstream `calculateTextWidth`).
148    ///
149    /// This should reflect actual glyph outline extents (including ASCII overhang where present),
150    /// rather than the symmetric/center-anchored title bbox approximation.
151    fn measure_svg_simple_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
152        let (l, r) = self.measure_svg_title_bbox_x(text, style);
153        (l + r).max(0.0)
154    }
155
156    /// Measures raw SVG `<text>.getBBox().width` for diagram renderers that append text directly.
157    ///
158    /// Unlike [`TextMeasurer::measure_svg_simple_text_bbox_width_px`], this models the DOM shape
159    /// directly and does not inherit `drawSimpleText(...)`-specific behavior.
160    fn measure_svg_raw_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
161        self.measure_svg_simple_text_bbox_width_px(text, style)
162    }
163
164    /// Measures raw SVG `<text>.getBBox().height` for direct text content.
165    ///
166    /// This stays distinct from tspan and Mermaid helper probes so browser hosts can preserve the
167    /// exact DOM shape used by renderers such as TreeView.
168    fn measure_svg_raw_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
169        // This is an explicitly approximate compatibility default. Exact profiles override the
170        // raw-text operation instead of borrowing another DOM shape's result.
171        self.measure(text, style).height.max(0.0)
172    }
173
174    /// Signed bbox Y of direct SVG text at baseline zero. `None` means this profile does not
175    /// supply baseline facts; callers retain their established compatibility bounds.
176    fn measure_svg_raw_text_bbox_y_offset_px(
177        &self,
178        _text: &str,
179        _style: &TextStyle,
180    ) -> Option<f64> {
181        None
182    }
183
184    /// Signed bbox Y of direct SVG text with inherited `dominant-baseline: middle`.
185    /// This is not the specially positioned Mermaid createFormattedText DOM.
186    fn measure_svg_raw_text_middle_bbox_y_offset_px(
187        &self,
188        _text: &str,
189        _style: &TextStyle,
190    ) -> Option<f64> {
191        None
192    }
193
194    /// Signed bbox Y of direct SVG text with inherited `dominant-baseline: hanging`.
195    fn measure_svg_raw_text_hanging_bbox_y_offset_px(
196        &self,
197        _text: &str,
198        _style: &TextStyle,
199    ) -> Option<f64> {
200        None
201    }
202
203    /// Measures the CSS pixel width returned by SVG `<text>.getBoundingClientRect().width`.
204    ///
205    /// This is a distinct browser primitive from `getBBox().width`: transforms, CSS pixel
206    /// quantization, and layout-engine rounding can make their results diverge. Mermaid uses the
207    /// client rect directly for Journey actor labels and Pie legend/title sizing. Headless
208    /// profiles fall back to the closest raw SVG text width; browser hosts should implement the
209    /// exact DOM operation.
210    fn measure_svg_text_bounding_client_rect_width_px(&self, text: &str, style: &TextStyle) -> f64 {
211        self.measure_svg_raw_text_bbox_width_px(text, style)
212    }
213
214    /// Measures a `<text>` node whose visible rows are emitted as child `<tspan>` elements.
215    ///
216    /// Browsers can apply different hinting and endpoint extents to this DOM shape than to a
217    /// direct text node. The default keeps custom measurers source-compatible by falling back to
218    /// raw SVG text measurement.
219    fn measure_svg_tspan_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
220        self.measure_svg_raw_text_bbox_width_px(text, style)
221    }
222
223    /// Measures child `<tspan>` rows with explicit `xml:space="preserve"` semantics.
224    ///
225    /// Mermaid's `gitGraphRenderer.drawText` preserves interior row whitespace, as does Merman's
226    /// Gitgraph SVG emitter. Routed hosts receive the existing `TspanBBoxWidth` operation with
227    /// `WrapMode::HtmlLike` to distinguish preserved spaces from ordinary SVG collapsing; this
228    /// probe remains unwrapped. Existing custom profiles keep their tspan-width implementation.
229    fn measure_svg_preserved_tspan_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
230        self.measure_svg_tspan_text_bbox_width_px(text, style)
231    }
232
233    /// Measures the bbox height of a `<text>` node whose visible rows are child `<tspan>` nodes.
234    fn measure_svg_tspan_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
235        // This is an explicitly approximate compatibility default. Exact profiles override the
236        // single-tspan operation independently from raw `<text>` measurement.
237        self.measure(text, style).height.max(0.0)
238    }
239
240    /// Measures the `getBBox().y` offset of Mermaid's `createFormattedText(...)` SVG label.
241    ///
242    /// That DOM shape positions an outer `<tspan>` with `y="-0.1em"` and `dy="1.1em"` under a
243    /// `<text y="-10.1">` element. The resulting offset depends on the selected font and may be
244    /// negative, so browser-backed hosts should implement this operation directly.
245    fn measure_svg_create_text_bbox_y_offset_px(&self, _text: &str, _style: &TextStyle) -> f64 {
246        // No font-independent y offset exists. Profiles without this operation return the neutral
247        // baseline; operation-owned deterministic and host profiles provide the real DOM fact.
248        0.0
249    }
250
251    /// Measures the same `createFormattedText(...)` bbox y offset after the outer Architecture
252    /// label container applies inherited `dominant-baseline: middle`.
253    ///
254    /// SVG middle-baseline positioning depends on the selected font's x-height and is distinct
255    /// from the raw createText bbox operation. Browser-backed hosts should measure this DOM shape
256    /// directly. Profiles without this operation return a neutral value rather than reusing the
257    /// ordinary formatted-text result or guessing an x-height from an unrelated font.
258    fn measure_svg_create_text_middle_bbox_y_offset_px(
259        &self,
260        _text: &str,
261        _style: &TextStyle,
262    ) -> f64 {
263        0.0
264    }
265
266    /// Measures simple SVG text for wrap decisions.
267    ///
268    /// Incremental `wrapLabel(...)` probes and final rendered text can use different DOM shapes.
269    /// Implementations may specialize this method when the wrap probe's browser API differs from
270    /// the final node's bbox API.
271    fn measure_svg_simple_text_bbox_width_for_wrap_px(&self, text: &str, style: &TextStyle) -> f64 {
272        self.measure_svg_simple_text_bbox_width_px(text, style)
273    }
274
275    /// Measures one SVG row using Mermaid's body-attached `calculateTextDimensions(...)` probe.
276    ///
277    /// The probe's CSSOM assignment and DOM attachment can affect both dimensions, so this stays a
278    /// single metrics operation rather than combining an operation-specific width with a generic
279    /// height. Profiles may specialize it when invalid CSS falls back to the host's default font.
280    fn measure_mermaid_calculate_text_dimensions(
281        &self,
282        text: &str,
283        style: &TextStyle,
284    ) -> TextMetrics {
285        TextMetrics {
286            width: self.measure_svg_simple_text_bbox_width_for_wrap_px(text, style),
287            height: self.measure_svg_simple_text_bbox_height_px(text, style),
288            line_count: 1,
289        }
290    }
291
292    /// Measures Canvas2D `measureText(...).width` for canvas-backed layout engines.
293    ///
294    /// Canvas text advance is a distinct host primitive from SVG bbox and computed-length APIs.
295    /// The default uses the closest baseline-advance operation so existing custom profiles remain
296    /// source-compatible; browser-backed hosts should implement the exact Canvas2D operation.
297    fn measure_canvas_text_width_px(&self, text: &str, style: &TextStyle) -> f64 {
298        self.measure_svg_text_computed_length_px(text, style)
299    }
300
301    /// Measures the bbox height for Mermaid `drawSimpleText(...).getBBox().height`-style probes.
302    ///
303    /// Despite this method's historical "simple text" name, upstream `drawSimpleText(...)`
304    /// appends one child `<tspan>` to the `<text>` node. Exact implementations therefore route it
305    /// through their single-tspan DOM-shape profile, not raw direct text content.
306    ///
307    /// Default implementation falls back to `measure(...).height`.
308    fn measure_svg_simple_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
309        let m = self.measure(text, style);
310        m.height.max(0.0)
311    }
312
313    fn measure_wrapped(
314        &self,
315        text: &str,
316        style: &TextStyle,
317        max_width: Option<f64>,
318        wrap_mode: WrapMode,
319    ) -> TextMetrics {
320        let _ = max_width;
321        let _ = wrap_mode;
322        self.measure(text, style)
323    }
324
325    /// Measures wrapped text and (optionally) returns the unwrapped width for the same payload.
326    ///
327    /// This exists mainly to avoid redundant measurement passes in diagrams that need both:
328    /// - wrapped metrics (for height/line breaks), and
329    /// - a raw "overflow width" probe (for sizing containers that can visually overflow).
330    ///
331    /// Default implementation returns `None` for `raw_width_px` and callers may fall back to an
332    /// explicit second measurement if needed.
333    fn measure_wrapped_with_raw_width(
334        &self,
335        text: &str,
336        style: &TextStyle,
337        max_width: Option<f64>,
338        wrap_mode: WrapMode,
339    ) -> (TextMetrics, Option<f64>) {
340        (
341            self.measure_wrapped(text, style, max_width, wrap_mode),
342            None,
343        )
344    }
345}
346
347#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
348pub(crate) struct MermaidTextDimensions {
349    pub(crate) width: i64,
350    pub(crate) height: i64,
351    pub(crate) line_height: i64,
352}
353
354fn try_measure_mermaid_text_dimensions_for_family<E>(
355    measurer: &dyn TextMeasurer,
356    text: &str,
357    style: &TextStyle,
358    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
359) -> std::result::Result<MermaidTextDimensions, E> {
360    if text.is_empty() {
361        return Ok(MermaidTextDimensions::default());
362    }
363
364    let mut dimensions = MermaidTextDimensions::default();
365    for line in super::split_html_br_lines(text) {
366        checkpoint()?;
367        let measured_line = if line.is_empty() { "\u{200b}" } else { line };
368        checkpoint()?;
369        let measured = measurer.measure_mermaid_calculate_text_dimensions(measured_line, style);
370        checkpoint()?;
371        let width = measured.width.max(0.0).round() as i64;
372        let height = measured.height.max(0.0).round() as i64;
373        dimensions.width = dimensions.width.max(width);
374        dimensions.height += height;
375        dimensions.line_height = dimensions.line_height.max(height);
376    }
377    Ok(dimensions)
378}
379
380fn try_measure_mermaid_text_dimensions<E>(
381    measurer: &dyn TextMeasurer,
382    text: &str,
383    configured_style: &TextStyle,
384    checkpoint: &mut impl FnMut() -> std::result::Result<(), E>,
385) -> std::result::Result<MermaidTextDimensions, E> {
386    let mut sans_style = configured_style.clone();
387    sans_style.font_family = Some("sans-serif".to_string());
388
389    let sans =
390        try_measure_mermaid_text_dimensions_for_family(measurer, text, &sans_style, checkpoint)?;
391    let configured = try_measure_mermaid_text_dimensions_for_family(
392        measurer,
393        text,
394        configured_style,
395        checkpoint,
396    )?;
397
398    if sans.width > configured.width
399        && sans.height > configured.height
400        && sans.line_height > configured.line_height
401    {
402        Ok(sans)
403    } else {
404        Ok(configured)
405    }
406}
407
408/// Mirrors Mermaid's shared `calculateTextDimensions` utility.
409pub(crate) fn measure_mermaid_text_dimensions(
410    measurer: &dyn TextMeasurer,
411    text: &str,
412    configured_style: &TextStyle,
413) -> MermaidTextDimensions {
414    let mut checkpoint = || Ok::<(), std::convert::Infallible>(());
415    match try_measure_mermaid_text_dimensions(measurer, text, configured_style, &mut checkpoint) {
416        Ok(dimensions) => dimensions,
417        Err(error) => match error {},
418    }
419}
420
421pub(crate) fn measure_mermaid_text_dimensions_with_checkpoint(
422    measurer: &dyn TextMeasurer,
423    text: &str,
424    configured_style: &TextStyle,
425    mut checkpoint: impl FnMut() -> crate::Result<()>,
426) -> crate::Result<MermaidTextDimensions> {
427    try_measure_mermaid_text_dimensions(measurer, text, configured_style, &mut checkpoint)
428}