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            if let Some(other_em) = find_entry_em(entries, other) {
192                return other_em;
193            }
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            if 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
618        let (l, r) = Self::line_svg_bbox_extents_px_single_run(table, text, font_size);
619        (l + r).max(0.0)
620    }
621
622    fn line_svg_title_bbox_extents_px(
623        table: &crate::generated::font_metrics_flowchart_11_12_2::FontMetricsTables,
624        text: &str,
625        font_size: f64,
626    ) -> (f64, f64) {
627        let profile = Self::metric_profile(table);
628        let t = text.trim_end();
629        if t.is_empty() {
630            return (0.0, 0.0);
631        }
632
633        // Flowchart titles are emitted as a centered single `<text>` node. The final upstream
634        // root bbox behaves as a symmetric title advance, while the generic SVG override table
635        // captures simple-text probes with per-edge overhang. Keep title measurement separate so
636        // those simple-text asymmetries do not force fixture root viewport pins.
637        let advance_px = if let Some(em) = Self::lookup_html_override_em(table.html_overrides, t) {
638            em * font_size
639        } else {
640            Self::line_width_px(profile, t, false, font_size) * table.svg_scale
641        };
642        let half = Self::quantize_svg_half_px_nearest((advance_px / 2.0).max(0.0));
643        (half, half)
644    }
645
646    fn split_token_to_svg_bbox_width_px(
647        table: &crate::generated::font_metrics_flowchart_11_12_2::FontMetricsTables,
648        tok: &str,
649        max_width_px: f64,
650        font_size: f64,
651    ) -> (String, String) {
652        if max_width_px <= 0.0 {
653            return (tok.to_string(), String::new());
654        }
655        let chars = tok.chars().collect::<Vec<_>>();
656        if chars.is_empty() {
657            return (String::new(), String::new());
658        }
659
660        let first = chars[0];
661        let left_oh_em = if first.is_ascii() {
662            0.0
663        } else {
664            Self::lookup_overhang_em(
665                table.svg_bbox_overhang_left,
666                table.svg_bbox_overhang_left_default_em,
667                first,
668            )
669        };
670
671        let mut em = 0.0;
672        let mut prev: Option<char> = None;
673        let mut split_at = 1usize;
674        for (idx, ch) in chars.iter().enumerate() {
675            em += Self::lookup_char_em(table.entries, table.default_em.max(0.1), *ch);
676            if let Some(p) = prev {
677                em += Self::lookup_kern_em(table.kern_pairs, p, *ch);
678            }
679            prev = Some(*ch);
680
681            let right_oh_em = if ch.is_ascii() {
682                0.0
683            } else {
684                Self::lookup_overhang_em(
685                    table.svg_bbox_overhang_right,
686                    table.svg_bbox_overhang_right_default_em,
687                    *ch,
688                )
689            };
690            let half_px = Self::quantize_svg_half_px_nearest(
691                (em * font_size * table.svg_scale / 2.0).max(0.0),
692            );
693            let w_px = 2.0 * half_px + (left_oh_em + right_oh_em) * font_size;
694            if w_px.is_finite() && w_px <= max_width_px {
695                split_at = idx + 1;
696            } else if idx > 0 {
697                break;
698            }
699        }
700        let head = chars[..split_at].iter().collect::<String>();
701        let tail = chars[split_at..].iter().collect::<String>();
702        (head, tail)
703    }
704
705    fn wrap_text_lines_svg_bbox_px(
706        table: &crate::generated::font_metrics_flowchart_11_12_2::FontMetricsTables,
707        text: &str,
708        max_width_px: Option<f64>,
709        font_size: f64,
710        tokenize_whitespace: bool,
711    ) -> Vec<String> {
712        const EPS_PX: f64 = 0.125;
713        let max_width_px = max_width_px.filter(|w| w.is_finite() && *w > 0.0);
714        let width_fn = if tokenize_whitespace {
715            Self::line_svg_bbox_width_px
716        } else {
717            Self::line_svg_bbox_width_single_run_px
718        };
719
720        let mut lines = Vec::new();
721        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
722            let Some(w) = max_width_px else {
723                lines.push(line);
724                continue;
725            };
726
727            let mut tokens = std::collections::VecDeque::from(
728                DeterministicTextMeasurer::split_line_to_words(&line),
729            );
730            let mut out: Vec<String> = Vec::new();
731            let mut cur = String::new();
732
733            while let Some(tok) = tokens.pop_front() {
734                if cur.is_empty() && tok == " " {
735                    continue;
736                }
737
738                let candidate = format!("{cur}{tok}");
739                let candidate_trimmed = candidate.trim_end();
740                if width_fn(table, candidate_trimmed, font_size) <= w + EPS_PX {
741                    cur = candidate;
742                    continue;
743                }
744
745                if !cur.trim().is_empty() {
746                    out.push(cur.trim_end().to_string());
747                    cur.clear();
748                    tokens.push_front(tok);
749                    continue;
750                }
751
752                if tok == " " {
753                    continue;
754                }
755
756                if width_fn(table, tok.as_str(), font_size) <= w + EPS_PX {
757                    cur = tok;
758                    continue;
759                }
760
761                // Mermaid's SVG wrapping breaks long words.
762                let (head, tail) =
763                    Self::split_token_to_svg_bbox_width_px(table, &tok, w + EPS_PX, font_size);
764                out.push(head);
765                if !tail.is_empty() {
766                    tokens.push_front(tail);
767                }
768            }
769
770            if !cur.trim().is_empty() {
771                out.push(cur.trim_end().to_string());
772            }
773
774            if out.is_empty() {
775                lines.push("".to_string());
776            } else {
777                lines.extend(out);
778            }
779        }
780
781        if lines.is_empty() {
782            vec!["".to_string()]
783        } else {
784            lines
785        }
786    }
787
788    fn line_width_px(
789        profile: FontMetricProfile<'_>,
790        text: &str,
791        bold: bool,
792        font_size: f64,
793    ) -> f64 {
794        fn normalize_whitespace_like(ch: char) -> (char, f64) {
795            // Mermaid frequently uses `&nbsp;` inside HTML labels (e.g. block arrows). In SVG
796            // exports this becomes U+00A0. Treat it as a regular space for width/kerning models
797            // so it does not fall back to `default_em`.
798            //
799            // Empirically, for Mermaid@11.12.2 fixtures, U+00A0 measures slightly narrower than
800            // U+0020 in the default font stack. Model that as a tiny delta in `em` space so
801            // repeated `&nbsp;` placeholders land on the same 1/64px lattice as upstream.
802            const NBSP_DELTA_EM: f64 = -1.0 / 3072.0;
803            if ch == '\u{00A0}' {
804                (' ', NBSP_DELTA_EM)
805            } else {
806                (ch, 0.0)
807            }
808        }
809
810        let mut em = 0.0;
811        let mut prevprev: Option<char> = None;
812        let mut prev: Option<char> = None;
813        let mut same_run_len = 0usize;
814        for ch in text.chars() {
815            let (ch, delta_em) = normalize_whitespace_like(ch);
816            let next_same_run_len = if prev == Some(ch) {
817                same_run_len + 1
818            } else {
819                1
820            };
821            em += Self::lookup_char_em(profile.entries, profile.default_em, ch) + delta_em;
822            if let Some(p) = prev {
823                em += Self::same_glyph_pair_kern_em(profile, p, ch, next_same_run_len);
824            }
825            if bold {
826                if let Some(p) = prev {
827                    em += flowchart_default_bold_kern_delta_em(p, ch);
828                }
829                em += flowchart_default_bold_delta_em(ch);
830            }
831            if let (Some(a), Some(b)) = (prevprev, prev) {
832                if b == ' ' {
833                    if !(a.is_whitespace() || ch.is_whitespace()) {
834                        let space_delta =
835                            Self::lookup_space_trigram_em(profile.space_trigrams, a, ch);
836                        if space_delta != 0.0 {
837                            em += space_delta;
838                        } else if a == 'A' && ch == '(' {
839                            em += profile.missing_space_after_capital_a_before_open_paren_em;
840                        } else if ch == 'A' && a.is_ascii_alphanumeric() {
841                            // The default Mermaid stack consistently tightens a preceding word
842                            // space before capital `A`. The generated table captures this for
843                            // observed pairs such as `r A`; use the same profile delta as a
844                            // fallback for missing pairs instead of carrying per-label overrides.
845                            em += profile.missing_space_before_capital_a_em;
846                        }
847                    }
848                } else if !(a.is_whitespace() || b.is_whitespace() || ch.is_whitespace()) {
849                    em += Self::same_glyph_trigram_em(profile, a, b, ch);
850                }
851            }
852            prevprev = prev;
853            prev = Some(ch);
854            same_run_len = next_same_run_len;
855        }
856        em * font_size
857    }
858
859    fn split_token_to_width_px(
860        profile: FontMetricProfile<'_>,
861        tok: &str,
862        max_width_px: f64,
863        bold: bool,
864        font_size: f64,
865    ) -> (String, String) {
866        fn normalize_whitespace_like(ch: char) -> (char, f64) {
867            const NBSP_DELTA_EM: f64 = -1.0 / 3072.0;
868            if ch == '\u{00A0}' {
869                (' ', NBSP_DELTA_EM)
870            } else {
871                (ch, 0.0)
872            }
873        }
874
875        if max_width_px <= 0.0 {
876            return (tok.to_string(), String::new());
877        }
878        let max_em = max_width_px / font_size.max(1.0);
879        let mut em = 0.0;
880        let mut prevprev: Option<char> = None;
881        let mut prev: Option<char> = None;
882        let mut same_run_len = 0usize;
883        let chars = tok.chars().collect::<Vec<_>>();
884        let mut split_at = 0usize;
885        for (idx, ch) in chars.iter().enumerate() {
886            let (ch_norm, delta_em) = normalize_whitespace_like(*ch);
887            let next_same_run_len = if prev == Some(ch_norm) {
888                same_run_len + 1
889            } else {
890                1
891            };
892            em += Self::lookup_char_em(profile.entries, profile.default_em, ch_norm) + delta_em;
893            if let Some(p) = prev {
894                em += Self::same_glyph_pair_kern_em(profile, p, ch_norm, next_same_run_len);
895            }
896            if bold {
897                if let Some(p) = prev {
898                    em += flowchart_default_bold_kern_delta_em(p, ch_norm);
899                }
900                em += flowchart_default_bold_delta_em(ch_norm);
901            }
902            if let (Some(a), Some(b)) = (prevprev, prev) {
903                if !(a.is_whitespace() || b.is_whitespace() || ch_norm.is_whitespace()) {
904                    em += Self::same_glyph_trigram_em(profile, a, b, ch_norm);
905                }
906            }
907            prevprev = prev;
908            prev = Some(ch_norm);
909            same_run_len = next_same_run_len;
910            if em > max_em && idx > 0 {
911                break;
912            }
913            split_at = idx + 1;
914            if em >= max_em {
915                break;
916            }
917        }
918        if split_at == 0 {
919            split_at = 1.min(chars.len());
920        }
921        let head = chars.iter().take(split_at).collect::<String>();
922        let tail = chars.iter().skip(split_at).collect::<String>();
923        (head, tail)
924    }
925
926    fn wrap_line_to_width_px(
927        profile: FontMetricProfile<'_>,
928        line: &str,
929        max_width_px: f64,
930        font_size: f64,
931        break_long_words: bool,
932        bold: bool,
933    ) -> Vec<String> {
934        fn split_html_breakable_segments(tok: &str) -> Vec<String> {
935            // Browser HTML line breaking (UAX #14) provides extra break opportunities inside
936            // path/URL-like tokens. Keep this deliberately narrow: short prose punctuation such
937            // as `(a/b/c)` in subgraph titles should still wrap at spaces first, matching upstream
938            // Mermaid's rendered 200px HTML title boxes.
939            //
940            // Intentionally *exclude* '=': upstream fixtures show tokens like `wrappingWidth=120`
941            // overflowing rather than breaking at '='.
942            let hyphen_count = tok.chars().filter(|ch| *ch == '-').count();
943            let char_count = tok.chars().count();
944            let is_hyphenated_compound = hyphen_count >= 2 && char_count >= 16;
945            let is_url_like = tok.starts_with("http://") || tok.starts_with("https://");
946            let is_path_like = is_hyphenated_compound
947                || is_url_like
948                || tok.len() >= 24
949                    && tok
950                        .chars()
951                        .filter(|ch| {
952                            matches!(ch, '/' | '\\' | '-' | ':' | '?' | '&' | '#' | '[' | ']')
953                        })
954                        .count()
955                        >= 2;
956            if !is_path_like {
957                return vec![tok.to_string()];
958            }
959
960            fn is_break_after(ch: char, is_url_like: bool) -> bool {
961                matches!(ch, '/' | '-' | ':' | '?' | '&' | '#' | ')' | ']' | '}')
962                    || (is_url_like && ch == '.')
963            }
964
965            let mut out: Vec<String> = Vec::new();
966            let mut cur = String::new();
967            for ch in tok.chars() {
968                cur.push(ch);
969                if is_break_after(ch, is_url_like) && !cur.is_empty() {
970                    out.push(std::mem::take(&mut cur));
971                }
972            }
973            if !cur.is_empty() {
974                out.push(cur);
975            }
976            if out.len() <= 1 {
977                vec![tok.to_string()]
978            } else {
979                out
980            }
981        }
982
983        // HTML measurement in upstream Mermaid comes from the browser layout engine and tends to
984        // be slightly more permissive at wrap boundaries than our glyph-advance sum (especially
985        // after the 1/64px lattice quantization seen in fixtures). Add a tiny slack to reduce
986        // off-by-one-line wrapping deltas near the threshold.
987        let max_width_px = if break_long_words {
988            max_width_px
989        } else {
990            max_width_px + (1.0 / 64.0)
991        };
992
993        let mut tokens =
994            std::collections::VecDeque::from(DeterministicTextMeasurer::split_line_to_words(line));
995        let mut out: Vec<String> = Vec::new();
996        let mut cur = String::new();
997
998        while let Some(tok) = tokens.pop_front() {
999            if cur.is_empty() && tok == " " {
1000                continue;
1001            }
1002
1003            let candidate = format!("{cur}{tok}");
1004            let candidate_trimmed = candidate.trim_end();
1005            if Self::line_width_px(profile, candidate_trimmed, bold, font_size) <= max_width_px {
1006                cur = candidate;
1007                continue;
1008            }
1009
1010            if !break_long_words && tok != " " && !cur.trim().is_empty() {
1011                // Browser HTML layout uses punctuation-aware break opportunities even when a token
1012                // would fit on its own line (e.g. URLs inside parentheses). Try to consume a
1013                // breakable prefix before forcing the whole token onto the next line.
1014                let segments = split_html_breakable_segments(&tok);
1015                if segments.len() > 1 {
1016                    let mut cur_candidate = cur.clone();
1017                    let mut consumed = 0usize;
1018                    for seg in &segments {
1019                        let candidate = format!("{cur_candidate}{seg}");
1020                        let candidate_trimmed = candidate.trim_end();
1021                        if Self::line_width_px(profile, candidate_trimmed, bold, font_size)
1022                            <= max_width_px
1023                        {
1024                            cur_candidate = candidate;
1025                            consumed += 1;
1026                        } else {
1027                            break;
1028                        }
1029                    }
1030                    if consumed > 0 {
1031                        cur = cur_candidate;
1032                        for seg in segments.into_iter().skip(consumed).rev() {
1033                            tokens.push_front(seg);
1034                        }
1035                        continue;
1036                    }
1037                }
1038            }
1039
1040            if !cur.trim().is_empty() {
1041                out.push(cur.trim_end().to_string());
1042                cur.clear();
1043            }
1044
1045            if tok == " " {
1046                continue;
1047            }
1048
1049            if Self::line_width_px(profile, tok.as_str(), bold, font_size) <= max_width_px {
1050                cur = tok;
1051                continue;
1052            }
1053
1054            if !break_long_words {
1055                let segments = split_html_breakable_segments(&tok);
1056                if segments.len() > 1 {
1057                    for seg in segments.into_iter().rev() {
1058                        tokens.push_front(seg);
1059                    }
1060                    continue;
1061                }
1062                out.push(tok);
1063                continue;
1064            }
1065
1066            let (head, tail) =
1067                Self::split_token_to_width_px(profile, &tok, max_width_px, bold, font_size);
1068            out.push(head);
1069            if !tail.is_empty() {
1070                tokens.push_front(tail);
1071            }
1072        }
1073
1074        if !cur.trim().is_empty() {
1075            out.push(cur.trim_end().to_string());
1076        }
1077
1078        if out.is_empty() {
1079            vec!["".to_string()]
1080        } else {
1081            out
1082        }
1083    }
1084
1085    fn wrap_text_lines_px(
1086        profile: FontMetricProfile<'_>,
1087        text: &str,
1088        style: &TextStyle,
1089        bold: bool,
1090        max_width_px: Option<f64>,
1091        wrap_mode: WrapMode,
1092    ) -> Vec<String> {
1093        let font_size = style.font_size.max(1.0);
1094        let max_width_px = max_width_px.filter(|w| w.is_finite() && *w > 0.0);
1095        let break_long_words = wrap_mode == WrapMode::SvgLike;
1096
1097        let mut lines = Vec::new();
1098        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1099            if let Some(w) = max_width_px {
1100                lines.extend(Self::wrap_line_to_width_px(
1101                    profile,
1102                    &line,
1103                    w,
1104                    font_size,
1105                    break_long_words,
1106                    bold,
1107                ));
1108            } else {
1109                lines.push(line);
1110            }
1111        }
1112
1113        if lines.is_empty() {
1114            vec!["".to_string()]
1115        } else {
1116            lines
1117        }
1118    }
1119}
1120
1121fn vendored_measure_wrapped_impl(
1122    measurer: &VendoredFontMetricsTextMeasurer,
1123    text: &str,
1124    style: &TextStyle,
1125    max_width: Option<f64>,
1126    wrap_mode: WrapMode,
1127    use_html_overrides: bool,
1128) -> (TextMetrics, Option<f64>) {
1129    let Some(table) = measurer.lookup_table(style) else {
1130        return measurer
1131            .fallback
1132            .measure_wrapped_with_raw_width(text, style, max_width, wrap_mode);
1133    };
1134
1135    let bold = is_flowchart_default_font(style) && style_requests_bold_font_weight(style);
1136    let font_size = style.font_size.max(1.0);
1137    let max_width = max_width.filter(|w| w.is_finite() && *w > 0.0);
1138    let line_height_factor = match wrap_mode {
1139        WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => 1.1,
1140        WrapMode::HtmlLike => 1.5,
1141    };
1142
1143    let html_overrides: &[(&'static str, f64)] = if use_html_overrides && !bold {
1144        table.html_overrides
1145    } else {
1146        &[]
1147    };
1148    let profile = VendoredFontMetricsTextMeasurer::metric_profile(table);
1149
1150    let html_override_px = |em: f64| -> f64 {
1151        // `html_overrides` entries are generated from upstream fixtures by dividing the measured
1152        // pixel width by `base_font_size_px`. When a fixture applies a non-default `font-size`
1153        // via CSS (e.g. flowchart class definitions), the recorded width already reflects that
1154        // larger font size, so we must *not* scale it again by `font_size`.
1155        //
1156        // Empirically (Mermaid@11.12.2), upstream HTML label widths in those cases match
1157        // `em * base_font_size_px` rather than `em * font_size`.
1158        if (font_size - table.base_font_size_px).abs() < 0.01 {
1159            em * font_size
1160        } else {
1161            em * table.base_font_size_px
1162        }
1163    };
1164
1165    let html_width_override_px = |line: &str| -> Option<f64> {
1166        // Flowchart labels still flow through the generic text API, so the few remaining
1167        // root-viewport guard widths stay here instead of in the Flowchart renderer.
1168        overrides::lookup_flowchart_html_width_px(table.font_key, font_size, line)
1169    };
1170
1171    // Mermaid HTML labels behave differently depending on whether the content "needs" wrapping:
1172    // - if the unwrapped line width exceeds the configured wrapping width, Mermaid constrains
1173    //   the element to `width=max_width` and lets HTML wrapping determine line breaks
1174    //   (`white-space: break-spaces` / `width: 200px` patterns in upstream SVGs).
1175    // - otherwise, Mermaid uses an auto-sized container and measures the natural width.
1176    //
1177    // In headless mode we model this by computing the unwrapped width first, then forcing the
1178    // measured width to `max_width` when it would overflow.
1179    let raw_width_unscaled = if wrap_mode == WrapMode::HtmlLike {
1180        let mut raw_w: f64 = 0.0;
1181        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1182            if let Some(w) = html_width_override_px(&line) {
1183                raw_w = raw_w.max(w);
1184                continue;
1185            }
1186            if let Some(em) =
1187                VendoredFontMetricsTextMeasurer::lookup_html_override_em(html_overrides, &line)
1188            {
1189                raw_w = raw_w.max(html_override_px(em));
1190            } else {
1191                raw_w = raw_w.max(VendoredFontMetricsTextMeasurer::line_width_px(
1192                    profile, &line, bold, font_size,
1193                ));
1194            }
1195        }
1196        Some(raw_w)
1197    } else {
1198        None
1199    };
1200
1201    // Mermaid's HTML label measurements are taken from a `<div style="max-width: wpx">` that is
1202    // later switched to `display: table; width: wpx; white-space: break-spaces` when it hits the
1203    // max width.
1204    //
1205    // When a "word" (space-delimited token) is wider than the configured max width, browsers may
1206    // still wrap other parts of the paragraph, but the element's measured bounding box can expand
1207    // to accommodate the token's min-content width. Upstream Mermaid records that via
1208    // `getBoundingClientRect()` into `foreignObject width="..."`.
1209    //
1210    // Model this by tracking the widest space-delimited token width as a separate "min-content"
1211    // contributor to the final measured width, without changing the wrapping width used for line
1212    // breaking.
1213    fn split_html_min_content_segments(tok: &str) -> Vec<String> {
1214        // HTML min-content sizing for `display: table` tends to treat URL query separators as
1215        // break opportunities, but does not behave like a full `word-break: break-all`.
1216        //
1217        // Keep this conservative: avoid splitting on `/`/`.`/`:` so we still model wide URL path
1218        // segments that expand the measured bounding box beyond `wrappingWidth`.
1219        fn is_break_after(ch: char) -> bool {
1220            matches!(ch, '-' | '?' | '&' | '#')
1221        }
1222
1223        let mut out: Vec<String> = Vec::new();
1224        let mut cur = String::new();
1225        for ch in tok.chars() {
1226            cur.push(ch);
1227            if is_break_after(ch) && !cur.is_empty() {
1228                out.push(std::mem::take(&mut cur));
1229            }
1230        }
1231        if !cur.is_empty() {
1232            out.push(cur);
1233        }
1234        if out.len() <= 1 {
1235            vec![tok.to_string()]
1236        } else {
1237            out
1238        }
1239    }
1240
1241    let html_min_content_width = if wrap_mode == WrapMode::HtmlLike && max_width.is_some() {
1242        let mut max_word_w: f64 = 0.0;
1243        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1244            for part in line.split(' ') {
1245                let part = part.trim();
1246                if part.is_empty() {
1247                    continue;
1248                }
1249                for seg in split_html_min_content_segments(part) {
1250                    let seg_w = html_width_override_px(&seg).unwrap_or_else(|| {
1251                        VendoredFontMetricsTextMeasurer::line_width_px(
1252                            profile,
1253                            seg.as_str(),
1254                            bold,
1255                            font_size,
1256                        )
1257                    });
1258                    max_word_w = max_word_w.max(seg_w);
1259                }
1260            }
1261        }
1262        if max_word_w.is_finite() && max_word_w > 0.0 {
1263            Some(max_word_w)
1264        } else {
1265            None
1266        }
1267    } else {
1268        None
1269    };
1270
1271    let lines = match wrap_mode {
1272        WrapMode::HtmlLike => VendoredFontMetricsTextMeasurer::wrap_text_lines_px(
1273            profile, text, style, bold, max_width, wrap_mode,
1274        ),
1275        WrapMode::SvgLike => VendoredFontMetricsTextMeasurer::wrap_text_lines_svg_bbox_px(
1276            table, text, max_width, font_size, true,
1277        ),
1278        WrapMode::SvgLikeSingleRun => VendoredFontMetricsTextMeasurer::wrap_text_lines_svg_bbox_px(
1279            table, text, max_width, font_size, false,
1280        ),
1281    };
1282
1283    let mut width: f64 = 0.0;
1284    match wrap_mode {
1285        WrapMode::HtmlLike => {
1286            for line in &lines {
1287                if let Some(w) = html_width_override_px(line) {
1288                    width = width.max(w);
1289                    continue;
1290                }
1291                if let Some(em) =
1292                    VendoredFontMetricsTextMeasurer::lookup_html_override_em(html_overrides, line)
1293                {
1294                    width = width.max(html_override_px(em));
1295                } else {
1296                    width = width.max(VendoredFontMetricsTextMeasurer::line_width_px(
1297                        profile, line, bold, font_size,
1298                    ));
1299                }
1300            }
1301        }
1302        WrapMode::SvgLike => {
1303            for line in &lines {
1304                width = width.max(VendoredFontMetricsTextMeasurer::line_svg_bbox_width_px(
1305                    table, line, font_size,
1306                ));
1307            }
1308        }
1309        WrapMode::SvgLikeSingleRun => {
1310            for line in &lines {
1311                width = width.max(
1312                    VendoredFontMetricsTextMeasurer::line_svg_bbox_width_single_run_px(
1313                        table, line, font_size,
1314                    ),
1315                );
1316            }
1317        }
1318    }
1319
1320    // Mermaid HTML labels use `max-width` and can visually overflow for long words, but their
1321    // layout width is at least the max width in "wrapped" mode (tables), and may exceed it for
1322    // long unbreakable tokens.
1323    if wrap_mode == WrapMode::HtmlLike {
1324        let needs_wrap = max_width.is_some_and(|w| raw_width_unscaled.is_some_and(|rw| rw > w));
1325        if let Some(w) = max_width {
1326            if needs_wrap {
1327                width = width.max(w);
1328            } else {
1329                width = width.min(w);
1330            }
1331        }
1332        if needs_wrap {
1333            if let Some(w) = html_min_content_width {
1334                width = width.max(w);
1335            }
1336        }
1337        // Empirically, upstream HTML label widths (via `getBoundingClientRect()`) land on a 1/64px
1338        // lattice. Quantize to that grid to keep our layout math stable.
1339        width = round_to_1_64_px(width);
1340        if let Some(w) = max_width {
1341            width = if needs_wrap {
1342                width.max(w)
1343            } else {
1344                width.min(w)
1345            };
1346        }
1347    }
1348
1349    let height = match wrap_mode {
1350        WrapMode::HtmlLike => lines.len() as f64 * font_size * line_height_factor,
1351        WrapMode::SvgLike | WrapMode::SvgLikeSingleRun => {
1352            if lines.is_empty() {
1353                0.0
1354            } else {
1355                // Mermaid's SVG `<text>.getBBox().height` behaves as "one taller first line"
1356                // plus 1.1em per additional wrapped line (observed in upstream fixtures at
1357                // Mermaid@11.12.2).
1358                // Chromium often reports an integer first-line bbox height; keep ties-to-even
1359                // rounding so `28.5px` becomes `28px` (matching upstream class SVG probes).
1360                let first_line_h = svg_wrapped_first_line_bbox_height_px(style);
1361                let additional = (lines.len().saturating_sub(1)) as f64 * font_size * 1.1;
1362                first_line_h + additional
1363            }
1364        }
1365    };
1366
1367    let metrics = TextMetrics {
1368        width,
1369        height,
1370        line_count: lines.len(),
1371    };
1372    let raw_width_px = if wrap_mode == WrapMode::HtmlLike {
1373        raw_width_unscaled
1374    } else {
1375        None
1376    };
1377    (metrics, raw_width_px)
1378}
1379
1380impl TextMeasurer for VendoredFontMetricsTextMeasurer {
1381    fn measure(&self, text: &str, style: &TextStyle) -> TextMetrics {
1382        self.measure_wrapped(text, style, None, WrapMode::SvgLike)
1383    }
1384
1385    fn measure_svg_text_computed_length_px(&self, text: &str, style: &TextStyle) -> f64 {
1386        let Some(table) = self.lookup_table(style) else {
1387            return self
1388                .fallback
1389                .measure_svg_text_computed_length_px(text, style);
1390        };
1391
1392        let bold = is_flowchart_default_font(style) && style_requests_bold_font_weight(style);
1393        let font_size = style.font_size.max(1.0);
1394        let profile = VendoredFontMetricsTextMeasurer::metric_profile(table);
1395        let mut width: f64 = 0.0;
1396        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1397            width = width.max(VendoredFontMetricsTextMeasurer::line_width_px(
1398                profile, &line, bold, font_size,
1399            ));
1400        }
1401        if width.is_finite() && width >= 0.0 {
1402            width
1403        } else {
1404            0.0
1405        }
1406    }
1407
1408    fn measure_svg_text_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
1409        let Some(table) = self.lookup_table(style) else {
1410            return self.fallback.measure_svg_text_bbox_x(text, style);
1411        };
1412
1413        let font_size = style.font_size.max(1.0);
1414        let mut left: f64 = 0.0;
1415        let mut right: f64 = 0.0;
1416        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1417            let (l, r) = Self::line_svg_bbox_extents_px(table, &line, font_size);
1418            left = left.max(l);
1419            right = right.max(r);
1420        }
1421        (left, right)
1422    }
1423
1424    fn measure_svg_text_bbox_x_with_ascii_overhang(
1425        &self,
1426        text: &str,
1427        style: &TextStyle,
1428    ) -> (f64, f64) {
1429        let Some(table) = self.lookup_table(style) else {
1430            return self
1431                .fallback
1432                .measure_svg_text_bbox_x_with_ascii_overhang(text, style);
1433        };
1434
1435        let font_size = style.font_size.max(1.0);
1436        let mut left: f64 = 0.0;
1437        let mut right: f64 = 0.0;
1438        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1439            let (l, r) = Self::line_svg_bbox_extents_px_single_run_with_ascii_overhang(
1440                table, &line, font_size,
1441            );
1442            left = left.max(l);
1443            right = right.max(r);
1444        }
1445        (left, right)
1446    }
1447
1448    fn measure_svg_title_bbox_x(&self, text: &str, style: &TextStyle) -> (f64, f64) {
1449        let Some(table) = self.lookup_table(style) else {
1450            return self.fallback.measure_svg_title_bbox_x(text, style);
1451        };
1452
1453        let font_size = style.font_size.max(1.0);
1454        let mut left: f64 = 0.0;
1455        let mut right: f64 = 0.0;
1456        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1457            let (l, r) = Self::line_svg_title_bbox_extents_px(table, &line, font_size);
1458            left = left.max(l);
1459            right = right.max(r);
1460        }
1461        (left, right)
1462    }
1463
1464    fn measure_svg_simple_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
1465        let Some(table) = self.lookup_table(style) else {
1466            return self
1467                .fallback
1468                .measure_svg_simple_text_bbox_width_px(text, style);
1469        };
1470
1471        let font_size = style.font_size.max(1.0);
1472        let t = text.trim_end();
1473        if !t.is_empty() {
1474            if let Some((left_em, right_em)) =
1475                overrides::lookup_sequence_svg_override_em(table.font_key, t)
1476            {
1477                let left = Self::quantize_svg_bbox_px_nearest((left_em * font_size).max(0.0));
1478                let right = Self::quantize_svg_bbox_px_nearest((right_em * font_size).max(0.0));
1479                return (left + right).max(0.0);
1480            }
1481        }
1482
1483        let mut width: f64 = 0.0;
1484        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1485            let (l, r) = Self::line_svg_bbox_extents_px_single_run_with_ascii_overhang(
1486                table, &line, font_size,
1487            );
1488            width = width.max((l + r).max(0.0));
1489        }
1490        width
1491    }
1492
1493    fn measure_svg_simple_text_bbox_width_for_wrap_px(&self, text: &str, style: &TextStyle) -> f64 {
1494        let Some(table) = self.lookup_table(style) else {
1495            return self
1496                .fallback
1497                .measure_svg_simple_text_bbox_width_for_wrap_px(text, style);
1498        };
1499
1500        let font_size = style.font_size.max(1.0);
1501        let mut width: f64 = 0.0;
1502        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1503            let (l, r) = Self::line_svg_bbox_extents_px_single_run_with_ascii_overhang(
1504                table, &line, font_size,
1505            );
1506            width = width.max((l + r).max(0.0));
1507        }
1508        width
1509    }
1510
1511    fn measure_svg_raw_text_bbox_width_px(&self, text: &str, style: &TextStyle) -> f64 {
1512        let Some(table) = self.lookup_table(style) else {
1513            return self
1514                .fallback
1515                .measure_svg_raw_text_bbox_width_px(text, style);
1516        };
1517
1518        let font_size = style.font_size.max(1.0);
1519        let bold = is_flowchart_default_font(style) && style_requests_bold_font_weight(style);
1520        let mut width: f64 = 0.0;
1521        for line in DeterministicTextMeasurer::normalized_text_lines(text) {
1522            let (l, r) = Self::line_svg_bbox_extents_px_single_run_with_ascii_overhang_and_weight(
1523                table, &line, font_size, bold,
1524            );
1525            width = width.max((l + r).max(0.0));
1526        }
1527        width
1528    }
1529
1530    fn measure_svg_simple_text_bbox_height_px(&self, text: &str, style: &TextStyle) -> f64 {
1531        let t = text.trim_end();
1532        if t.is_empty() {
1533            return 0.0;
1534        }
1535        // Upstream gitGraph uses `<text>.getBBox().height` for commit/tag labels, and those values
1536        // land on a tighter ~`1.1em` height compared to our wrapped SVG text heuristic.
1537        let font_size = style.font_size.max(1.0);
1538        (font_size * 1.1).max(0.0)
1539    }
1540
1541    fn measure_wrapped(
1542        &self,
1543        text: &str,
1544        style: &TextStyle,
1545        max_width: Option<f64>,
1546        wrap_mode: WrapMode,
1547    ) -> TextMetrics {
1548        vendored_measure_wrapped_impl(self, text, style, max_width, wrap_mode, true).0
1549    }
1550
1551    fn measure_wrapped_with_raw_width(
1552        &self,
1553        text: &str,
1554        style: &TextStyle,
1555        max_width: Option<f64>,
1556        wrap_mode: WrapMode,
1557    ) -> (TextMetrics, Option<f64>) {
1558        vendored_measure_wrapped_impl(self, text, style, max_width, wrap_mode, true)
1559    }
1560
1561    fn measure_wrapped_raw(
1562        &self,
1563        text: &str,
1564        style: &TextStyle,
1565        max_width: Option<f64>,
1566        wrap_mode: WrapMode,
1567    ) -> TextMetrics {
1568        vendored_measure_wrapped_impl(self, text, style, max_width, wrap_mode, false).0
1569    }
1570}