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}