Skip to main content

merman_render/text/
font_metrics.rs

1//! Vendored browser/font metrics text measurer.
2
3use super::{
4    DeterministicTextMeasurer, FLOWCHART_DEFAULT_FONT_KEY, TextMeasurer, TextMetrics, TextStyle,
5    WrapMode, flowchart_default_bold_delta_em, flowchart_default_bold_kern_delta_em,
6    flowchart_default_bold_svg_right_overhang_em, font_key_uses_courier_metrics,
7    is_flowchart_default_font, overrides, round_to_1_64_px, style_requests_bold_font_weight,
8    svg_wrapped_first_line_bbox_height_px,
9};
10
11#[derive(Debug, Clone, Default)]
12pub struct VendoredFontMetricsTextMeasurer {
13    fallback: DeterministicTextMeasurer,
14}
15
16#[derive(Clone, Copy)]
17struct FontMetricProfile<'a> {
18    entries: &'a [(char, f64)],
19    default_em: f64,
20    kern_pairs: &'a [(u32, u32, f64)],
21    space_trigrams: &'a [(u32, u32, f64)],
22    trigrams: &'a [(u32, u32, u32, f64)],
23    missing_v_comma_kern_em: f64,
24    missing_t_o_kern_em: f64,
25    missing_t_r_kern_em: f64,
26    missing_space_before_capital_a_em: f64,
27    missing_space_after_capital_a_before_open_paren_em: f64,
28}
29
30impl VendoredFontMetricsTextMeasurer {
31    fn metric_profile(
32        table: &crate::generated::font_metrics_flowchart_11_12_2::FontMetricsTables,
33    ) -> FontMetricProfile<'_> {
34        FontMetricProfile {
35            entries: table.entries,
36            default_em: table.default_em.max(0.1),
37            kern_pairs: table.kern_pairs,
38            space_trigrams: table.space_trigrams,
39            trigrams: table.trigrams,
40            missing_v_comma_kern_em: if table.font_key == FLOWCHART_DEFAULT_FONT_KEY {
41                -140.0 / 1024.0
42            } else {
43                0.0
44            },
45            missing_t_o_kern_em: if table.font_key == FLOWCHART_DEFAULT_FONT_KEY {
46                -128.0 / 1024.0
47            } else {
48                0.0
49            },
50            missing_t_r_kern_em: if table.font_key == FLOWCHART_DEFAULT_FONT_KEY {
51                -113.0 / 1024.0
52            } else {
53                0.0
54            },
55            missing_space_before_capital_a_em: if table.font_key
56                == "trebuchetms,verdana,arial,sans-serif"
57            {
58                -57.0 / 1024.0
59            } else {
60                0.0
61            },
62            missing_space_after_capital_a_before_open_paren_em: if table.font_key
63                == "trebuchetms,verdana,arial,sans-serif"
64            {
65                -57.0 / 1024.0
66            } else {
67                0.0
68            },
69        }
70    }
71
72    pub(super) fn quantize_svg_bbox_px_nearest(v: f64) -> f64 {
73        if !(v.is_finite() && v >= 0.0) {
74            return 0.0;
75        }
76        // Title/label `getBBox()` extents in upstream fixtures frequently land on 1/1024px
77        // increments. Quantize after applying svg-overrides so (em * font_size) does not leak FP
78        // noise into viewBox/max-width comparisons.
79        let x = v * 1024.0;
80        let f = x.floor();
81        let frac = x - f;
82        let i = if frac < 0.5 {
83            f
84        } else if frac > 0.5 {
85            f + 1.0
86        } else {
87            let fi = f as i64;
88            if fi % 2 == 0 { f } else { f + 1.0 }
89        };
90        i / 1024.0
91    }
92
93    fn quantize_svg_half_px_nearest(half_px: f64) -> f64 {
94        if !(half_px.is_finite() && half_px >= 0.0) {
95            return 0.0;
96        }
97        // SVG `getBBox()` metrics in upstream Mermaid baselines tend to behave like a truncation
98        // on a power-of-two grid for the anchored half-advance. Using `floor` here avoids a
99        // systematic +1/256px drift in wide titles that can bubble up into `viewBox`/`max-width`.
100        (half_px * 256.0).floor() / 256.0
101    }
102
103    fn normalize_font_key(s: &str) -> String {
104        s.chars()
105            .filter_map(|ch| {
106                // Mermaid config strings occasionally embed the trailing CSS `;` in `fontFamily`.
107                // We treat it as syntactic noise so lookups work with both `...sans-serif` and
108                // `...sans-serif;`.
109                if ch.is_whitespace() || ch == '"' || ch == '\'' || ch == ';' {
110                    None
111                } else {
112                    Some(ch.to_ascii_lowercase())
113                }
114            })
115            .collect()
116    }
117
118    fn lookup_table(
119        &self,
120        style: &TextStyle,
121    ) -> Option<&'static crate::generated::font_metrics_flowchart_11_12_2::FontMetricsTables> {
122        let key = style
123            .font_family
124            .as_deref()
125            .map(Self::normalize_font_key)
126            .unwrap_or_default();
127        let key = if key.is_empty() {
128            // Mermaid defaults to `"trebuchet ms", verdana, arial, sans-serif`. Many headless
129            // layout call sites omit `font_family` and rely on that implicit default.
130            FLOWCHART_DEFAULT_FONT_KEY
131        } else {
132            key.as_str()
133        };
134        if let Some(t) = crate::generated::font_metrics_flowchart_11_12_2::lookup_font_metrics(key)
135        {
136            return Some(t);
137        }
138
139        // Best-effort aliases for common stacks in upstream fixtures (Mermaid measures via DOM,
140        // while our vendored tables cover a small set of representative families).
141        let key_lower = key;
142        if font_key_uses_courier_metrics(key_lower) {
143            return crate::generated::font_metrics_flowchart_11_12_2::lookup_font_metrics(
144                "courier",
145            );
146        }
147        // Prefer explicit generic stacks. If the font family does not match a known table and
148        // does not include an explicit fallback token like `sans-serif`, fall back to the
149        // deterministic measurer (unknown fonts vary widely across environments).
150        if key_lower.contains("sans-serif") {
151            return crate::generated::font_metrics_flowchart_11_12_2::lookup_font_metrics(
152                "sans-serif",
153            );
154        }
155        None
156    }
157
158    fn lookup_char_em(entries: &[(char, f64)], default_em: f64, ch: char) -> f64 {
159        fn find_entry_em(entries: &[(char, f64)], ch: char) -> Option<f64> {
160            let mut lo = 0usize;
161            let mut hi = entries.len();
162            while lo < hi {
163                let mid = (lo + hi) / 2;
164                match entries[mid].0.cmp(&ch) {
165                    std::cmp::Ordering::Equal => return Some(entries[mid].1),
166                    std::cmp::Ordering::Less => lo = mid + 1,
167                    std::cmp::Ordering::Greater => hi = mid,
168                }
169            }
170            None
171        }
172
173        if let Some(em) = find_entry_em(entries, ch) {
174            return em;
175        }
176
177        // Browser-measured metric tables are generated from observed fixture text, so a table can
178        // contain one side of a mirrored ASCII punctuation pair but not the other. Use the measured
179        // counterpart before falling back to the broad average; this keeps ordinary punctuation
180        // labels deterministic without adding fixture-specific width lookups.
181        let paired = match ch {
182            '(' => Some(')'),
183            ')' => Some('('),
184            '[' => Some(']'),
185            ']' => Some('['),
186            '{' => Some('}'),
187            '}' => Some('{'),
188            _ => None,
189        };
190        if let Some(other) = paired
191            && let Some(other_em) = find_entry_em(entries, other)
192        {
193            return other_em;
194        }
195        if ch.is_ascii() {
196            return default_em;
197        }
198
199        if ('\u{80}'..='\u{9f}').contains(&ch) {
200            // Mermaid/Chromium preserves C1 control bytes that appear in mojibake labels from
201            // upstream fixtures and measures the rendered replacement glyph. Chromium 11.15
202            // reports these glyphs closer to a narrow fallback than a full CJK cell for Flowchart
203            // HTML labels, so keep the fallback near 0.6em.
204            return 0.598_7;
205        }
206
207        Self::lookup_non_ascii_fallback_em(default_em, ch)
208    }
209
210    fn lookup_non_ascii_fallback_em(default_em: f64, ch: char) -> f64 {
211        let code = ch as u32;
212
213        // Mermaid's default font stack is `"trebuchet ms", verdana, arial, sans-serif`.
214        // In browser rendering, non-Latin glyphs frequently fall back to script-specific fonts
215        // rather than inheriting Trebuchet's Latin average. Keep the model at Unicode block
216        // granularity: this mirrors browser fallback classes without adding per-fixture strings
217        // or glyph lookup tables.
218        if unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1) == 0
219            || (0x1f3fb..=0x1f3ff).contains(&code)
220        {
221            return 0.0;
222        }
223        if (0x0590..=0x05ff).contains(&code) {
224            return 0.479_980_468_75;
225        }
226        if (0x1f300..=0x1faff).contains(&code) || (0x2600..=0x27bf).contains(&code) {
227            return 1.249_67;
228        }
229        if (0xac00..=0xd7af).contains(&code) {
230            return 0.864_257_812_5;
231        }
232
233        match unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1) {
234            2.. => 1.0,
235            _ => default_em,
236        }
237    }
238
239    fn lookup_kern_em(kern_pairs: &[(u32, u32, f64)], a: char, b: char) -> f64 {
240        let key_a = a as u32;
241        let key_b = b as u32;
242        let mut lo = 0usize;
243        let mut hi = kern_pairs.len();
244        while lo < hi {
245            let mid = (lo + hi) / 2;
246            let (ma, mb, v) = kern_pairs[mid];
247            match (ma.cmp(&key_a), mb.cmp(&key_b)) {
248                (std::cmp::Ordering::Equal, std::cmp::Ordering::Equal) => return v,
249                (std::cmp::Ordering::Less, _) => lo = mid + 1,
250                (std::cmp::Ordering::Equal, std::cmp::Ordering::Less) => lo = mid + 1,
251                _ => hi = mid,
252            }
253        }
254        0.0
255    }
256
257    fn lookup_profile_kern_em(profile: FontMetricProfile<'_>, a: char, b: char) -> f64 {
258        let explicit = Self::lookup_kern_em(profile.kern_pairs, a, b);
259        if explicit != 0.0 {
260            return explicit;
261        }
262
263        if a == 'v' && b == ',' {
264            // The generated default-font table captures strong comma kerning for nearby lowercase
265            // terminal shapes such as `r,` and `y,`, but fixture coverage does not always observe
266            // `v,`. Keep this as a narrow missing-pair fallback instead of adding literal label
267            // overrides for JSON-like prose.
268            return profile.missing_v_comma_kern_em;
269        }
270        if a == 'T' && b == 'o' {
271            return profile.missing_t_o_kern_em;
272        }
273        if a == 'T' && b == 'r' {
274            return profile.missing_t_r_kern_em;
275        }
276
277        0.0
278    }
279
280    fn lookup_space_trigram_em(space_trigrams: &[(u32, u32, f64)], a: char, b: char) -> f64 {
281        let key_a = a as u32;
282        let key_b = b as u32;
283        let mut lo = 0usize;
284        let mut hi = space_trigrams.len();
285        while lo < hi {
286            let mid = (lo + hi) / 2;
287            let (ma, mb, v) = space_trigrams[mid];
288            match (ma.cmp(&key_a), mb.cmp(&key_b)) {
289                (std::cmp::Ordering::Equal, std::cmp::Ordering::Equal) => return v,
290                (std::cmp::Ordering::Less, _) => lo = mid + 1,
291                (std::cmp::Ordering::Equal, std::cmp::Ordering::Less) => lo = mid + 1,
292                _ => hi = mid,
293            }
294        }
295        0.0
296    }
297
298    fn lookup_trigram_em(trigrams: &[(u32, u32, u32, f64)], a: char, b: char, c: char) -> f64 {
299        let key_a = a as u32;
300        let key_b = b as u32;
301        let key_c = c as u32;
302        let mut lo = 0usize;
303        let mut hi = trigrams.len();
304        while lo < hi {
305            let mid = (lo + hi) / 2;
306            let (ma, mb, mc, v) = trigrams[mid];
307            match (ma.cmp(&key_a), mb.cmp(&key_b), mc.cmp(&key_c)) {
308                (
309                    std::cmp::Ordering::Equal,
310                    std::cmp::Ordering::Equal,
311                    std::cmp::Ordering::Equal,
312                ) => return v,
313                (std::cmp::Ordering::Less, _, _) => lo = mid + 1,
314                (std::cmp::Ordering::Equal, std::cmp::Ordering::Less, _) => lo = mid + 1,
315                (
316                    std::cmp::Ordering::Equal,
317                    std::cmp::Ordering::Equal,
318                    std::cmp::Ordering::Less,
319                ) => lo = mid + 1,
320                _ => hi = mid,
321            }
322        }
323        0.0
324    }
325
326    fn is_tiny_lattice_residual_em(v: f64) -> bool {
327        // At Mermaid's 16px default font size, Chromium's 1/64px DOM lattice is 1/1024em.
328        // Generated two-character samples can capture that quantization as a tiny "kerning"
329        // residual. For same-glyph runs, browser layout accumulates it per glyph pair cell
330        // (`ss`, `ssss`, ...), not per overlapping pair (`ss`, `sss`, ...).
331        v.abs() <= (1.0 / 1024.0) + 1e-12
332    }
333
334    fn same_glyph_pair_kern_em(
335        profile: FontMetricProfile<'_>,
336        a: char,
337        b: char,
338        same_run_len_after: usize,
339    ) -> f64 {
340        let kern = Self::lookup_profile_kern_em(profile, a, b);
341        if a == b && Self::is_tiny_lattice_residual_em(kern) && same_run_len_after % 2 == 1 {
342            0.0
343        } else {
344            kern
345        }
346    }
347
348    fn same_glyph_trigram_em(profile: FontMetricProfile<'_>, a: char, b: char, c: char) -> f64 {
349        let delta = Self::lookup_trigram_em(profile.trigrams, a, b, c);
350        if a == b && b == c && Self::is_tiny_lattice_residual_em(delta) {
351            0.0
352        } else {
353            delta
354        }
355    }
356
357    fn lookup_html_override_em(overrides: &[(&'static str, f64)], text: &str) -> Option<f64> {
358        let mut lo = 0usize;
359        let mut hi = overrides.len();
360        while lo < hi {
361            let mid = (lo + hi) / 2;
362            let (k, v) = overrides[mid];
363            match k.cmp(text) {
364                std::cmp::Ordering::Equal => return Some(v),
365                std::cmp::Ordering::Less => lo = mid + 1,
366                std::cmp::Ordering::Greater => hi = mid,
367            }
368        }
369        None
370    }
371
372    fn lookup_svg_override_em(
373        overrides: &[(&'static str, f64, f64)],
374        text: &str,
375    ) -> Option<(f64, f64)> {
376        let mut lo = 0usize;
377        let mut hi = overrides.len();
378        while lo < hi {
379            let mid = (lo + hi) / 2;
380            let (k, l, r) = overrides[mid];
381            match k.cmp(text) {
382                std::cmp::Ordering::Equal => return Some((l, r)),
383                std::cmp::Ordering::Less => lo = mid + 1,
384                std::cmp::Ordering::Greater => hi = mid,
385            }
386        }
387        None
388    }
389
390    fn lookup_overhang_em(entries: &[(char, f64)], default_em: f64, ch: char) -> f64 {
391        let mut lo = 0usize;
392        let mut hi = entries.len();
393        while lo < hi {
394            let mid = (lo + hi) / 2;
395            match entries[mid].0.cmp(&ch) {
396                std::cmp::Ordering::Equal => return entries[mid].1,
397                std::cmp::Ordering::Less => lo = mid + 1,
398                std::cmp::Ordering::Greater => hi = mid,
399            }
400        }
401        default_em
402    }
403
404    fn line_svg_bbox_extents_px(
405        table: &crate::generated::font_metrics_flowchart_11_12_2::FontMetricsTables,
406        text: &str,
407        font_size: f64,
408    ) -> (f64, f64) {
409        let profile = Self::metric_profile(table);
410        let t = text.trim_end();
411        if t.is_empty() {
412            return (0.0, 0.0);
413        }
414
415        if let Some((left_em, right_em)) = Self::lookup_svg_override_em(table.svg_overrides, t) {
416            let left = Self::quantize_svg_bbox_px_nearest((left_em * font_size).max(0.0));
417            let right = Self::quantize_svg_bbox_px_nearest((right_em * font_size).max(0.0));
418            return (left, right);
419        }
420
421        if let Some((left, right)) =
422            overrides::lookup_flowchart_svg_bbox_x_px(table.font_key, font_size, t)
423        {
424            return (left, right);
425        }
426
427        let first = t.chars().next().unwrap_or(' ');
428        let last = t.chars().last().unwrap_or(' ');
429
430        // Mermaid's SVG label renderer tokenizes whitespace into multiple inner `<tspan>` runs
431        // (one word per run, with a leading space on subsequent runs).
432        //
433        // These boundaries can affect shaping/kerning vs treating the text as one run, and those
434        // small differences bubble into Dagre layout and viewBox parity. Mirror the upstream
435        // behavior by summing per-run advances when whitespace tokenization would occur.
436        let advance_px_unscaled = {
437            let words: Vec<&str> = t.split_whitespace().filter(|s| !s.is_empty()).collect();
438            if words.len() >= 2 {
439                let mut sum_px = 0.0f64;
440                for (idx, w) in words.iter().enumerate() {
441                    if idx == 0 {
442                        sum_px += Self::line_width_px(profile, w, false, font_size);
443                    } else {
444                        let seg = format!(" {w}");
445                        sum_px += Self::line_width_px(profile, &seg, false, font_size);
446                    }
447                }
448                sum_px
449            } else {
450                Self::line_width_px(profile, t, false, font_size)
451            }
452        };
453
454        let advance_px = advance_px_unscaled * table.svg_scale;
455        let half = Self::quantize_svg_half_px_nearest((advance_px / 2.0).max(0.0));
456        // In upstream Mermaid fixtures, SVG `getBBox()` overhang at the ends of ASCII labels tends
457        // to behave like `0` after quantization/hinting, even for glyphs with a non-zero outline
458        // overhang (e.g. `s`). To avoid systematic `viewBox`/`max-width` drift, treat ASCII
459        // overhang as zero and only apply per-glyph overhang for non-ASCII.
460        // Most ASCII glyph overhang tends to quantize away in upstream SVG `getBBox()` fixtures,
461        // but frame labels (e.g. `[opt ...]`, `[loop ...]`) start/end with bracket-like glyphs
462        // where keeping overhang improves wrapping parity.
463        let left_oh_em = if first.is_ascii() && !matches!(first, '[' | '(' | '{') {
464            0.0
465        } else {
466            Self::lookup_overhang_em(
467                table.svg_bbox_overhang_left,
468                table.svg_bbox_overhang_left_default_em,
469                first,
470            )
471        };
472        let right_oh_em = if last.is_ascii() && !matches!(last, ']' | ')' | '}') {
473            0.0
474        } else {
475            Self::lookup_overhang_em(
476                table.svg_bbox_overhang_right,
477                table.svg_bbox_overhang_right_default_em,
478                last,
479            )
480        };
481
482        let left = (half + left_oh_em * font_size).max(0.0);
483        let right = (half + right_oh_em * font_size).max(0.0);
484        (left, right)
485    }
486
487    fn line_svg_bbox_extents_px_single_run(
488        table: &crate::generated::font_metrics_flowchart_11_12_2::FontMetricsTables,
489        text: &str,
490        font_size: f64,
491    ) -> (f64, f64) {
492        let profile = Self::metric_profile(table);
493        let t = text.trim_end();
494        if t.is_empty() {
495            return (0.0, 0.0);
496        }
497
498        if let Some((left_em, right_em)) = Self::lookup_svg_override_em(table.svg_overrides, t) {
499            let left = Self::quantize_svg_bbox_px_nearest((left_em * font_size).max(0.0));
500            let right = Self::quantize_svg_bbox_px_nearest((right_em * font_size).max(0.0));
501            return (left, right);
502        }
503
504        let first = t.chars().next().unwrap_or(' ');
505        let last = t.chars().last().unwrap_or(' ');
506
507        // Mermaid titles (e.g. flowchartTitleText) are rendered as a single `<text>` run, without
508        // whitespace-tokenized `<tspan>` segments. Measure as one run to keep viewport parity.
509        let advance_px_unscaled = Self::line_width_px(profile, t, false, font_size);
510
511        let advance_px = advance_px_unscaled * table.svg_scale;
512        let half = Self::quantize_svg_half_px_nearest((advance_px / 2.0).max(0.0));
513
514        let left_oh_em = if first.is_ascii() && !matches!(first, '[' | '(' | '{') {
515            0.0
516        } else {
517            Self::lookup_overhang_em(
518                table.svg_bbox_overhang_left,
519                table.svg_bbox_overhang_left_default_em,
520                first,
521            )
522        };
523        let right_oh_em = if last.is_ascii() && !matches!(last, ']' | ')' | '}') {
524            0.0
525        } else {
526            Self::lookup_overhang_em(
527                table.svg_bbox_overhang_right,
528                table.svg_bbox_overhang_right_default_em,
529                last,
530            )
531        };
532
533        let left = (half + left_oh_em * font_size).max(0.0);
534        let right = (half + right_oh_em * font_size).max(0.0);
535        (left, right)
536    }
537
538    fn line_svg_bbox_extents_px_single_run_with_ascii_overhang(
539        table: &crate::generated::font_metrics_flowchart_11_12_2::FontMetricsTables,
540        text: &str,
541        font_size: f64,
542    ) -> (f64, f64) {
543        Self::line_svg_bbox_extents_px_single_run_with_ascii_overhang_and_weight(
544            table, text, font_size, false,
545        )
546    }
547
548    fn line_svg_bbox_extents_px_single_run_with_ascii_overhang_and_weight(
549        table: &crate::generated::font_metrics_flowchart_11_12_2::FontMetricsTables,
550        text: &str,
551        font_size: f64,
552        bold: bool,
553    ) -> (f64, f64) {
554        let profile = Self::metric_profile(table);
555        let t = text.trim_end();
556        if t.is_empty() {
557            return (0.0, 0.0);
558        }
559
560        if let Some((left_em, right_em)) = Self::lookup_svg_override_em(table.svg_overrides, t) {
561            let left = Self::quantize_svg_bbox_px_nearest((left_em * font_size).max(0.0));
562            let right = Self::quantize_svg_bbox_px_nearest((right_em * font_size).max(0.0));
563            return (left, right);
564        }
565
566        let first = t.chars().next().unwrap_or(' ');
567        let last = t.chars().last().unwrap_or(' ');
568
569        let advance_px_unscaled = Self::line_width_px(profile, t, bold, font_size);
570
571        let advance_px = advance_px_unscaled * table.svg_scale;
572        let half = Self::quantize_svg_half_px_nearest((advance_px / 2.0).max(0.0));
573
574        let left_oh_em = Self::lookup_overhang_em(
575            table.svg_bbox_overhang_left,
576            table.svg_bbox_overhang_left_default_em,
577            first,
578        );
579        let mut right_oh_em = Self::lookup_overhang_em(
580            table.svg_bbox_overhang_right,
581            table.svg_bbox_overhang_right_default_em,
582            last,
583        );
584        if bold && table.font_key == FLOWCHART_DEFAULT_FONT_KEY {
585            right_oh_em = right_oh_em.max(flowchart_default_bold_svg_right_overhang_em(last));
586        }
587
588        let left = (half + left_oh_em * font_size).max(0.0);
589        let right = (half + right_oh_em * font_size).max(0.0);
590        (left, right)
591    }
592
593    fn line_svg_bbox_width_px(
594        table: &crate::generated::font_metrics_flowchart_11_12_2::FontMetricsTables,
595        text: &str,
596        font_size: f64,
597    ) -> f64 {
598        let (l, r) = Self::line_svg_bbox_extents_px(table, text, font_size);
599        (l + r).max(0.0)
600    }
601
602    fn line_svg_bbox_width_single_run_px(
603        table: &crate::generated::font_metrics_flowchart_11_12_2::FontMetricsTables,
604        text: &str,
605        font_size: f64,
606    ) -> f64 {
607        let t = text.trim_end();
608        if !t.is_empty()
609            && let Some((left_em, right_em)) =
610                overrides::lookup_sequence_svg_override_em(table.font_key, t)
611        {
612            let left = Self::quantize_svg_bbox_px_nearest((left_em * font_size).max(0.0));
613            let right = Self::quantize_svg_bbox_px_nearest((right_em * font_size).max(0.0));
614            return (left + right).max(0.0);
615        }
616
617        let (l, r) = Self::line_svg_bbox_extents_px_single_run(table, text, font_size);
618        (l + r).max(0.0)
619    }
620
621    fn line_svg_title_bbox_extents_px(
622        table: &crate::generated::font_metrics_flowchart_11_12_2::FontMetricsTables,
623        text: &str,
624        font_size: f64,
625    ) -> (f64, f64) {
626        let profile = Self::metric_profile(table);
627        let t = text.trim_end();
628        if t.is_empty() {
629            return (0.0, 0.0);
630        }
631
632        // Flowchart titles are emitted as a centered single `<text>` node. The final upstream
633        // root bbox behaves as a symmetric title advance, while the generic SVG override table
634        // captures simple-text probes with per-edge overhang. Keep title measurement separate so
635        // those simple-text asymmetries do not force fixture root viewport pins.
636        let advance_px = if let Some(em) = Self::lookup_html_override_em(table.html_overrides, t) {
637            em * font_size
638        } else {
639            Self::line_width_px(profile, t, false, font_size) * table.svg_scale
640        };
641        let half = Self::quantize_svg_half_px_nearest((advance_px / 2.0).max(0.0));
642        (half, half)
643    }
644
645    fn split_token_to_svg_bbox_width_px(
646        table: &crate::generated::font_metrics_flowchart_11_12_2::FontMetricsTables,
647        tok: &str,
648        max_width_px: f64,
649        font_size: f64,
650    ) -> (String, String) {
651        if max_width_px <= 0.0 {
652            return (tok.to_string(), String::new());
653        }
654        let chars = tok.chars().collect::<Vec<_>>();
655        if chars.is_empty() {
656            return (String::new(), String::new());
657        }
658
659        let first = chars[0];
660        let left_oh_em = if first.is_ascii() {
661            0.0
662        } else {
663            Self::lookup_overhang_em(
664                table.svg_bbox_overhang_left,
665                table.svg_bbox_overhang_left_default_em,
666                first,
667            )
668        };
669
670        let mut em = 0.0;
671        let mut prev: Option<char> = None;
672        let mut split_at = 1usize;
673        for (idx, ch) in chars.iter().enumerate() {
674            em += Self::lookup_char_em(table.entries, table.default_em.max(0.1), *ch);
675            if let Some(p) = prev {
676                em += Self::lookup_kern_em(table.kern_pairs, p, *ch);
677            }
678            prev = Some(*ch);
679
680            let right_oh_em = if ch.is_ascii() {
681                0.0
682            } else {
683                Self::lookup_overhang_em(
684                    table.svg_bbox_overhang_right,
685                    table.svg_bbox_overhang_right_default_em,
686                    *ch,
687                )
688            };
689            let half_px = Self::quantize_svg_half_px_nearest(
690                (em * font_size * table.svg_scale / 2.0).max(0.0),
691            );
692            let w_px = 2.0 * half_px + (left_oh_em + right_oh_em) * font_size;
693            if w_px.is_finite() && w_px <= max_width_px {
694                split_at = idx + 1;
695            } else if idx > 0 {
696                break;
697            }
698        }
699        let head = chars[..split_at].iter().collect::<String>();
700        let tail = chars[split_at..].iter().collect::<String>();
701        (head, tail)
702    }
703
704    fn wrap_text_lines_svg_bbox_px(
705        table: &crate::generated::font_metrics_flowchart_11_12_2::FontMetricsTables,
706        text: &str,
707        max_width_px: Option<f64>,
708        font_size: f64,
709        tokenize_whitespace: bool,
710    ) -> Vec<String> {
711        const EPS_PX: f64 = 0.125;
712        let max_width_px = max_width_px.filter(|w| w.is_finite() && *w > 0.0);
713        let width_fn = if tokenize_whitespace {
714            Self::line_svg_bbox_width_px
715        } else {
716            Self::line_svg_bbox_width_single_run_px
717        };
718
719        let mut lines = Vec::new();
720        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
721            let Some(w) = max_width_px else {
722                lines.push(line);
723                continue;
724            };
725
726            let mut tokens = std::collections::VecDeque::from(
727                DeterministicTextMeasurer::split_line_to_words(&line),
728            );
729            let mut out: Vec<String> = Vec::new();
730            let mut cur = String::new();
731
732            while let Some(tok) = tokens.pop_front() {
733                if cur.is_empty() && tok == " " {
734                    continue;
735                }
736
737                let candidate = format!("{cur}{tok}");
738                let candidate_trimmed = candidate.trim_end();
739                if width_fn(table, candidate_trimmed, font_size) <= w + EPS_PX {
740                    cur = candidate;
741                    continue;
742                }
743
744                if !cur.trim().is_empty() {
745                    out.push(cur.trim_end().to_string());
746                    cur.clear();
747                    tokens.push_front(tok);
748                    continue;
749                }
750
751                if tok == " " {
752                    continue;
753                }
754
755                if width_fn(table, tok.as_str(), font_size) <= w + EPS_PX {
756                    cur = tok;
757                    continue;
758                }
759
760                // Mermaid's SVG wrapping breaks long words.
761                let (head, tail) =
762                    Self::split_token_to_svg_bbox_width_px(table, &tok, w + EPS_PX, font_size);
763                out.push(head);
764                if !tail.is_empty() {
765                    tokens.push_front(tail);
766                }
767            }
768
769            if !cur.trim().is_empty() {
770                out.push(cur.trim_end().to_string());
771            }
772
773            if out.is_empty() {
774                lines.push("".to_string());
775            } else {
776                lines.extend(out);
777            }
778        }
779
780        if lines.is_empty() {
781            vec!["".to_string()]
782        } else {
783            lines
784        }
785    }
786
787    fn line_width_px(
788        profile: FontMetricProfile<'_>,
789        text: &str,
790        bold: bool,
791        font_size: f64,
792    ) -> f64 {
793        fn normalize_whitespace_like(ch: char) -> (char, f64) {
794            // Mermaid frequently uses `&nbsp;` inside HTML labels (e.g. block arrows). In SVG
795            // exports this becomes U+00A0. Treat it as a regular space for width/kerning models
796            // so it does not fall back to `default_em`.
797            //
798            // Empirically, for Mermaid@11.12.2 fixtures, U+00A0 measures slightly narrower than
799            // U+0020 in the default font stack. Model that as a tiny delta in `em` space so
800            // repeated `&nbsp;` placeholders land on the same 1/64px lattice as upstream.
801            const NBSP_DELTA_EM: f64 = -1.0 / 3072.0;
802            if ch == '\u{00A0}' {
803                (' ', NBSP_DELTA_EM)
804            } else {
805                (ch, 0.0)
806            }
807        }
808
809        let mut em = 0.0;
810        let mut prevprev: Option<char> = None;
811        let mut prev: Option<char> = None;
812        let mut same_run_len = 0usize;
813        for ch in text.chars() {
814            let (ch, delta_em) = normalize_whitespace_like(ch);
815            let next_same_run_len = if prev == Some(ch) {
816                same_run_len + 1
817            } else {
818                1
819            };
820            em += Self::lookup_char_em(profile.entries, profile.default_em, ch) + delta_em;
821            if let Some(p) = prev {
822                em += Self::same_glyph_pair_kern_em(profile, p, ch, next_same_run_len);
823            }
824            if bold {
825                if let Some(p) = prev {
826                    em += flowchart_default_bold_kern_delta_em(p, ch);
827                }
828                em += flowchart_default_bold_delta_em(ch);
829            }
830            if let (Some(a), Some(b)) = (prevprev, prev) {
831                if b == ' ' {
832                    if !(a.is_whitespace() || ch.is_whitespace()) {
833                        let space_delta =
834                            Self::lookup_space_trigram_em(profile.space_trigrams, a, ch);
835                        if space_delta != 0.0 {
836                            em += space_delta;
837                        } else if a == 'A' && ch == '(' {
838                            em += profile.missing_space_after_capital_a_before_open_paren_em;
839                        } else if ch == 'A' && a.is_ascii_alphanumeric() {
840                            // The default Mermaid stack consistently tightens a preceding word
841                            // space before capital `A`. The generated table captures this for
842                            // observed pairs such as `r A`; use the same profile delta as a
843                            // fallback for missing pairs instead of carrying per-label overrides.
844                            em += profile.missing_space_before_capital_a_em;
845                        }
846                    }
847                } else if !(a.is_whitespace() || b.is_whitespace() || ch.is_whitespace()) {
848                    em += Self::same_glyph_trigram_em(profile, a, b, ch);
849                }
850            }
851            prevprev = prev;
852            prev = Some(ch);
853            same_run_len = next_same_run_len;
854        }
855        em * font_size
856    }
857
858    fn split_token_to_width_px(
859        profile: FontMetricProfile<'_>,
860        tok: &str,
861        max_width_px: f64,
862        bold: bool,
863        font_size: f64,
864    ) -> (String, String) {
865        fn normalize_whitespace_like(ch: char) -> (char, f64) {
866            const NBSP_DELTA_EM: f64 = -1.0 / 3072.0;
867            if ch == '\u{00A0}' {
868                (' ', NBSP_DELTA_EM)
869            } else {
870                (ch, 0.0)
871            }
872        }
873
874        if max_width_px <= 0.0 {
875            return (tok.to_string(), String::new());
876        }
877        let max_em = max_width_px / font_size.max(1.0);
878        let mut em = 0.0;
879        let mut prevprev: Option<char> = None;
880        let mut prev: Option<char> = None;
881        let mut same_run_len = 0usize;
882        let chars = tok.chars().collect::<Vec<_>>();
883        let mut split_at = 0usize;
884        for (idx, ch) in chars.iter().enumerate() {
885            let (ch_norm, delta_em) = normalize_whitespace_like(*ch);
886            let next_same_run_len = if prev == Some(ch_norm) {
887                same_run_len + 1
888            } else {
889                1
890            };
891            em += Self::lookup_char_em(profile.entries, profile.default_em, ch_norm) + delta_em;
892            if let Some(p) = prev {
893                em += Self::same_glyph_pair_kern_em(profile, p, ch_norm, next_same_run_len);
894            }
895            if bold {
896                if let Some(p) = prev {
897                    em += flowchart_default_bold_kern_delta_em(p, ch_norm);
898                }
899                em += flowchart_default_bold_delta_em(ch_norm);
900            }
901            if let (Some(a), Some(b)) = (prevprev, prev)
902                && !(a.is_whitespace() || b.is_whitespace() || ch_norm.is_whitespace())
903            {
904                em += Self::same_glyph_trigram_em(profile, a, b, ch_norm);
905            }
906            prevprev = prev;
907            prev = Some(ch_norm);
908            same_run_len = next_same_run_len;
909            if em > max_em && idx > 0 {
910                break;
911            }
912            split_at = idx + 1;
913            if em >= max_em {
914                break;
915            }
916        }
917        if split_at == 0 {
918            split_at = 1.min(chars.len());
919        }
920        let head = chars.iter().take(split_at).collect::<String>();
921        let tail = chars.iter().skip(split_at).collect::<String>();
922        (head, tail)
923    }
924
925    fn wrap_line_to_width_px(
926        profile: FontMetricProfile<'_>,
927        line: &str,
928        max_width_px: f64,
929        font_size: f64,
930        break_long_words: bool,
931        bold: bool,
932    ) -> Vec<String> {
933        fn split_html_breakable_segments(tok: &str) -> Vec<String> {
934            // Browser HTML line breaking (UAX #14) provides extra break opportunities inside
935            // path/URL-like tokens. Keep this deliberately narrow: short prose punctuation such
936            // as `(a/b/c)` in subgraph titles should still wrap at spaces first, matching upstream
937            // Mermaid's rendered 200px HTML title boxes.
938            //
939            // Intentionally *exclude* '=': upstream fixtures show tokens like `wrappingWidth=120`
940            // overflowing rather than breaking at '='.
941            let hyphen_count = tok.chars().filter(|ch| *ch == '-').count();
942            let char_count = tok.chars().count();
943            let is_hyphenated_compound = hyphen_count >= 2 && char_count >= 16;
944            let is_url_like = tok.starts_with("http://") || tok.starts_with("https://");
945            let is_path_like = is_hyphenated_compound
946                || is_url_like
947                || tok.len() >= 24
948                    && tok
949                        .chars()
950                        .filter(|ch| {
951                            matches!(ch, '/' | '\\' | '-' | ':' | '?' | '&' | '#' | '[' | ']')
952                        })
953                        .count()
954                        >= 2;
955            if !is_path_like {
956                return vec![tok.to_string()];
957            }
958
959            fn is_break_after(ch: char, is_url_like: bool) -> bool {
960                matches!(ch, '/' | '-' | ':' | '?' | '&' | '#' | ')' | ']' | '}')
961                    || (is_url_like && ch == '.')
962            }
963
964            let mut out: Vec<String> = Vec::new();
965            let mut cur = String::new();
966            for ch in tok.chars() {
967                cur.push(ch);
968                if is_break_after(ch, is_url_like) && !cur.is_empty() {
969                    out.push(std::mem::take(&mut cur));
970                }
971            }
972            if !cur.is_empty() {
973                out.push(cur);
974            }
975            if out.len() <= 1 {
976                vec![tok.to_string()]
977            } else {
978                out
979            }
980        }
981
982        // HTML measurement in upstream Mermaid comes from the browser layout engine and tends to
983        // be slightly more permissive at wrap boundaries than our glyph-advance sum (especially
984        // after the 1/64px lattice quantization seen in fixtures). Add a tiny slack to reduce
985        // off-by-one-line wrapping deltas near the threshold.
986        let max_width_px = if break_long_words {
987            max_width_px
988        } else {
989            max_width_px + (1.0 / 64.0)
990        };
991
992        let mut tokens =
993            std::collections::VecDeque::from(DeterministicTextMeasurer::split_line_to_words(line));
994        let mut out: Vec<String> = Vec::new();
995        let mut cur = String::new();
996
997        while let Some(tok) = tokens.pop_front() {
998            if cur.is_empty() && tok == " " {
999                continue;
1000            }
1001
1002            let candidate = format!("{cur}{tok}");
1003            let candidate_trimmed = candidate.trim_end();
1004            if Self::line_width_px(profile, candidate_trimmed, bold, font_size) <= max_width_px {
1005                cur = candidate;
1006                continue;
1007            }
1008
1009            if !break_long_words && tok != " " && !cur.trim().is_empty() {
1010                // Browser HTML layout uses punctuation-aware break opportunities even when a token
1011                // would fit on its own line (e.g. URLs inside parentheses). Try to consume a
1012                // breakable prefix before forcing the whole token onto the next line.
1013                let segments = split_html_breakable_segments(&tok);
1014                if segments.len() > 1 {
1015                    let mut cur_candidate = cur.clone();
1016                    let mut consumed = 0usize;
1017                    for seg in &segments {
1018                        let candidate = format!("{cur_candidate}{seg}");
1019                        let candidate_trimmed = candidate.trim_end();
1020                        if Self::line_width_px(profile, candidate_trimmed, bold, font_size)
1021                            <= max_width_px
1022                        {
1023                            cur_candidate = candidate;
1024                            consumed += 1;
1025                        } else {
1026                            break;
1027                        }
1028                    }
1029                    if consumed > 0 {
1030                        cur = cur_candidate;
1031                        for seg in segments.into_iter().skip(consumed).rev() {
1032                            tokens.push_front(seg);
1033                        }
1034                        continue;
1035                    }
1036                }
1037            }
1038
1039            if !cur.trim().is_empty() {
1040                out.push(cur.trim_end().to_string());
1041                cur.clear();
1042            }
1043
1044            if tok == " " {
1045                continue;
1046            }
1047
1048            if Self::line_width_px(profile, tok.as_str(), bold, font_size) <= max_width_px {
1049                cur = tok;
1050                continue;
1051            }
1052
1053            if !break_long_words {
1054                let segments = split_html_breakable_segments(&tok);
1055                if segments.len() > 1 {
1056                    for seg in segments.into_iter().rev() {
1057                        tokens.push_front(seg);
1058                    }
1059                    continue;
1060                }
1061                out.push(tok);
1062                continue;
1063            }
1064
1065            let (head, tail) =
1066                Self::split_token_to_width_px(profile, &tok, max_width_px, bold, font_size);
1067            out.push(head);
1068            if !tail.is_empty() {
1069                tokens.push_front(tail);
1070            }
1071        }
1072
1073        if !cur.trim().is_empty() {
1074            out.push(cur.trim_end().to_string());
1075        }
1076
1077        if out.is_empty() {
1078            vec!["".to_string()]
1079        } else {
1080            out
1081        }
1082    }
1083
1084    fn wrap_text_lines_px(
1085        profile: FontMetricProfile<'_>,
1086        text: &str,
1087        style: &TextStyle,
1088        bold: bool,
1089        max_width_px: Option<f64>,
1090        wrap_mode: WrapMode,
1091    ) -> Vec<String> {
1092        let font_size = style.font_size.max(1.0);
1093        let max_width_px = max_width_px.filter(|w| w.is_finite() && *w > 0.0);
1094        let break_long_words = wrap_mode == WrapMode::SvgLike;
1095
1096        let mut lines = Vec::new();
1097        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1098            if let Some(w) = max_width_px {
1099                lines.extend(Self::wrap_line_to_width_px(
1100                    profile,
1101                    &line,
1102                    w,
1103                    font_size,
1104                    break_long_words,
1105                    bold,
1106                ));
1107            } else {
1108                lines.push(line);
1109            }
1110        }
1111
1112        if lines.is_empty() {
1113            vec!["".to_string()]
1114        } else {
1115            lines
1116        }
1117    }
1118}
1119
1120fn vendored_measure_wrapped_impl(
1121    measurer: &VendoredFontMetricsTextMeasurer,
1122    text: &str,
1123    style: &TextStyle,
1124    max_width: Option<f64>,
1125    wrap_mode: WrapMode,
1126    use_html_overrides: bool,
1127) -> (TextMetrics, Option<f64>) {
1128    let Some(table) = measurer.lookup_table(style) else {
1129        return measurer
1130            .fallback
1131            .measure_wrapped_with_raw_width(text, style, max_width, wrap_mode);
1132    };
1133
1134    let bold = is_flowchart_default_font(style) && style_requests_bold_font_weight(style);
1135    let font_size = style.font_size.max(1.0);
1136    let max_width = max_width.filter(|w| w.is_finite() && *w > 0.0);
1137    let line_height_factor = match wrap_mode {
1138        WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => 1.1,
1139        WrapMode::HtmlLike => 1.5,
1140    };
1141
1142    let html_overrides: &[(&'static str, f64)] = if use_html_overrides && !bold {
1143        table.html_overrides
1144    } else {
1145        &[]
1146    };
1147    let profile = VendoredFontMetricsTextMeasurer::metric_profile(table);
1148
1149    let html_override_px = |em: f64| -> f64 {
1150        // `html_overrides` entries are generated from upstream fixtures by dividing the measured
1151        // pixel width by `base_font_size_px`. When a fixture applies a non-default `font-size`
1152        // via CSS (e.g. flowchart class definitions), the recorded width already reflects that
1153        // larger font size, so we must *not* scale it again by `font_size`.
1154        //
1155        // Empirically (Mermaid@11.12.2), upstream HTML label widths in those cases match
1156        // `em * base_font_size_px` rather than `em * font_size`.
1157        if (font_size - table.base_font_size_px).abs() < 0.01 {
1158            em * font_size
1159        } else {
1160            em * table.base_font_size_px
1161        }
1162    };
1163
1164    let html_width_override_px = |line: &str| -> Option<f64> {
1165        // Flowchart labels still flow through the generic text API, so the few remaining
1166        // root-viewport guard widths stay here instead of in the Flowchart renderer.
1167        overrides::lookup_flowchart_html_width_px(table.font_key, font_size, line)
1168    };
1169
1170    // Mermaid HTML labels behave differently depending on whether the content "needs" wrapping:
1171    // - if the unwrapped line width exceeds the configured wrapping width, Mermaid constrains
1172    //   the element to `width=max_width` and lets HTML wrapping determine line breaks
1173    //   (`white-space: break-spaces` / `width: 200px` patterns in upstream SVGs).
1174    // - otherwise, Mermaid uses an auto-sized container and measures the natural width.
1175    //
1176    // In headless mode we model this by computing the unwrapped width first, then forcing the
1177    // measured width to `max_width` when it would overflow.
1178    let raw_width_unscaled = if wrap_mode == WrapMode::HtmlLike {
1179        let mut raw_w: f64 = 0.0;
1180        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1181            if let Some(w) = html_width_override_px(&line) {
1182                raw_w = raw_w.max(w);
1183                continue;
1184            }
1185            if let Some(em) =
1186                VendoredFontMetricsTextMeasurer::lookup_html_override_em(html_overrides, &line)
1187            {
1188                raw_w = raw_w.max(html_override_px(em));
1189            } else {
1190                raw_w = raw_w.max(VendoredFontMetricsTextMeasurer::line_width_px(
1191                    profile, &line, bold, font_size,
1192                ));
1193            }
1194        }
1195        Some(raw_w)
1196    } else {
1197        None
1198    };
1199
1200    // Mermaid's HTML label measurements are taken from a `<div style="max-width: wpx">` that is
1201    // later switched to `display: table; width: wpx; white-space: break-spaces` when it hits the
1202    // max width.
1203    //
1204    // When a "word" (space-delimited token) is wider than the configured max width, browsers may
1205    // still wrap other parts of the paragraph, but the element's measured bounding box can expand
1206    // to accommodate the token's min-content width. Upstream Mermaid records that via
1207    // `getBoundingClientRect()` into `foreignObject width="..."`.
1208    //
1209    // Model this by tracking the widest space-delimited token width as a separate "min-content"
1210    // contributor to the final measured width, without changing the wrapping width used for line
1211    // breaking.
1212    fn split_html_min_content_segments(tok: &str) -> Vec<String> {
1213        // HTML min-content sizing for `display: table` tends to treat URL query separators as
1214        // break opportunities, but does not behave like a full `word-break: break-all`.
1215        //
1216        // Keep this conservative: avoid splitting on `/`/`.`/`:` so we still model wide URL path
1217        // segments that expand the measured bounding box beyond `wrappingWidth`.
1218        fn is_break_after(ch: char) -> bool {
1219            matches!(ch, '-' | '?' | '&' | '#')
1220        }
1221
1222        let mut out: Vec<String> = Vec::new();
1223        let mut cur = String::new();
1224        for ch in tok.chars() {
1225            cur.push(ch);
1226            if is_break_after(ch) && !cur.is_empty() {
1227                out.push(std::mem::take(&mut cur));
1228            }
1229        }
1230        if !cur.is_empty() {
1231            out.push(cur);
1232        }
1233        if out.len() <= 1 {
1234            vec![tok.to_string()]
1235        } else {
1236            out
1237        }
1238    }
1239
1240    let html_min_content_width = if wrap_mode == WrapMode::HtmlLike && max_width.is_some() {
1241        let mut max_word_w: f64 = 0.0;
1242        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1243            for part in line.split(' ') {
1244                let part = part.trim();
1245                if part.is_empty() {
1246                    continue;
1247                }
1248                for seg in split_html_min_content_segments(part) {
1249                    let seg_w = html_width_override_px(&seg).unwrap_or_else(|| {
1250                        VendoredFontMetricsTextMeasurer::line_width_px(
1251                            profile,
1252                            seg.as_str(),
1253                            bold,
1254                            font_size,
1255                        )
1256                    });
1257                    max_word_w = max_word_w.max(seg_w);
1258                }
1259            }
1260        }
1261        if max_word_w.is_finite() && max_word_w > 0.0 {
1262            Some(max_word_w)
1263        } else {
1264            None
1265        }
1266    } else {
1267        None
1268    };
1269
1270    let lines = match wrap_mode {
1271        WrapMode::HtmlLike => VendoredFontMetricsTextMeasurer::wrap_text_lines_px(
1272            profile, text, style, bold, max_width, wrap_mode,
1273        ),
1274        WrapMode::SvgLike => VendoredFontMetricsTextMeasurer::wrap_text_lines_svg_bbox_px(
1275            table, text, max_width, font_size, true,
1276        ),
1277        WrapMode::SvgLikeSingleRun => VendoredFontMetricsTextMeasurer::wrap_text_lines_svg_bbox_px(
1278            table, text, max_width, font_size, false,
1279        ),
1280    };
1281
1282    let mut width: f64 = 0.0;
1283    match wrap_mode {
1284        WrapMode::HtmlLike => {
1285            for line in &lines {
1286                if let Some(w) = html_width_override_px(line) {
1287                    width = width.max(w);
1288                    continue;
1289                }
1290                if let Some(em) =
1291                    VendoredFontMetricsTextMeasurer::lookup_html_override_em(html_overrides, line)
1292                {
1293                    width = width.max(html_override_px(em));
1294                } else {
1295                    width = width.max(VendoredFontMetricsTextMeasurer::line_width_px(
1296                        profile, line, bold, font_size,
1297                    ));
1298                }
1299            }
1300        }
1301        WrapMode::SvgLike => {
1302            for line in &lines {
1303                width = width.max(VendoredFontMetricsTextMeasurer::line_svg_bbox_width_px(
1304                    table, line, font_size,
1305                ));
1306            }
1307        }
1308        WrapMode::SvgLikeSingleRun => {
1309            for line in &lines {
1310                width = width.max(
1311                    VendoredFontMetricsTextMeasurer::line_svg_bbox_width_single_run_px(
1312                        table, line, font_size,
1313                    ),
1314                );
1315            }
1316        }
1317    }
1318
1319    // Mermaid HTML labels use `max-width` and can visually overflow for long words, but their
1320    // layout width is at least the max width in "wrapped" mode (tables), and may exceed it for
1321    // long unbreakable tokens.
1322    if wrap_mode == WrapMode::HtmlLike {
1323        let needs_wrap = max_width.is_some_and(|w| raw_width_unscaled.is_some_and(|rw| rw > w));
1324        if let Some(w) = max_width {
1325            if needs_wrap {
1326                width = width.max(w);
1327            } else {
1328                width = width.min(w);
1329            }
1330        }
1331        if needs_wrap && let Some(w) = html_min_content_width {
1332            width = width.max(w);
1333        }
1334        // Empirically, upstream HTML label widths (via `getBoundingClientRect()`) land on a 1/64px
1335        // lattice. Quantize to that grid to keep our layout math stable.
1336        width = round_to_1_64_px(width);
1337        if let Some(w) = max_width {
1338            width = if needs_wrap {
1339                width.max(w)
1340            } else {
1341                width.min(w)
1342            };
1343        }
1344    }
1345
1346    let height = match wrap_mode {
1347        WrapMode::HtmlLike => lines.len() as f64 * font_size * line_height_factor,
1348        WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => {
1349            if lines.is_empty() {
1350                0.0
1351            } else {
1352                // Mermaid's SVG `<text>.getBBox().height` behaves as "one taller first line"
1353                // plus 1.1em per additional wrapped line (observed in upstream fixtures at
1354                // Mermaid@11.12.2).
1355                // Chromium often reports an integer first-line bbox height; keep ties-to-even
1356                // rounding so `28.5px` becomes `28px` (matching upstream class SVG probes).
1357                let first_line_h = svg_wrapped_first_line_bbox_height_px(style);
1358                let additional = (lines.len().saturating_sub(1)) as f64 * font_size * 1.1;
1359                first_line_h + additional
1360            }
1361        }
1362    };
1363
1364    let metrics = TextMetrics {
1365        width,
1366        height,
1367        line_count: lines.len(),
1368    };
1369    let raw_width_px = if wrap_mode == WrapMode::HtmlLike {
1370        raw_width_unscaled
1371    } else {
1372        None
1373    };
1374    (metrics, raw_width_px)
1375}
1376
1377impl TextMeasurer for VendoredFontMetricsTextMeasurer {
1378    fn measure(&self, text: &str, style: &TextStyle) -> TextMetrics {
1379        self.measure_wrapped(text, style, None, WrapMode::SvgLike)
1380    }
1381
1382    fn measure_svg_text_computed_length_px(&self, text: &str, style: &TextStyle) -> f64 {
1383        let Some(table) = self.lookup_table(style) else {
1384            return self
1385                .fallback
1386                .measure_svg_text_computed_length_px(text, style);
1387        };
1388
1389        let bold = is_flowchart_default_font(style) && style_requests_bold_font_weight(style);
1390        let font_size = style.font_size.max(1.0);
1391        let profile = VendoredFontMetricsTextMeasurer::metric_profile(table);
1392        let mut width: f64 = 0.0;
1393        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1394            width = width.max(VendoredFontMetricsTextMeasurer::line_width_px(
1395                profile, &line, bold, font_size,
1396            ));
1397        }
1398        if width.is_finite() && width >= 0.0 {
1399            width
1400        } else {
1401            0.0
1402        }
1403    }
1404
1405    fn measure_svg_text_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
1406        let Some(table) = self.lookup_table(style) else {
1407            return self.fallback.measure_svg_text_bbox_x(text, style);
1408        };
1409
1410        let font_size = style.font_size.max(1.0);
1411        let mut left: f64 = 0.0;
1412        let mut right: f64 = 0.0;
1413        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1414            let (l, r) = Self::line_svg_bbox_extents_px(table, &line, font_size);
1415            left = left.max(l);
1416            right = right.max(r);
1417        }
1418        (left, right)
1419    }
1420
1421    fn measure_svg_text_bbox_x_with_ascii_overhang(
1422        &self,
1423        text: &str,
1424        style: &TextStyle,
1425    ) -> (f64, f64) {
1426        let Some(table) = self.lookup_table(style) else {
1427            return self
1428                .fallback
1429                .measure_svg_text_bbox_x_with_ascii_overhang(text, style);
1430        };
1431
1432        let font_size = style.font_size.max(1.0);
1433        let mut left: f64 = 0.0;
1434        let mut right: f64 = 0.0;
1435        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1436            let (l, r) = Self::line_svg_bbox_extents_px_single_run_with_ascii_overhang(
1437                table, &line, font_size,
1438            );
1439            left = left.max(l);
1440            right = right.max(r);
1441        }
1442        (left, right)
1443    }
1444
1445    fn measure_svg_title_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
1446        let Some(table) = self.lookup_table(style) else {
1447            return self.fallback.measure_svg_title_bbox_x(text, style);
1448        };
1449
1450        let font_size = style.font_size.max(1.0);
1451        let mut left: f64 = 0.0;
1452        let mut right: f64 = 0.0;
1453        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1454            let (l, r) = Self::line_svg_title_bbox_extents_px(table, &line, font_size);
1455            left = left.max(l);
1456            right = right.max(r);
1457        }
1458        (left, right)
1459    }
1460
1461    fn measure_svg_simple_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
1462        let Some(table) = self.lookup_table(style) else {
1463            return self
1464                .fallback
1465                .measure_svg_simple_text_bbox_width_px(text, style);
1466        };
1467
1468        let font_size = style.font_size.max(1.0);
1469        let t = text.trim_end();
1470        if !t.is_empty()
1471            && let Some((left_em, right_em)) =
1472                overrides::lookup_sequence_svg_override_em(table.font_key, t)
1473        {
1474            let left = Self::quantize_svg_bbox_px_nearest((left_em * font_size).max(0.0));
1475            let right = Self::quantize_svg_bbox_px_nearest((right_em * font_size).max(0.0));
1476            return (left + right).max(0.0);
1477        }
1478
1479        let mut width: f64 = 0.0;
1480        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1481            let (l, r) = Self::line_svg_bbox_extents_px_single_run_with_ascii_overhang(
1482                table, &line, font_size,
1483            );
1484            width = width.max((l + r).max(0.0));
1485        }
1486        width
1487    }
1488
1489    fn measure_svg_simple_text_bbox_width_for_wrap_px(&self, text: &str, style: &TextStyle) -> f64 {
1490        let Some(table) = self.lookup_table(style) else {
1491            return self
1492                .fallback
1493                .measure_svg_simple_text_bbox_width_for_wrap_px(text, style);
1494        };
1495
1496        let font_size = style.font_size.max(1.0);
1497        let mut width: f64 = 0.0;
1498        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1499            let (l, r) = Self::line_svg_bbox_extents_px_single_run_with_ascii_overhang(
1500                table, &line, font_size,
1501            );
1502            width = width.max((l + r).max(0.0));
1503        }
1504        width
1505    }
1506
1507    fn measure_svg_raw_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
1508        let Some(table) = self.lookup_table(style) else {
1509            return self
1510                .fallback
1511                .measure_svg_raw_text_bbox_width_px(text, style);
1512        };
1513
1514        let font_size = style.font_size.max(1.0);
1515        let bold = is_flowchart_default_font(style) && style_requests_bold_font_weight(style);
1516        let mut width: f64 = 0.0;
1517        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1518            let (l, r) = Self::line_svg_bbox_extents_px_single_run_with_ascii_overhang_and_weight(
1519                table, &line, font_size, bold,
1520            );
1521            width = width.max((l + r).max(0.0));
1522        }
1523        width
1524    }
1525
1526    fn measure_svg_simple_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
1527        let t = text.trim_end();
1528        if t.is_empty() {
1529            return 0.0;
1530        }
1531        // Upstream gitGraph uses `<text>.getBBox().height` for commit/tag labels, and those values
1532        // land on a tighter ~`1.1em` height compared to our wrapped SVG text heuristic.
1533        let font_size = style.font_size.max(1.0);
1534        (font_size * 1.1).max(0.0)
1535    }
1536
1537    fn measure_wrapped(
1538        &self,
1539        text: &str,
1540        style: &TextStyle,
1541        max_width: Option<f64>,
1542        wrap_mode: WrapMode,
1543    ) -> TextMetrics {
1544        vendored_measure_wrapped_impl(self, text, style, max_width, wrap_mode, true).0
1545    }
1546
1547    fn measure_wrapped_with_raw_width(
1548        &self,
1549        text: &str,
1550        style: &TextStyle,
1551        max_width: Option<f64>,
1552        wrap_mode: WrapMode,
1553    ) -> (TextMetrics, Option<f64>) {
1554        vendored_measure_wrapped_impl(self, text, style, max_width, wrap_mode, true)
1555    }
1556
1557    fn measure_wrapped_raw(
1558        &self,
1559        text: &str,
1560        style: &TextStyle,
1561        max_width: Option<f64>,
1562        wrap_mode: WrapMode,
1563    ) -> TextMetrics {
1564        vendored_measure_wrapped_impl(self, text, style, max_width, wrap_mode, false).0
1565    }
1566}