merman_render/text/measure.rs
1//! Text measurement trait shared by renderers and wrapping helpers.
2
3use super::{TextMetrics, TextStyle, WrapMode};
4
5pub trait TextMeasurer {
6 fn measure(&self, text: &str, style: &TextStyle) -> TextMetrics;
7
8 /// Measures SVG `<tspan>.getComputedTextLength()`-like widths (advance length along the
9 /// baseline).
10 ///
11 /// Mermaid's Timeline diagram uses `getComputedTextLength()` to decide when to wrap tokens
12 /// into additional `<tspan>` lines. This length can differ meaningfully from `getBBox().width`
13 /// (which includes glyph overhang), especially near wrapping boundaries.
14 ///
15 /// Default implementation falls back to bbox-derived widths.
16 fn measure_svg_text_computed_length_px(&self, text: &str, style: &TextStyle) -> f64 {
17 self.measure_svg_simple_text_bbox_width_px(text, style)
18 }
19
20 /// Measures the horizontal extents of an SVG `<text>` element relative to its anchor `x`.
21 ///
22 /// Mermaid's flowchart-v2 viewport sizing uses `getBBox()` on the rendered SVG. For `<text>`
23 /// elements this bbox can be slightly asymmetric around the anchor due to glyph overhangs.
24 ///
25 /// Default implementation assumes a symmetric bbox: `left = right = width/2`.
26 fn measure_svg_text_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
27 let m = self.measure(text, style);
28 let half = (m.width.max(0.0)) / 2.0;
29 (half, half)
30 }
31
32 /// Measures SVG `<text>.getBBox()` horizontal extents while including ASCII overhang.
33 ///
34 /// Upstream Mermaid bbox behavior can be asymmetric even for ASCII strings due to glyph
35 /// outlines and hinting. Most diagrams in this codebase intentionally ignore ASCII overhang
36 /// to avoid systematic `viewBox` drift, but some diagrams (notably `timeline`) rely on the
37 /// actual `getBBox()` extents when labels can overflow node shapes.
38 ///
39 /// Default implementation falls back to the symmetric bbox measurement.
40 fn measure_svg_text_bbox_x_with_ascii_overhang(
41 &self,
42 text: &str,
43 style: &TextStyle,
44 ) -> (f64, f64) {
45 self.measure_svg_text_bbox_x(text, style)
46 }
47
48 /// Measures the horizontal extents for Mermaid diagram titles rendered as a single `<text>`
49 /// node (no whitespace-tokenized `<tspan>` runs).
50 ///
51 /// Mermaid flowchart-v2 uses this style for `flowchartTitleText`, and the bbox impacts the
52 /// final `viewBox` / `max-width` computed via `getBBox()`.
53 fn measure_svg_title_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
54 self.measure_svg_text_bbox_x(text, style)
55 }
56
57 /// Measures the bbox width for Mermaid `drawSimpleText(...).getBBox().width`-style probes
58 /// (used by upstream `calculateTextWidth`).
59 ///
60 /// This should reflect actual glyph outline extents (including ASCII overhang where present),
61 /// rather than the symmetric/center-anchored title bbox approximation.
62 fn measure_svg_simple_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
63 let (l, r) = self.measure_svg_title_bbox_x(text, style);
64 (l + r).max(0.0)
65 }
66
67 /// Measures raw SVG `<text>.getBBox().width` for diagram renderers that append text directly.
68 ///
69 /// Unlike [`TextMeasurer::measure_svg_simple_text_bbox_width_px`], this intentionally avoids
70 /// diagram-specific `drawSimpleText(...)` compatibility overrides.
71 fn measure_svg_raw_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
72 self.measure_svg_simple_text_bbox_width_px(text, style)
73 }
74
75 /// Measures simple SVG text for wrap decisions.
76 ///
77 /// Some implementations carry fixture-derived exact text-width overrides for final layout
78 /// sizing. Those can be too sharp for incremental `wrapLabel(...)` probes, where changing one
79 /// candidate prefix width changes the emitted DOM line structure. Implementations may override
80 /// this to use their smoother base font model for wrap decisions.
81 fn measure_svg_simple_text_bbox_width_for_wrap_px(&self, text: &str, style: &TextStyle) -> f64 {
82 self.measure_svg_simple_text_bbox_width_px(text, style)
83 }
84
85 /// Measures the bbox height for Mermaid `drawSimpleText(...).getBBox().height`-style probes.
86 ///
87 /// Upstream Mermaid uses `<text>.getBBox()` for some diagrams (notably `gitGraph` commit/tag
88 /// labels). Those `<text>` nodes are not split into `<tspan>` runs, and empirically their
89 /// bbox height behaves closer to ~`1.1em` than the slightly taller first-line heuristic used
90 /// by `measure_wrapped(..., WrapMode::SvgLike)`.
91 ///
92 /// Default implementation falls back to `measure(...).height`.
93 fn measure_svg_simple_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
94 let m = self.measure(text, style);
95 m.height.max(0.0)
96 }
97
98 fn measure_wrapped(
99 &self,
100 text: &str,
101 style: &TextStyle,
102 max_width: Option<f64>,
103 wrap_mode: WrapMode,
104 ) -> TextMetrics {
105 let _ = max_width;
106 let _ = wrap_mode;
107 self.measure(text, style)
108 }
109
110 /// Measures wrapped text and (optionally) returns the unwrapped width for the same payload.
111 ///
112 /// This exists mainly to avoid redundant measurement passes in diagrams that need both:
113 /// - wrapped metrics (for height/line breaks), and
114 /// - a raw "overflow width" probe (for sizing containers that can visually overflow).
115 ///
116 /// Default implementation returns `None` for `raw_width_px` and callers may fall back to an
117 /// explicit second measurement if needed.
118 fn measure_wrapped_with_raw_width(
119 &self,
120 text: &str,
121 style: &TextStyle,
122 max_width: Option<f64>,
123 wrap_mode: WrapMode,
124 ) -> (TextMetrics, Option<f64>) {
125 (
126 self.measure_wrapped(text, style, max_width, wrap_mode),
127 None,
128 )
129 }
130
131 /// Measures wrapped text while disabling any implementation-specific HTML overrides.
132 ///
133 /// This is primarily used for Markdown labels measured via DOM in upstream Mermaid, where we
134 /// want a raw regular-weight baseline before applying `<strong>/<em>` deltas.
135 fn measure_wrapped_raw(
136 &self,
137 text: &str,
138 style: &TextStyle,
139 max_width: Option<f64>,
140 wrap_mode: WrapMode,
141 ) -> TextMetrics {
142 self.measure_wrapped(text, style, max_width, wrap_mode)
143 }
144}