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}