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}