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