Skip to main content

ratex_layout/
engine.rs

1use ratex_font::{get_char_metrics, get_global_metrics, FontId};
2use ratex_parser::parse_node::{AtomFamily, Mode, ParseNode};
3use ratex_types::color::Color;
4use ratex_types::math_style::MathStyle;
5use ratex_types::path_command::PathCommand;
6
7use crate::hbox::make_hbox;
8use crate::layout_box::{BoxContent, LayoutBox};
9use crate::layout_options::LayoutOptions;
10use crate::spacing::{atom_spacing, mu_to_em, MathClass};
11use crate::stacked_delim::make_stacked_delim_if_needed;
12
13/// Main entry point: lay out a list of ParseNodes into a LayoutBox.
14pub fn layout(nodes: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
15    layout_expression(nodes, options, true)
16}
17
18/// KaTeX `binLeftCanceller` / `binRightCanceller` (TeXbook p.442–446, Rules 5–6).
19/// Binary operators become ordinary in certain contexts so spacing matches TeX/KaTeX.
20fn apply_bin_cancellation(raw: &[Option<MathClass>]) -> Vec<Option<MathClass>> {
21    let n = raw.len();
22    let mut eff = raw.to_vec();
23    for i in 0..n {
24        if raw[i] != Some(MathClass::Bin) {
25            continue;
26        }
27        let prev = if i == 0 { None } else { raw[i - 1] };
28        let left_cancel = matches!(
29            prev,
30            None
31                | Some(MathClass::Bin)
32                | Some(MathClass::Open)
33                | Some(MathClass::Rel)
34                | Some(MathClass::Op)
35                | Some(MathClass::Punct)
36        );
37        if left_cancel {
38            eff[i] = Some(MathClass::Ord);
39        }
40    }
41    for i in 0..n {
42        if raw[i] != Some(MathClass::Bin) {
43            continue;
44        }
45        let next = if i + 1 < n { raw[i + 1] } else { None };
46        let right_cancel = matches!(
47            next,
48            None | Some(MathClass::Rel) | Some(MathClass::Close) | Some(MathClass::Punct)
49        );
50        if right_cancel {
51            eff[i] = Some(MathClass::Ord);
52        }
53    }
54    eff
55}
56
57/// Lay out an expression (list of nodes) as a horizontal sequence with spacing.
58fn layout_expression(
59    nodes: &[ParseNode],
60    options: &LayoutOptions,
61    is_real_group: bool,
62) -> LayoutBox {
63    if nodes.is_empty() {
64        return LayoutBox::new_empty();
65    }
66
67    // Check for line breaks (\\, \newline) — split into rows stacked in a VBox
68    let has_cr = nodes.iter().any(|n| matches!(n, ParseNode::Cr { .. }));
69    if has_cr {
70        return layout_multiline(nodes, options, is_real_group);
71    }
72
73    let raw_classes: Vec<Option<MathClass>> =
74        nodes.iter().map(node_math_class).collect();
75    let eff_classes = apply_bin_cancellation(&raw_classes);
76
77    let mut children = Vec::new();
78    let mut prev_class: Option<MathClass> = None;
79
80    for (i, node) in nodes.iter().enumerate() {
81        let lbox = layout_node(node, options);
82        let cur_class = eff_classes.get(i).copied().flatten();
83
84        if is_real_group {
85            if let (Some(prev), Some(cur)) = (prev_class, cur_class) {
86                let mu = atom_spacing(prev, cur, options.style.is_tight());
87                let mu = options
88                    .align_relation_spacing
89                    .map_or(mu, |cap| mu.min(cap));
90                if mu > 0.0 {
91                    let em = mu_to_em(mu, options.metrics().quad);
92                    children.push(LayoutBox::new_kern(em));
93                }
94            }
95        }
96
97        if cur_class.is_some() {
98            prev_class = cur_class;
99        }
100
101        children.push(lbox);
102    }
103
104    make_hbox(children)
105}
106
107/// Layout an expression containing line-break nodes (\\, \newline) as a VBox.
108fn layout_multiline(
109    nodes: &[ParseNode],
110    options: &LayoutOptions,
111    is_real_group: bool,
112) -> LayoutBox {
113    use crate::layout_box::{BoxContent, VBoxChild, VBoxChildKind};
114    let metrics = options.metrics();
115    let pt = 1.0 / metrics.pt_per_em;
116    let baselineskip = 12.0 * pt; // standard TeX baselineskip
117    let lineskip = 1.0 * pt; // minimum gap between lines
118
119    // Split nodes at Cr boundaries
120    let mut rows: Vec<&[ParseNode]> = Vec::new();
121    let mut start = 0;
122    for (i, node) in nodes.iter().enumerate() {
123        if matches!(node, ParseNode::Cr { .. }) {
124            rows.push(&nodes[start..i]);
125            start = i + 1;
126        }
127    }
128    rows.push(&nodes[start..]);
129
130    let row_boxes: Vec<LayoutBox> = rows
131        .iter()
132        .map(|row| layout_expression(row, options, is_real_group))
133        .collect();
134
135    let total_width = row_boxes.iter().map(|b| b.width).fold(0.0_f64, f64::max);
136
137    let mut vchildren: Vec<VBoxChild> = Vec::new();
138    let mut h = row_boxes.first().map(|b| b.height).unwrap_or(0.0);
139    let d = row_boxes.last().map(|b| b.depth).unwrap_or(0.0);
140    for (i, row) in row_boxes.iter().enumerate() {
141        if i > 0 {
142            // TeX baselineskip: gap = baselineskip - prev_depth - cur_height
143            let prev_depth = row_boxes[i - 1].depth;
144            let gap = (baselineskip - prev_depth - row.height).max(lineskip);
145            vchildren.push(VBoxChild { kind: VBoxChildKind::Kern(gap), shift: 0.0 });
146            h += gap + row.height + prev_depth;
147        }
148        vchildren.push(VBoxChild {
149            kind: VBoxChildKind::Box(Box::new(row.clone())),
150            shift: 0.0,
151        });
152    }
153
154    LayoutBox {
155        width: total_width,
156        height: h,
157        depth: d,
158        content: BoxContent::VBox(vchildren),
159        color: options.color,
160    }
161}
162
163
164/// Lay out a single ParseNode.
165fn layout_node(node: &ParseNode, options: &LayoutOptions) -> LayoutBox {
166    match node {
167        ParseNode::MathOrd { text, mode, .. } => layout_symbol(text, *mode, options),
168        ParseNode::TextOrd { text, mode, .. } => layout_symbol(text, *mode, options),
169        ParseNode::Atom { text, mode, .. } => layout_symbol(text, *mode, options),
170        ParseNode::OpToken { text, mode, .. } => layout_symbol(text, *mode, options),
171
172        ParseNode::OrdGroup { body, .. } => layout_expression(body, options, true),
173
174        ParseNode::SupSub {
175            base, sup, sub, ..
176        } => {
177            if let Some(base_node) = base.as_deref() {
178                if should_use_op_limits(base_node, options) {
179                    return layout_op_with_limits(base_node, sup.as_deref(), sub.as_deref(), options);
180                }
181            }
182            layout_supsub(base.as_deref(), sup.as_deref(), sub.as_deref(), options, None)
183        }
184
185        ParseNode::GenFrac {
186            numer,
187            denom,
188            has_bar_line,
189            bar_size,
190            left_delim,
191            right_delim,
192            ..
193        } => {
194            let bar_thickness = if *has_bar_line {
195                bar_size
196                    .as_ref()
197                    .map(|m| measurement_to_em(m, options))
198                    .unwrap_or(options.metrics().default_rule_thickness)
199            } else {
200                0.0
201            };
202            let frac = layout_fraction(numer, denom, bar_thickness, options);
203
204            let has_left = left_delim.as_ref().is_some_and(|d| !d.is_empty() && d != ".");
205            let has_right = right_delim.as_ref().is_some_and(|d| !d.is_empty() && d != ".");
206
207            if has_left || has_right {
208                let total_h = genfrac_delim_target_height(options);
209                let left_d = left_delim.as_deref().unwrap_or(".");
210                let right_d = right_delim.as_deref().unwrap_or(".");
211                let left_box = make_stretchy_delim(left_d, total_h, options);
212                let right_box = make_stretchy_delim(right_d, total_h, options);
213
214                let width = left_box.width + frac.width + right_box.width;
215                let height = frac.height.max(left_box.height).max(right_box.height);
216                let depth = frac.depth.max(left_box.depth).max(right_box.depth);
217
218                LayoutBox {
219                    width,
220                    height,
221                    depth,
222                    content: BoxContent::LeftRight {
223                        left: Box::new(left_box),
224                        right: Box::new(right_box),
225                        inner: Box::new(frac),
226                    },
227                    color: options.color,
228                }
229            } else {
230                frac
231            }
232        }
233
234        ParseNode::Sqrt { body, index, .. } => {
235            layout_radical(body, index.as_deref(), options)
236        }
237
238        ParseNode::Op {
239            name,
240            symbol,
241            body,
242            limits,
243            suppress_base_shift,
244            ..
245        } => layout_op(
246            name.as_deref(),
247            *symbol,
248            body.as_deref(),
249            *limits,
250            suppress_base_shift.unwrap_or(false),
251            options,
252        ),
253
254        ParseNode::OperatorName { body, .. } => layout_operatorname(body, options),
255
256        ParseNode::SpacingNode { text, .. } => layout_spacing_command(text, options),
257
258        ParseNode::Kern { dimension, .. } => {
259            let em = measurement_to_em(dimension, options);
260            LayoutBox::new_kern(em)
261        }
262
263        ParseNode::Color { color, body, .. } => {
264            let new_color = Color::parse(color).unwrap_or(options.color);
265            let new_opts = options.with_color(new_color);
266            let mut lbox = layout_expression(body, &new_opts, true);
267            lbox.color = new_color;
268            lbox
269        }
270
271        ParseNode::Styling { style, body, .. } => {
272            let new_style = match style {
273                ratex_parser::parse_node::StyleStr::Display => MathStyle::Display,
274                ratex_parser::parse_node::StyleStr::Text => MathStyle::Text,
275                ratex_parser::parse_node::StyleStr::Script => MathStyle::Script,
276                ratex_parser::parse_node::StyleStr::Scriptscript => MathStyle::ScriptScript,
277            };
278            let ratio = new_style.size_multiplier() / options.style.size_multiplier();
279            let new_opts = options.with_style(new_style);
280            let inner = layout_expression(body, &new_opts, true);
281            if (ratio - 1.0).abs() < 0.001 {
282                inner
283            } else {
284                LayoutBox {
285                    width: inner.width * ratio,
286                    height: inner.height * ratio,
287                    depth: inner.depth * ratio,
288                    content: BoxContent::Scaled {
289                        body: Box::new(inner),
290                        child_scale: ratio,
291                    },
292                    color: options.color,
293                }
294            }
295        }
296
297        ParseNode::Accent {
298            label, base, is_stretchy, is_shifty, ..
299        } => {
300            // Some text accents (e.g. \c cedilla) place the mark below
301            let is_below = matches!(label.as_str(), "\\c");
302            layout_accent(label, base, is_stretchy.unwrap_or(false), is_shifty.unwrap_or(false), is_below, options)
303        }
304
305        ParseNode::AccentUnder {
306            label, base, is_stretchy, ..
307        } => layout_accent(label, base, is_stretchy.unwrap_or(false), false, true, options),
308
309        ParseNode::LeftRight {
310            body, left, right, ..
311        } => layout_left_right(body, left, right, options),
312
313        ParseNode::DelimSizing {
314            size, delim, ..
315        } => layout_delim_sizing(*size, delim, options),
316
317        ParseNode::Array {
318            body,
319            cols,
320            arraystretch,
321            add_jot,
322            row_gaps,
323            hlines_before_row,
324            col_separation_type,
325            hskip_before_and_after,
326            ..
327        } => layout_array(
328            body,
329            cols.as_deref(),
330            *arraystretch,
331            add_jot.unwrap_or(false),
332            row_gaps,
333            hlines_before_row,
334            col_separation_type.as_deref(),
335            hskip_before_and_after.unwrap_or(false),
336            options,
337        ),
338
339        ParseNode::Sizing { size, body, .. } => layout_sizing(*size, body, options),
340
341        ParseNode::Text { body, font, mode, .. } => match font.as_deref() {
342            Some(f) => {
343                let group = ParseNode::OrdGroup {
344                    mode: *mode,
345                    body: body.clone(),
346                    semisimple: None,
347                    loc: None,
348                };
349                layout_font(f, &group, options)
350            }
351            None => layout_text(body, options),
352        },
353
354        ParseNode::Font { font, body, .. } => layout_font(font, body, options),
355
356        ParseNode::Href { body, .. } => layout_href(body, options),
357
358        ParseNode::Overline { body, .. } => layout_overline(body, options),
359        ParseNode::Underline { body, .. } => layout_underline(body, options),
360
361        ParseNode::Rule {
362            width: w,
363            height: h,
364            shift,
365            ..
366        } => {
367            let width = measurement_to_em(w, options);
368            let ink_h = measurement_to_em(h, options);
369            let raise = shift
370                .as_ref()
371                .map(|s| measurement_to_em(s, options))
372                .unwrap_or(0.0);
373            let box_height = (raise + ink_h).max(0.0);
374            let box_depth = (-raise).max(0.0);
375            LayoutBox::new_rule(width, box_height, box_depth, ink_h, raise)
376        }
377
378        ParseNode::Phantom { body, .. } => {
379            let inner = layout_expression(body, options, true);
380            LayoutBox {
381                width: inner.width,
382                height: inner.height,
383                depth: inner.depth,
384                content: BoxContent::Empty,
385                color: Color::BLACK,
386            }
387        }
388
389        ParseNode::VPhantom { body, .. } => {
390            let inner = layout_node(body, options);
391            LayoutBox {
392                width: 0.0,
393                height: inner.height,
394                depth: inner.depth,
395                content: BoxContent::Empty,
396                color: Color::BLACK,
397            }
398        }
399
400        ParseNode::Smash { body, smash_height, smash_depth, .. } => {
401            let mut inner = layout_node(body, options);
402            if *smash_height { inner.height = 0.0; }
403            if *smash_depth { inner.depth = 0.0; }
404            inner
405        }
406
407        ParseNode::Middle { delim, .. } => {
408            match options.leftright_delim_height {
409                Some(h) => make_stretchy_delim(delim, h, options),
410                None => {
411                    // First pass inside \left...\right: reserve width but don't affect inner height.
412                    let placeholder = make_stretchy_delim(delim, 1.0, options);
413                    LayoutBox {
414                        width: placeholder.width,
415                        height: 0.0,
416                        depth: 0.0,
417                        content: BoxContent::Empty,
418                        color: options.color,
419                    }
420                }
421            }
422        }
423
424        ParseNode::HtmlMathMl { html, .. } => {
425            layout_expression(html, options, true)
426        }
427
428        ParseNode::MClass { body, .. } => layout_expression(body, options, true),
429
430        ParseNode::MathChoice {
431            display, text, script, scriptscript, ..
432        } => {
433            let branch = match options.style {
434                MathStyle::Display | MathStyle::DisplayCramped => display,
435                MathStyle::Text | MathStyle::TextCramped => text,
436                MathStyle::Script | MathStyle::ScriptCramped => script,
437                MathStyle::ScriptScript | MathStyle::ScriptScriptCramped => scriptscript,
438            };
439            layout_expression(branch, options, true)
440        }
441
442        ParseNode::Lap { alignment, body, .. } => {
443            let inner = layout_node(body, options);
444            let shift = match alignment.as_str() {
445                "llap" => -inner.width,
446                "clap" => -inner.width / 2.0,
447                _ => 0.0, // rlap: no shift
448            };
449            let mut children = Vec::new();
450            if shift != 0.0 {
451                children.push(LayoutBox::new_kern(shift));
452            }
453            let h = inner.height;
454            let d = inner.depth;
455            children.push(inner);
456            LayoutBox {
457                width: 0.0,
458                height: h,
459                depth: d,
460                content: BoxContent::HBox(children),
461                color: options.color,
462            }
463        }
464
465        ParseNode::HorizBrace {
466            base, is_over, ..
467        } => layout_horiz_brace(base, *is_over, options),
468
469        ParseNode::XArrow {
470            label, body, below, ..
471        } => layout_xarrow(label, body, below.as_deref(), options),
472
473        ParseNode::Pmb { body, .. } => layout_pmb(body, options),
474
475        ParseNode::HBox { body, .. } => layout_text(body, options),
476
477        ParseNode::Enclose { label, background_color, border_color, body, .. } => {
478            layout_enclose(label, background_color.as_deref(), border_color.as_deref(), body, options)
479        }
480
481        ParseNode::RaiseBox { dy, body, .. } => {
482            let shift = measurement_to_em(dy, options);
483            layout_raisebox(shift, body, options)
484        }
485
486        ParseNode::VCenter { body, .. } => {
487            // Vertically center on the math axis
488            let inner = layout_node(body, options);
489            let axis = options.metrics().axis_height;
490            let total = inner.height + inner.depth;
491            let height = total / 2.0 + axis;
492            let depth = total - height;
493            LayoutBox {
494                width: inner.width,
495                height,
496                depth,
497                content: inner.content,
498                color: inner.color,
499            }
500        }
501
502        ParseNode::Verb { body, star, .. } => layout_verb(body, *star, options),
503
504        // Fallback for unhandled node types: produce empty box
505        _ => LayoutBox::new_empty(),
506    }
507}
508
509// ============================================================================
510// Symbol layout
511// ============================================================================
512
513/// Advance width for glyphs missing from bundled KaTeX fonts (e.g. CJK in `\text{…}`).
514///
515/// The placeholder width must match what system font fallback draws at ~1em: using 0.5em
516/// collapses.advance and Core Text / platform rasterizers still paint a full-width ideograph,
517/// so neighbors overlap and the row looks "too large" / clipped.
518fn missing_glyph_width_em(ch: char) -> f64 {
519    match ch as u32 {
520        // Hiragana / Katakana
521        0x3040..=0x30FF | 0x31F0..=0x31FF => 1.0,
522        // CJK Unified + extension / compatibility ideographs
523        0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF => 1.0,
524        // Hangul syllables
525        0xAC00..=0xD7AF => 1.0,
526        // Fullwidth ASCII, punctuation, currency
527        0xFF01..=0xFF60 | 0xFFE0..=0xFFEE => 1.0,
528        _ => 0.5,
529    }
530}
531
532fn missing_glyph_metrics_fallback(ch: char, options: &LayoutOptions) -> (f64, f64, f64) {
533    let m = get_global_metrics(options.style.size_index());
534    let w = missing_glyph_width_em(ch);
535    if w >= 0.99 {
536        let h = (m.quad * 0.92).max(m.x_height);
537        (w, h, 0.0)
538    } else {
539        (w, m.x_height, 0.0)
540    }
541}
542
543fn layout_symbol(text: &str, mode: Mode, options: &LayoutOptions) -> LayoutBox {
544    let ch = resolve_symbol_char(text, mode);
545    let mut font_id = select_font(text, ch, mode, options);
546    let char_code = ch as u32;
547
548    let mut metrics = get_char_metrics(font_id, char_code);
549
550    if metrics.is_none() && mode == Mode::Math && font_id != FontId::MathItalic {
551        if let Some(m) = get_char_metrics(FontId::MathItalic, char_code) {
552            font_id = FontId::MathItalic;
553            metrics = Some(m);
554        }
555    }
556
557    let (width, height, depth) = match metrics {
558        Some(m) => (m.width, m.height, m.depth),
559        None => missing_glyph_metrics_fallback(ch, options),
560    };
561
562    LayoutBox {
563        width,
564        height,
565        depth,
566        content: BoxContent::Glyph {
567            font_id,
568            char_code,
569        },
570        color: options.color,
571    }
572}
573
574/// Resolve a symbol name to its actual character.
575fn resolve_symbol_char(text: &str, mode: Mode) -> char {
576    let font_mode = match mode {
577        Mode::Math => ratex_font::Mode::Math,
578        Mode::Text => ratex_font::Mode::Text,
579    };
580
581    if let Some(info) = ratex_font::get_symbol(text, font_mode) {
582        if let Some(cp) = info.codepoint {
583            return cp;
584        }
585    }
586
587    text.chars().next().unwrap_or('?')
588}
589
590/// Select the font for a math symbol.
591/// Uses the symbol table's font field for AMS symbols, and character properties
592/// to choose between MathItalic (for letters and Greek) and MainRegular.
593fn select_font(text: &str, resolved_char: char, mode: Mode, _options: &LayoutOptions) -> FontId {
594    let font_mode = match mode {
595        Mode::Math => ratex_font::Mode::Math,
596        Mode::Text => ratex_font::Mode::Text,
597    };
598
599    if let Some(info) = ratex_font::get_symbol(text, font_mode) {
600        if info.font == ratex_font::SymbolFont::Ams {
601            return FontId::AmsRegular;
602        }
603    }
604
605    match mode {
606        Mode::Math => {
607            if resolved_char.is_ascii_lowercase()
608                || resolved_char.is_ascii_uppercase()
609                || is_greek_letter(resolved_char)
610            {
611                FontId::MathItalic
612            } else {
613                FontId::MainRegular
614            }
615        }
616        Mode::Text => FontId::MainRegular,
617    }
618}
619
620fn is_greek_letter(ch: char) -> bool {
621    matches!(ch,
622        '\u{0391}'..='\u{03C9}' |
623        '\u{03D1}' | '\u{03D5}' | '\u{03D6}' |
624        '\u{03F1}' | '\u{03F5}'
625    )
626}
627
628fn is_arrow_accent(label: &str) -> bool {
629    matches!(
630        label,
631        "\\overrightarrow"
632            | "\\overleftarrow"
633            | "\\Overrightarrow"
634            | "\\overleftrightarrow"
635            | "\\underrightarrow"
636            | "\\underleftarrow"
637            | "\\underleftrightarrow"
638            | "\\overleftharpoon"
639            | "\\overrightharpoon"
640            | "\\overlinesegment"
641            | "\\underlinesegment"
642    )
643}
644
645// ============================================================================
646// Fraction layout (TeX Rule 15d)
647// ============================================================================
648
649fn layout_fraction(
650    numer: &ParseNode,
651    denom: &ParseNode,
652    bar_thickness: f64,
653    options: &LayoutOptions,
654) -> LayoutBox {
655    let numer_s = options.style.numerator();
656    let denom_s = options.style.denominator();
657    let numer_style = options.with_style(numer_s);
658    let denom_style = options.with_style(denom_s);
659
660    let numer_box = layout_node(numer, &numer_style);
661    let denom_box = layout_node(denom, &denom_style);
662
663    // Size ratios for converting child em to parent em
664    let numer_ratio = numer_s.size_multiplier() / options.style.size_multiplier();
665    let denom_ratio = denom_s.size_multiplier() / options.style.size_multiplier();
666
667    let numer_height = numer_box.height * numer_ratio;
668    let numer_depth = numer_box.depth * numer_ratio;
669    let denom_height = denom_box.height * denom_ratio;
670    let denom_depth = denom_box.depth * denom_ratio;
671    let numer_width = numer_box.width * numer_ratio;
672    let denom_width = denom_box.width * denom_ratio;
673
674    let metrics = options.metrics();
675    let axis = metrics.axis_height;
676    let rule = bar_thickness;
677
678    // TeX Rule 15d: choose shift amounts based on display/text mode
679    let (mut num_shift, mut den_shift) = if options.style.is_display() {
680        (metrics.num1, metrics.denom1)
681    } else if bar_thickness > 0.0 {
682        (metrics.num2, metrics.denom2)
683    } else {
684        (metrics.num3, metrics.denom2)
685    };
686
687    if bar_thickness > 0.0 {
688        let min_clearance = if options.style.is_display() {
689            3.0 * rule
690        } else {
691            rule
692        };
693
694        let num_clearance = (num_shift - numer_depth) - (axis + rule / 2.0);
695        if num_clearance < min_clearance {
696            num_shift += min_clearance - num_clearance;
697        }
698
699        let den_clearance = (axis - rule / 2.0) + (den_shift - denom_height);
700        if den_clearance < min_clearance {
701            den_shift += min_clearance - den_clearance;
702        }
703    } else {
704        let min_gap = if options.style.is_display() {
705            7.0 * metrics.default_rule_thickness
706        } else {
707            3.0 * metrics.default_rule_thickness
708        };
709
710        let gap = (num_shift - numer_depth) - (denom_height - den_shift);
711        if gap < min_gap {
712            let adjust = (min_gap - gap) / 2.0;
713            num_shift += adjust;
714            den_shift += adjust;
715        }
716    }
717
718    let total_width = numer_width.max(denom_width);
719    let height = numer_height + num_shift;
720    let depth = denom_depth + den_shift;
721
722    LayoutBox {
723        width: total_width,
724        height,
725        depth,
726        content: BoxContent::Fraction {
727            numer: Box::new(numer_box),
728            denom: Box::new(denom_box),
729            numer_shift: num_shift,
730            denom_shift: den_shift,
731            bar_thickness: rule,
732            numer_scale: numer_ratio,
733            denom_scale: denom_ratio,
734        },
735        color: options.color,
736    }
737}
738
739// ============================================================================
740// Superscript/Subscript layout
741// ============================================================================
742
743fn layout_supsub(
744    base: Option<&ParseNode>,
745    sup: Option<&ParseNode>,
746    sub: Option<&ParseNode>,
747    options: &LayoutOptions,
748    inherited_font: Option<FontId>,
749) -> LayoutBox {
750    let layout_child = |n: &ParseNode, opts: &LayoutOptions| match inherited_font {
751        Some(fid) => layout_with_font(n, fid, opts),
752        None => layout_node(n, opts),
753    };
754
755    let horiz_brace_over = matches!(
756        base,
757        Some(ParseNode::HorizBrace {
758            is_over: true,
759            ..
760        })
761    );
762    let horiz_brace_under = matches!(
763        base,
764        Some(ParseNode::HorizBrace {
765            is_over: false,
766            ..
767        })
768    );
769    let center_scripts = horiz_brace_over || horiz_brace_under;
770
771    let base_box = base
772        .map(|b| layout_child(b, options))
773        .unwrap_or_else(LayoutBox::new_empty);
774
775    let is_char_box = base.is_some_and(is_character_box);
776    let metrics = options.metrics();
777
778    let sup_style = options.style.superscript();
779    let sub_style = options.style.subscript();
780
781    let sup_ratio = sup_style.size_multiplier() / options.style.size_multiplier();
782    let sub_ratio = sub_style.size_multiplier() / options.style.size_multiplier();
783
784    let sup_box = sup.map(|s| {
785        let sup_opts = options.with_style(sup_style);
786        layout_child(s, &sup_opts)
787    });
788
789    let sub_box = sub.map(|s| {
790        let sub_opts = options.with_style(sub_style);
791        layout_child(s, &sub_opts)
792    });
793
794    let sup_height_scaled = sup_box.as_ref().map(|b| b.height * sup_ratio).unwrap_or(0.0);
795    let sup_depth_scaled = sup_box.as_ref().map(|b| b.depth * sup_ratio).unwrap_or(0.0);
796    let sub_height_scaled = sub_box.as_ref().map(|b| b.height * sub_ratio).unwrap_or(0.0);
797    let sub_depth_scaled = sub_box.as_ref().map(|b| b.depth * sub_ratio).unwrap_or(0.0);
798
799    // KaTeX uses the CHILD style's metrics for supDrop/subDrop, not the parent's
800    let sup_style_metrics = get_global_metrics(sup_style.size_index());
801    let sub_style_metrics = get_global_metrics(sub_style.size_index());
802
803    // Rule 18a: initial shift from base dimensions
804    // For character boxes, supShift/subShift start at 0 (KaTeX behavior)
805    let mut sup_shift = if !is_char_box && sup_box.is_some() {
806        base_box.height - sup_style_metrics.sup_drop * sup_ratio
807    } else {
808        0.0
809    };
810
811    let mut sub_shift = if !is_char_box && sub_box.is_some() {
812        base_box.depth + sub_style_metrics.sub_drop * sub_ratio
813    } else {
814        0.0
815    };
816
817    let min_sup_shift = if options.style.is_cramped() {
818        metrics.sup3
819    } else if options.style.is_display() {
820        metrics.sup1
821    } else {
822        metrics.sup2
823    };
824
825    if sup_box.is_some() && sub_box.is_some() {
826        // Rule 18c+e: both sup and sub
827        sup_shift = sup_shift
828            .max(min_sup_shift)
829            .max(sup_depth_scaled + 0.25 * metrics.x_height);
830        sub_shift = sub_shift.max(metrics.sub2); // sub2 when both present
831
832        let rule_width = metrics.default_rule_thickness;
833        let max_width = 4.0 * rule_width;
834        let gap = (sup_shift - sup_depth_scaled) - (sub_height_scaled - sub_shift);
835        if gap < max_width {
836            sub_shift = max_width - (sup_shift - sup_depth_scaled) + sub_height_scaled;
837            let psi = 0.8 * metrics.x_height - (sup_shift - sup_depth_scaled);
838            if psi > 0.0 {
839                sup_shift += psi;
840                sub_shift -= psi;
841            }
842        }
843    } else if sub_box.is_some() {
844        // Rule 18b: sub only
845        sub_shift = sub_shift
846            .max(metrics.sub1)
847            .max(sub_height_scaled - 0.8 * metrics.x_height);
848    } else if sup_box.is_some() {
849        // Rule 18c,d: sup only
850        sup_shift = sup_shift
851            .max(min_sup_shift)
852            .max(sup_depth_scaled + 0.25 * metrics.x_height);
853    }
854
855    // `\overbrace{…}^{…}` / `\underbrace{…}_{…}`: default sup_shift = height - sup_drop places
856    // the script baseline *inside* tall atoms (by design for single glyphs). For stretchy
857    // horizontal braces the label must sit above/below the ink with limit-style clearance.
858    if horiz_brace_over && sup_box.is_some() {
859        sup_shift += sup_style_metrics.sup_drop * sup_ratio;
860        // Same order of gap as `\xrightarrow` labels (`big_op_spacing1` ≈ 2mu); extra +0.3em
861        // pushed the script too far above the brace vs KaTeX reference (golden 0603).
862        sup_shift += metrics.big_op_spacing1;
863    }
864    if horiz_brace_under && sub_box.is_some() {
865        sub_shift += sub_style_metrics.sub_drop * sub_ratio;
866        sub_shift += metrics.big_op_spacing2 + 0.2;
867    }
868
869    // Italic correction: KaTeX adds margin-right = italic to italic math characters,
870    // so superscripts start at advance_width + italic_correction.
871    // Only apply to simple single-glyph bases (not complex sub-expressions).
872    let italic_correction = if is_char_box && !center_scripts {
873        glyph_italic(&base_box)
874    } else {
875        0.0
876    };
877
878    // Compute total dimensions (using scaled child dimensions)
879    let mut height = base_box.height;
880    let mut depth = base_box.depth;
881    let mut total_width = base_box.width;
882
883    if let Some(ref sup_b) = sup_box {
884        height = height.max(sup_shift + sup_height_scaled);
885        if center_scripts {
886            total_width = total_width.max(sup_b.width * sup_ratio);
887        } else {
888            total_width = total_width.max(base_box.width + italic_correction + sup_b.width * sup_ratio);
889        }
890    }
891    if let Some(ref sub_b) = sub_box {
892        depth = depth.max(sub_shift + sub_depth_scaled);
893        if center_scripts {
894            total_width = total_width.max(sub_b.width * sub_ratio);
895        } else {
896            total_width = total_width.max(base_box.width + sub_b.width * sub_ratio);
897        }
898    }
899
900    LayoutBox {
901        width: total_width,
902        height,
903        depth,
904        content: BoxContent::SupSub {
905            base: Box::new(base_box),
906            sup: sup_box.map(Box::new),
907            sub: sub_box.map(Box::new),
908            sup_shift,
909            sub_shift,
910            sup_scale: sup_ratio,
911            sub_scale: sub_ratio,
912            center_scripts,
913            italic_correction,
914        },
915        color: options.color,
916    }
917}
918
919// ============================================================================
920// Radical (square root) layout
921// ============================================================================
922
923fn layout_radical(
924    body: &ParseNode,
925    index: Option<&ParseNode>,
926    options: &LayoutOptions,
927) -> LayoutBox {
928    let cramped = options.style.cramped();
929    let cramped_opts = options.with_style(cramped);
930    let mut body_box = layout_node(body, &cramped_opts);
931
932    // Cramped style has same size_multiplier as uncramped
933    let body_ratio = cramped.size_multiplier() / options.style.size_multiplier();
934    body_box.height *= body_ratio;
935    body_box.depth *= body_ratio;
936    body_box.width *= body_ratio;
937
938    // Ensure non-zero inner height (KaTeX: if inner.height === 0, use xHeight)
939    if body_box.height == 0.0 {
940        body_box.height = options.metrics().x_height;
941    }
942
943    let metrics = options.metrics();
944    let theta = metrics.default_rule_thickness; // 0.04 for textstyle
945
946    // KaTeX sqrt.js: `let phi = theta; if (options.style.id < Style.TEXT.id) phi = xHeight`.
947    // Style ids 0–1 are DISPLAY / DISPLAY_CRAMPED; TEXT is id 2. So only display styles use xHeight.
948    let phi = if options.style.is_display() {
949        metrics.x_height
950    } else {
951        theta
952    };
953
954    let mut line_clearance = theta + phi / 4.0;
955
956    // Minimum delimiter height needed
957    let min_delim_height = body_box.height + body_box.depth + line_clearance + theta;
958
959    // Select surd glyph size (simplified: use known breakpoints)
960    // KaTeX surd sizes: small=1.0, size1=1.2, size2=1.8, size3=2.4, size4=3.0
961    let tex_height = select_surd_height(min_delim_height);
962    let rule_width = theta;
963    let surd_font = crate::surd::surd_font_for_inner_height(tex_height);
964    let advance_width = ratex_font::get_char_metrics(surd_font, 0x221A)
965        .map(|m| m.width)
966        .unwrap_or(0.833);
967
968    // Check if delimiter is taller than needed → center the extra space
969    let delim_depth = tex_height - rule_width;
970    if delim_depth > body_box.height + body_box.depth + line_clearance {
971        line_clearance =
972            (line_clearance + delim_depth - body_box.height - body_box.depth) / 2.0;
973    }
974
975    let img_shift = tex_height - body_box.height - line_clearance - rule_width;
976
977    // Compute final box dimensions via vlist logic
978    // height = inner.height + lineClearance + 2*ruleWidth when inner.depth=0
979    let height = tex_height + rule_width - img_shift;
980    let depth = if img_shift > body_box.depth {
981        img_shift
982    } else {
983        body_box.depth
984    };
985
986    // Root index (e.g. \sqrt[3]{x}): KaTeX uses SCRIPTSCRIPT (TeX: superscript of superscript).
987    const INDEX_KERN: f64 = 0.05;
988    let (index_box, index_offset, index_scale) = if let Some(index_node) = index {
989        let root_style = options.style.superscript().superscript();
990        let root_opts = options.with_style(root_style);
991        let idx = layout_node(index_node, &root_opts);
992        let index_ratio = root_style.size_multiplier() / options.style.size_multiplier();
993        let offset = idx.width * index_ratio + INDEX_KERN;
994        (Some(Box::new(idx)), offset, index_ratio)
995    } else {
996        (None, 0.0, 1.0)
997    };
998
999    let width = index_offset + advance_width + body_box.width;
1000
1001    LayoutBox {
1002        width,
1003        height,
1004        depth,
1005        content: BoxContent::Radical {
1006            body: Box::new(body_box),
1007            index: index_box,
1008            index_offset,
1009            index_scale,
1010            rule_thickness: rule_width,
1011            inner_height: tex_height,
1012        },
1013        color: options.color,
1014    }
1015}
1016
1017/// Select the surd glyph height based on the required minimum delimiter height.
1018/// KaTeX uses: small(1.0), Size1(1.2), Size2(1.8), Size3(2.4), Size4(3.0).
1019fn select_surd_height(min_height: f64) -> f64 {
1020    const SURD_HEIGHTS: [f64; 5] = [1.0, 1.2, 1.8, 2.4, 3.0];
1021    for &h in &SURD_HEIGHTS {
1022        if h >= min_height {
1023            return h;
1024        }
1025    }
1026    // For very tall content, use the largest + stack
1027    SURD_HEIGHTS[4].max(min_height)
1028}
1029
1030// ============================================================================
1031// Operator layout (TeX Rule 13)
1032// ============================================================================
1033
1034const NO_SUCCESSOR: &[&str] = &["\\smallint"];
1035
1036/// Check if a SupSub's base should use limits (above/below) positioning.
1037fn should_use_op_limits(base: &ParseNode, options: &LayoutOptions) -> bool {
1038    match base {
1039        ParseNode::Op {
1040            limits,
1041            always_handle_sup_sub,
1042            ..
1043        } => {
1044            *limits
1045                && (options.style.is_display()
1046                    || always_handle_sup_sub.unwrap_or(false))
1047        }
1048        ParseNode::OperatorName {
1049            always_handle_sup_sub,
1050            limits,
1051            ..
1052        } => {
1053            *always_handle_sup_sub
1054                && (options.style.is_display() || *limits)
1055        }
1056        _ => false,
1057    }
1058}
1059
1060/// Lay out an Op node (without limits — standalone or nolimits mode).
1061///
1062/// In KaTeX, baseShift is applied via CSS `position:relative;top:` which
1063/// does NOT alter the box dimensions. So we return the original glyph
1064/// dimensions unchanged — the visual shift is handled at render time.
1065fn layout_op(
1066    name: Option<&str>,
1067    symbol: bool,
1068    body: Option<&[ParseNode]>,
1069    _limits: bool,
1070    suppress_base_shift: bool,
1071    options: &LayoutOptions,
1072) -> LayoutBox {
1073    let (mut base_box, _slant) = build_op_base(name, symbol, body, options);
1074
1075    // Center symbol operators on the math axis (TeX Rule 13a)
1076    if symbol && !suppress_base_shift {
1077        let axis = options.metrics().axis_height;
1078        let _total = base_box.height + base_box.depth;
1079        let shift = (base_box.height - base_box.depth) / 2.0 - axis;
1080        if shift.abs() > 0.001 {
1081            base_box.height -= shift;
1082            base_box.depth += shift;
1083        }
1084    }
1085
1086    base_box
1087}
1088
1089/// Build the base glyph/text for an operator.
1090/// Returns (base_box, slant) where slant is the italic correction.
1091fn build_op_base(
1092    name: Option<&str>,
1093    symbol: bool,
1094    body: Option<&[ParseNode]>,
1095    options: &LayoutOptions,
1096) -> (LayoutBox, f64) {
1097    if symbol {
1098        let large = options.style.is_display()
1099            && !NO_SUCCESSOR.contains(&name.unwrap_or(""));
1100        let font_id = if large {
1101            FontId::Size2Regular
1102        } else {
1103            FontId::Size1Regular
1104        };
1105
1106        let op_name = name.unwrap_or("");
1107        let ch = resolve_op_char(op_name);
1108        let char_code = ch as u32;
1109
1110        let metrics = get_char_metrics(font_id, char_code);
1111        let (width, height, depth, italic) = match metrics {
1112            Some(m) => (m.width, m.height, m.depth, m.italic),
1113            None => (1.0, 0.75, 0.25, 0.0),
1114        };
1115        // Include italic correction in width so limits centered above/below don't overlap
1116        // the operator's right-side extension (e.g. integral ∫ has non-zero italic).
1117        let width_with_italic = width + italic;
1118
1119        let base = LayoutBox {
1120            width: width_with_italic,
1121            height,
1122            depth,
1123            content: BoxContent::Glyph {
1124                font_id,
1125                char_code,
1126            },
1127            color: options.color,
1128        };
1129
1130        // \oiint and \oiiint: overlay an ellipse on the integral (∬/∭) like \oint’s circle.
1131        // resolve_op_char already maps them to ∬/∭; add the circle overlay here.
1132        if op_name == "\\oiint" || op_name == "\\oiiint" {
1133            let w = base.width;
1134            let ellipse_commands = ellipse_overlay_path(w, base.height, base.depth);
1135            let overlay_box = LayoutBox {
1136                width: w,
1137                height: base.height,
1138                depth: base.depth,
1139                content: BoxContent::SvgPath {
1140                    commands: ellipse_commands,
1141                    fill: false,
1142                },
1143                color: options.color,
1144            };
1145            let with_overlay = make_hbox(vec![base, LayoutBox::new_kern(-w), overlay_box]);
1146            return (with_overlay, italic);
1147        }
1148
1149        (base, italic)
1150    } else if let Some(body_nodes) = body {
1151        let base = layout_expression(body_nodes, options, true);
1152        (base, 0.0)
1153    } else {
1154        let base = layout_op_text(name.unwrap_or(""), options);
1155        (base, 0.0)
1156    }
1157}
1158
1159/// Render a text operator name like \sin, \cos, \lim.
1160fn layout_op_text(name: &str, options: &LayoutOptions) -> LayoutBox {
1161    let text = name.strip_prefix('\\').unwrap_or(name);
1162    let mut children = Vec::new();
1163    for ch in text.chars() {
1164        let char_code = ch as u32;
1165        let metrics = get_char_metrics(FontId::MainRegular, char_code);
1166        let (width, height, depth) = match metrics {
1167            Some(m) => (m.width, m.height, m.depth),
1168            None => (0.5, 0.43, 0.0),
1169        };
1170        children.push(LayoutBox {
1171            width,
1172            height,
1173            depth,
1174            content: BoxContent::Glyph {
1175                font_id: FontId::MainRegular,
1176                char_code,
1177            },
1178            color: options.color,
1179        });
1180    }
1181    make_hbox(children)
1182}
1183
1184/// Compute the vertical shift to center an op symbol on the math axis (Rule 13).
1185fn compute_op_base_shift(base: &LayoutBox, options: &LayoutOptions) -> f64 {
1186    let metrics = options.metrics();
1187    (base.height - base.depth) / 2.0 - metrics.axis_height
1188}
1189
1190/// Resolve an op command name to its Unicode character.
1191fn resolve_op_char(name: &str) -> char {
1192    // \oiint and \oiiint: use ∬/∭ as base glyph; circle overlay is drawn in build_op_base
1193    // (same idea as \oint’s circle, but U+222F/U+2230 often missing in math fonts).
1194    match name {
1195        "\\oiint"  => return '\u{222C}', // ∬ (double integral)
1196        "\\oiiint" => return '\u{222D}', // ∭ (triple integral)
1197        _ => {}
1198    }
1199    let font_mode = ratex_font::Mode::Math;
1200    if let Some(info) = ratex_font::get_symbol(name, font_mode) {
1201        if let Some(cp) = info.codepoint {
1202            return cp;
1203        }
1204    }
1205    name.chars().next().unwrap_or('?')
1206}
1207
1208/// Lay out an Op with limits above/below (called from SupSub delegation).
1209fn layout_op_with_limits(
1210    base_node: &ParseNode,
1211    sup_node: Option<&ParseNode>,
1212    sub_node: Option<&ParseNode>,
1213    options: &LayoutOptions,
1214) -> LayoutBox {
1215    let (name, symbol, body, suppress_base_shift) = match base_node {
1216        ParseNode::Op {
1217            name,
1218            symbol,
1219            body,
1220            suppress_base_shift,
1221            ..
1222        } => (
1223            name.as_deref(),
1224            *symbol,
1225            body.as_deref(),
1226            suppress_base_shift.unwrap_or(false),
1227        ),
1228        ParseNode::OperatorName { body, .. } => (None, false, Some(body.as_slice()), false),
1229        _ => return layout_supsub(Some(base_node), sup_node, sub_node, options, None),
1230    };
1231
1232    let (base_box, slant) = build_op_base(name, symbol, body, options);
1233    // baseShift only applies to symbol operators (KaTeX: base instanceof SymbolNode)
1234    let base_shift = if symbol && !suppress_base_shift {
1235        compute_op_base_shift(&base_box, options)
1236    } else {
1237        0.0
1238    };
1239
1240    layout_op_limits_inner(&base_box, sup_node, sub_node, slant, base_shift, options)
1241}
1242
1243/// Assemble an operator with limits above/below (KaTeX's assembleSupSub).
1244fn layout_op_limits_inner(
1245    base: &LayoutBox,
1246    sup_node: Option<&ParseNode>,
1247    sub_node: Option<&ParseNode>,
1248    slant: f64,
1249    base_shift: f64,
1250    options: &LayoutOptions,
1251) -> LayoutBox {
1252    let metrics = options.metrics();
1253    let sup_style = options.style.superscript();
1254    let sub_style = options.style.subscript();
1255
1256    let sup_ratio = sup_style.size_multiplier() / options.style.size_multiplier();
1257    let sub_ratio = sub_style.size_multiplier() / options.style.size_multiplier();
1258
1259    // Extra vertical padding so limits don't sit too close to the operator (e.g. ∫_0^1).
1260    let extra_clearance = 0.08_f64;
1261
1262    let sup_data = sup_node.map(|s| {
1263        let sup_opts = options.with_style(sup_style);
1264        let elem = layout_node(s, &sup_opts);
1265        let kern = (metrics.big_op_spacing1 + extra_clearance)
1266            .max(metrics.big_op_spacing3 - elem.depth * sup_ratio + extra_clearance);
1267        (elem, kern)
1268    });
1269
1270    let sub_data = sub_node.map(|s| {
1271        let sub_opts = options.with_style(sub_style);
1272        let elem = layout_node(s, &sub_opts);
1273        let kern = (metrics.big_op_spacing2 + extra_clearance)
1274            .max(metrics.big_op_spacing4 - elem.height * sub_ratio + extra_clearance);
1275        (elem, kern)
1276    });
1277
1278    let sp5 = metrics.big_op_spacing5;
1279
1280    let (total_height, total_depth, total_width) = match (&sup_data, &sub_data) {
1281        (Some((sup_elem, sup_kern)), Some((sub_elem, sub_kern))) => {
1282            // Both sup and sub: VList from bottom
1283            // [sp5, sub, sub_kern, base, sup_kern, sup, sp5]
1284            let sup_h = sup_elem.height * sup_ratio;
1285            let sup_d = sup_elem.depth * sup_ratio;
1286            let sub_h = sub_elem.height * sub_ratio;
1287            let sub_d = sub_elem.depth * sub_ratio;
1288
1289            let bottom = sp5 + sub_h + sub_d + sub_kern + base.depth + base_shift;
1290
1291            let height = bottom
1292                + base.height - base_shift
1293                + sup_kern
1294                + sup_h + sup_d
1295                + sp5
1296                - (base.height + base.depth);
1297
1298            let total_h = base.height - base_shift + sup_kern + sup_h + sup_d + sp5;
1299            let total_d = bottom;
1300
1301            let w = base
1302                .width
1303                .max(sup_elem.width * sup_ratio)
1304                .max(sub_elem.width * sub_ratio);
1305            let _ = height; // suppress unused; we use total_h/total_d
1306            (total_h, total_d, w)
1307        }
1308        (None, Some((sub_elem, sub_kern))) => {
1309            // Sub only: VList from top
1310            // [sp5, sub, sub_kern, base]
1311            let sub_h = sub_elem.height * sub_ratio;
1312            let sub_d = sub_elem.depth * sub_ratio;
1313
1314            let total_h = base.height - base_shift;
1315            let total_d = base.depth + base_shift + sub_kern + sub_h + sub_d + sp5;
1316
1317            let w = base.width.max(sub_elem.width * sub_ratio);
1318            (total_h, total_d, w)
1319        }
1320        (Some((sup_elem, sup_kern)), None) => {
1321            // Sup only: VList from bottom
1322            // [base, sup_kern, sup, sp5]
1323            let sup_h = sup_elem.height * sup_ratio;
1324            let sup_d = sup_elem.depth * sup_ratio;
1325
1326            let total_h =
1327                base.height - base_shift + sup_kern + sup_h + sup_d + sp5;
1328            let total_d = base.depth + base_shift;
1329
1330            let w = base.width.max(sup_elem.width * sup_ratio);
1331            (total_h, total_d, w)
1332        }
1333        (None, None) => {
1334            return base.clone();
1335        }
1336    };
1337
1338    let sup_kern_val = sup_data.as_ref().map(|(_, k)| *k).unwrap_or(0.0);
1339    let sub_kern_val = sub_data.as_ref().map(|(_, k)| *k).unwrap_or(0.0);
1340
1341    LayoutBox {
1342        width: total_width,
1343        height: total_height,
1344        depth: total_depth,
1345        content: BoxContent::OpLimits {
1346            base: Box::new(base.clone()),
1347            sup: sup_data.map(|(elem, _)| Box::new(elem)),
1348            sub: sub_data.map(|(elem, _)| Box::new(elem)),
1349            base_shift,
1350            sup_kern: sup_kern_val,
1351            sub_kern: sub_kern_val,
1352            slant,
1353            sup_scale: sup_ratio,
1354            sub_scale: sub_ratio,
1355        },
1356        color: options.color,
1357    }
1358}
1359
1360/// Lay out \operatorname body as roman text.
1361fn layout_operatorname(body: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
1362    let mut children = Vec::new();
1363    for node in body {
1364        match node {
1365            ParseNode::MathOrd { text, .. } | ParseNode::TextOrd { text, .. } => {
1366                let ch = text.chars().next().unwrap_or('?');
1367                let char_code = ch as u32;
1368                let metrics = get_char_metrics(FontId::MainRegular, char_code);
1369                let (width, height, depth) = match metrics {
1370                    Some(m) => (m.width, m.height, m.depth),
1371                    None => (0.5, 0.43, 0.0),
1372                };
1373                children.push(LayoutBox {
1374                    width,
1375                    height,
1376                    depth,
1377                    content: BoxContent::Glyph {
1378                        font_id: FontId::MainRegular,
1379                        char_code,
1380                    },
1381                    color: options.color,
1382                });
1383            }
1384            _ => {
1385                children.push(layout_node(node, options));
1386            }
1387        }
1388    }
1389    make_hbox(children)
1390}
1391
1392// ============================================================================
1393// Accent layout
1394// ============================================================================
1395
1396/// `\vec` KaTeX SVG: nudge slightly right to match KaTeX reference.
1397const VEC_SKEW_EXTRA_RIGHT_EM: f64 = 0.018;
1398
1399/// Extract the italic correction of the base glyph.
1400/// Used by superscripts: KaTeX adds margin-right = italic_correction to italic math characters,
1401/// so the superscript starts at advance_width + italic_correction (not just advance_width).
1402fn glyph_italic(lb: &LayoutBox) -> f64 {
1403    match &lb.content {
1404        BoxContent::Glyph { font_id, char_code } => {
1405            get_char_metrics(*font_id, *char_code)
1406                .map(|m| m.italic)
1407                .unwrap_or(0.0)
1408        }
1409        BoxContent::HBox(children) => {
1410            children.last().map(glyph_italic).unwrap_or(0.0)
1411        }
1412        _ => 0.0,
1413    }
1414}
1415
1416/// Extract the skew (italic correction) of the innermost/last glyph in a box.
1417/// Used by shifty accents (\hat, \tilde…) to horizontally centre the mark
1418/// over italic math letters (e.g. M in MathItalic has skew ≈ 0.083em).
1419fn glyph_skew(lb: &LayoutBox) -> f64 {
1420    match &lb.content {
1421        BoxContent::Glyph { font_id, char_code } => {
1422            get_char_metrics(*font_id, *char_code)
1423                .map(|m| m.skew)
1424                .unwrap_or(0.0)
1425        }
1426        BoxContent::HBox(children) => {
1427            children.last().map(glyph_skew).unwrap_or(0.0)
1428        }
1429        _ => 0.0,
1430    }
1431}
1432
1433fn layout_accent(
1434    label: &str,
1435    base: &ParseNode,
1436    is_stretchy: bool,
1437    is_shifty: bool,
1438    is_below: bool,
1439    options: &LayoutOptions,
1440) -> LayoutBox {
1441    let body_box = layout_node(base, options);
1442    let base_w = body_box.width.max(0.5);
1443
1444    // Special handling for \textcircled: draw a circle around the content
1445    if label == "\\textcircled" {
1446        return layout_textcircled(body_box, options);
1447    }
1448
1449    // Try KaTeX exact SVG paths first (widehat, widetilde, overgroup, etc.)
1450    if let Some((commands, w, h, fill)) =
1451        crate::katex_svg::katex_accent_path(label, base_w)
1452    {
1453        // KaTeX paths use SVG coords (y down): height=0, depth=h
1454        let accent_box = LayoutBox {
1455            width: w,
1456            height: 0.0,
1457            depth: h,
1458            content: BoxContent::SvgPath { commands, fill },
1459            color: options.color,
1460        };
1461        // KaTeX `accent.ts` uses `clearance = min(body.height, xHeight)` for ordinary accents.
1462        // That matches fixed-size `\vec` (svgData.vec); using it for *width-scaled* SVG accents
1463        // (\widehat, \widetilde, \overgroup, …) pulls the path down onto the base (golden 0604/0885/0886).
1464        let gap = 0.08;
1465        let clearance = if is_below {
1466            body_box.height + body_box.depth + gap
1467        } else if label == "\\vec" {
1468            // KaTeX: clearance = min(body.height, xHeight) is used as *overlap* (kern down).
1469            // Equivalent RaTeX position: vec bottom = body.height - overlap = max(0, body.height - xHeight).
1470            (body_box.height - options.metrics().x_height).max(0.0)
1471        } else {
1472            body_box.height + gap
1473        };
1474        let (height, depth) = if is_below {
1475            (body_box.height, body_box.depth + h + gap)
1476        } else if label == "\\vec" {
1477            // Box height = clearance + H_EM, matching KaTeX VList height.
1478            (clearance + h, body_box.depth)
1479        } else {
1480            (body_box.height + gap + h, body_box.depth)
1481        };
1482        let vec_skew = if label == "\\vec" {
1483            (if is_shifty {
1484                glyph_skew(&body_box)
1485            } else {
1486                0.0
1487            }) + VEC_SKEW_EXTRA_RIGHT_EM
1488        } else {
1489            0.0
1490        };
1491        return LayoutBox {
1492            width: body_box.width,
1493            height,
1494            depth,
1495            content: BoxContent::Accent {
1496                base: Box::new(body_box),
1497                accent: Box::new(accent_box),
1498                clearance,
1499                skew: vec_skew,
1500                is_below,
1501            },
1502            color: options.color,
1503        };
1504    }
1505
1506    // Arrow-type stretchy accents (overrightarrow, etc.)
1507    let use_arrow_path = is_stretchy && is_arrow_accent(label);
1508
1509    let accent_box = if use_arrow_path {
1510        let (commands, arrow_h, fill_arrow) =
1511            match crate::katex_svg::katex_stretchy_path(label, base_w) {
1512                Some((c, h)) => (c, h, true),
1513                None => {
1514                    let h = 0.3_f64;
1515                    let c = stretchy_accent_path(label, base_w, h);
1516                    let fill = label == "\\xtwoheadrightarrow" || label == "\\xtwoheadleftarrow";
1517                    (c, h, fill)
1518                }
1519            };
1520        LayoutBox {
1521            width: base_w,
1522            height: arrow_h / 2.0,
1523            depth: arrow_h / 2.0,
1524            content: BoxContent::SvgPath {
1525                commands,
1526                fill: fill_arrow,
1527            },
1528            color: options.color,
1529        }
1530    } else {
1531        // Try text mode first for text accents (\c, \', \`, etc.), fall back to math
1532        let accent_char = {
1533            let ch = resolve_symbol_char(label, Mode::Text);
1534            if ch == label.chars().next().unwrap_or('?') {
1535                // Text mode didn't resolve (returned first char of label, likely '\\')
1536                // so try math mode
1537                resolve_symbol_char(label, Mode::Math)
1538            } else {
1539                ch
1540            }
1541        };
1542        let accent_code = accent_char as u32;
1543        let accent_metrics = get_char_metrics(FontId::MainRegular, accent_code);
1544        let (accent_w, accent_h, accent_d) = match accent_metrics {
1545            Some(m) => (m.width, m.height, m.depth),
1546            None => (body_box.width, 0.25, 0.0),
1547        };
1548        LayoutBox {
1549            width: accent_w,
1550            height: accent_h,
1551            depth: accent_d,
1552            content: BoxContent::Glyph {
1553                font_id: FontId::MainRegular,
1554                char_code: accent_code,
1555            },
1556            color: options.color,
1557        }
1558    };
1559
1560    let skew = if use_arrow_path {
1561        0.0
1562    } else if is_shifty {
1563        // For shifty accents (\hat, \tilde, etc.) shift by the BASE character's skew,
1564        // which encodes the italic correction in math-italic fonts (e.g. M → 0.083em).
1565        glyph_skew(&body_box)
1566    } else {
1567        0.0
1568    };
1569
1570    // gap = clearance between body top and bottom of accent SVG.
1571    // For arrow accents, the SVG path is centered (height=h/2, depth=h/2).
1572    // The gap prevents the visible arrowhead / harpoon tip from overlapping the base top.
1573    //
1574    // KaTeX stretchy arrows with vb_height 522 have h/2 ≈ 0.261em; default gap=0.12 left
1575    // too little room for tall caps (`\overleftrightarrow{AB}`, `\overleftarrow{AB}`,
1576    // `\overleftharpoon{AB}`, …).  `\Overrightarrow` uses a taller glyph (vb 560) and keeps
1577    // the slightly smaller kern used in prior tuning.
1578    let gap = if use_arrow_path {
1579        if label == "\\Overrightarrow" {
1580            0.21
1581        } else {
1582            0.26
1583        }
1584    } else {
1585        0.0
1586    };
1587
1588    let clearance = if is_below {
1589        body_box.height + body_box.depth + accent_box.depth + gap
1590    } else if use_arrow_path {
1591        body_box.height + gap
1592    } else {
1593        // Clearance = how high above baseline the accent is positioned.
1594        // - For simple letters (M, b, o): body_box.height is the letter top → use directly.
1595        // - For a body that is itself an above-accent (\r{a} = \aa, \bar{x}, …):
1596        //   body_box.height = inner_clearance + 0.35 (the 0.35 rendering correction is
1597        //   already baked in). Using it as outer clearance adds ANOTHER 0.35 on top
1598        //   (staircase effect), placing hat 0.35em above ring — too spaced.
1599        //   Instead, read the inner accent's clearance directly from BoxContent and add
1600        //   a small ε (0.07em ≈ 3px) so the marks don't pixel-overlap in the rasterizer.
1601        //   This is equivalent to KaTeX's min(body.height, xHeight) approach.
1602        let base_clearance = match &body_box.content {
1603            BoxContent::Accent { clearance: inner_cl, is_below, accent: inner_accent, .. }
1604                if !is_below =>
1605            {
1606                // For SVG accents (height≈0, e.g. \vec): body_box.height = clearance + H_EM,
1607                // which matches KaTeX's body.height. Use min(body.height, xHeight) exactly as
1608                // KaTeX does: clearance = min(body.height, xHeight).
1609                // For glyph accents the 0.35 rendering shift is baked into body_box.height,
1610                // so we use inner_cl + 0.3 to avoid double-counting that correction.
1611                if inner_accent.height <= 0.001 {
1612                    // For SVG accents like \vec: KaTeX places the outer glyph accent with
1613                    // its baseline at body.height - min(body.height, xHeight) above formula
1614                    // baseline, i.e. max(0, body.height - xHeight).
1615                    // to_display.rs shifts the glyph DOWN by (accent_h - 0.35.min(accent_h))
1616                    // so we pre-add that correction to land at the right position.
1617                    let katex_pos = (body_box.height - options.metrics().x_height).max(0.0);
1618                    let correction = (accent_box.height - 0.35_f64.min(accent_box.height)).max(0.0);
1619                    katex_pos + correction
1620                } else {
1621                    inner_cl + 0.3
1622                }
1623            }
1624            _ => body_box.height,
1625        };
1626        // \bar and \= (macron): add small extra gap so bar distance matches KaTeX reference
1627        if label == "\\bar" || label == "\\=" {
1628            base_clearance - 0.2
1629        } else {
1630            base_clearance
1631        }
1632    };
1633
1634    let (height, depth) = if is_below {
1635        (body_box.height, body_box.depth + accent_box.height + accent_box.depth + gap)
1636    } else if use_arrow_path {
1637        (body_box.height + gap + accent_box.height, body_box.depth)
1638    } else {
1639        // to_display.rs shifts every glyph accent DOWN by max(0, accent.height - 0.35),
1640        // so the actual visual top of the accent mark = clearance + min(0.35, accent.height).
1641        // Use this for the layout height so nested accents (e.g. \hat{\r{a}}) see the
1642        // correct base height instead of the over-estimated clearance + accent.height.
1643        // For \hat, \bar, \dot, \ddot: also enforce KaTeX's 0.78056em strut so that
1644        // short bases (x_height ≈ 0.43) produce consistent line spacing.
1645        const ACCENT_ABOVE_STRUT_HEIGHT_EM: f64 = 0.78056;
1646        let accent_visual_top = clearance + 0.35_f64.min(accent_box.height);
1647        let h = if matches!(label, "\\hat" | "\\bar" | "\\=" | "\\dot" | "\\ddot") {
1648            accent_visual_top.max(ACCENT_ABOVE_STRUT_HEIGHT_EM)
1649        } else {
1650            body_box.height.max(accent_visual_top)
1651        };
1652        (h, body_box.depth)
1653    };
1654
1655    LayoutBox {
1656        width: body_box.width,
1657        height,
1658        depth,
1659        content: BoxContent::Accent {
1660            base: Box::new(body_box),
1661            accent: Box::new(accent_box),
1662            clearance,
1663            skew,
1664            is_below,
1665        },
1666        color: options.color,
1667    }
1668}
1669
1670// ============================================================================
1671// Left/Right stretchy delimiters
1672// ============================================================================
1673
1674/// Returns true if the node (or any descendant) is a Middle node.
1675fn node_contains_middle(node: &ParseNode) -> bool {
1676    match node {
1677        ParseNode::Middle { .. } => true,
1678        ParseNode::OrdGroup { body, .. } | ParseNode::MClass { body, .. } => {
1679            body.iter().any(node_contains_middle)
1680        }
1681        ParseNode::SupSub { base, sup, sub, .. } => {
1682            base.as_deref().is_some_and(node_contains_middle)
1683                || sup.as_deref().is_some_and(node_contains_middle)
1684                || sub.as_deref().is_some_and(node_contains_middle)
1685        }
1686        ParseNode::GenFrac { numer, denom, .. } => {
1687            node_contains_middle(numer) || node_contains_middle(denom)
1688        }
1689        ParseNode::Sqrt { body, index, .. } => {
1690            node_contains_middle(body) || index.as_deref().is_some_and(node_contains_middle)
1691        }
1692        ParseNode::Accent { base, .. } | ParseNode::AccentUnder { base, .. } => {
1693            node_contains_middle(base)
1694        }
1695        ParseNode::Op { body, .. } => body
1696            .as_ref()
1697            .is_some_and(|b| b.iter().any(node_contains_middle)),
1698        ParseNode::LeftRight { body, .. } => body.iter().any(node_contains_middle),
1699        ParseNode::OperatorName { body, .. } => body.iter().any(node_contains_middle),
1700        ParseNode::Font { body, .. } => node_contains_middle(body),
1701        ParseNode::Text { body, .. }
1702        | ParseNode::Color { body, .. }
1703        | ParseNode::Styling { body, .. }
1704        | ParseNode::Sizing { body, .. } => body.iter().any(node_contains_middle),
1705        ParseNode::Overline { body, .. } | ParseNode::Underline { body, .. } => {
1706            node_contains_middle(body)
1707        }
1708        ParseNode::Phantom { body, .. } => body.iter().any(node_contains_middle),
1709        ParseNode::VPhantom { body, .. } | ParseNode::Smash { body, .. } => {
1710            node_contains_middle(body)
1711        }
1712        ParseNode::Array { body, .. } => body
1713            .iter()
1714            .any(|row| row.iter().any(node_contains_middle)),
1715        ParseNode::Enclose { body, .. }
1716        | ParseNode::Lap { body, .. }
1717        | ParseNode::RaiseBox { body, .. }
1718        | ParseNode::VCenter { body, .. } => node_contains_middle(body),
1719        ParseNode::Pmb { body, .. } => body.iter().any(node_contains_middle),
1720        ParseNode::XArrow { body, below, .. } => {
1721            node_contains_middle(body) || below.as_deref().is_some_and(node_contains_middle)
1722        }
1723        ParseNode::MathChoice {
1724            display,
1725            text,
1726            script,
1727            scriptscript,
1728            ..
1729        } => {
1730            display.iter().any(node_contains_middle)
1731                || text.iter().any(node_contains_middle)
1732                || script.iter().any(node_contains_middle)
1733                || scriptscript.iter().any(node_contains_middle)
1734        }
1735        ParseNode::HorizBrace { base, .. } => node_contains_middle(base),
1736        ParseNode::Href { body, .. } => body.iter().any(node_contains_middle),
1737        _ => false,
1738    }
1739}
1740
1741/// Returns true if any node in the slice (recursing into all container nodes) is a Middle node.
1742fn body_contains_middle(nodes: &[ParseNode]) -> bool {
1743    nodes.iter().any(node_contains_middle)
1744}
1745
1746/// KaTeX genfrac HTML Rule 15e: `\binom`, `\brace`, `\brack`, `\atop` use `delim1`/`delim2`
1747/// from font metrics, not the `\left`/`\right` height formula (`makeLeftRightDelim` vs genfrac).
1748fn genfrac_delim_target_height(options: &LayoutOptions) -> f64 {
1749    let m = options.metrics();
1750    if options.style.is_display() {
1751        m.delim1
1752    } else if matches!(
1753        options.style,
1754        MathStyle::ScriptScript | MathStyle::ScriptScriptCramped
1755    ) {
1756        options
1757            .with_style(MathStyle::Script)
1758            .metrics()
1759            .delim2
1760    } else {
1761        m.delim2
1762    }
1763}
1764
1765/// Required total height for `\left`/`\right` stretchy delimiters (TeX `\sigma_4` rule).
1766fn left_right_delim_total_height(inner: &LayoutBox, options: &LayoutOptions) -> f64 {
1767    let metrics = options.metrics();
1768    let inner_height = inner.height;
1769    let inner_depth = inner.depth;
1770    let axis = metrics.axis_height;
1771    let max_dist = (inner_height - axis).max(inner_depth + axis);
1772    let delim_factor = 901.0;
1773    let delim_extend = 5.0 / metrics.pt_per_em;
1774    let from_formula = (max_dist / 500.0 * delim_factor).max(2.0 * max_dist - delim_extend);
1775    // Ensure delimiter is at least as tall as inner content
1776    from_formula.max(inner_height + inner_depth)
1777}
1778
1779fn layout_left_right(
1780    body: &[ParseNode],
1781    left_delim: &str,
1782    right_delim: &str,
1783    options: &LayoutOptions,
1784) -> LayoutBox {
1785    let (inner, total_height) = if body_contains_middle(body) {
1786        // First pass: layout with no delim height so \middle doesn't inflate inner size.
1787        let opts_first = LayoutOptions {
1788            leftright_delim_height: None,
1789            ..options.clone()
1790        };
1791        let inner_first = layout_expression(body, &opts_first, true);
1792        let total_height = left_right_delim_total_height(&inner_first, options);
1793        // Second pass: layout with total_height so \middle stretches to match \left and \right.
1794        let opts_second = LayoutOptions {
1795            leftright_delim_height: Some(total_height),
1796            ..options.clone()
1797        };
1798        let inner_second = layout_expression(body, &opts_second, true);
1799        (inner_second, total_height)
1800    } else {
1801        let inner = layout_expression(body, options, true);
1802        let total_height = left_right_delim_total_height(&inner, options);
1803        (inner, total_height)
1804    };
1805
1806    let inner_height = inner.height;
1807    let inner_depth = inner.depth;
1808
1809    let left_box = make_stretchy_delim(left_delim, total_height, options);
1810    let right_box = make_stretchy_delim(right_delim, total_height, options);
1811
1812    let width = left_box.width + inner.width + right_box.width;
1813    let height = left_box.height.max(right_box.height).max(inner_height);
1814    let depth = left_box.depth.max(right_box.depth).max(inner_depth);
1815
1816    LayoutBox {
1817        width,
1818        height,
1819        depth,
1820        content: BoxContent::LeftRight {
1821            left: Box::new(left_box),
1822            right: Box::new(right_box),
1823            inner: Box::new(inner),
1824        },
1825        color: options.color,
1826    }
1827}
1828
1829const DELIM_FONT_SEQUENCE: [FontId; 5] = [
1830    FontId::MainRegular,
1831    FontId::Size1Regular,
1832    FontId::Size2Regular,
1833    FontId::Size3Regular,
1834    FontId::Size4Regular,
1835];
1836
1837/// Normalize angle-bracket delimiter aliases to \langle / \rangle.
1838fn normalize_delim(delim: &str) -> &str {
1839    match delim {
1840        "<" | "\\lt" | "\u{27E8}" => "\\langle",
1841        ">" | "\\gt" | "\u{27E9}" => "\\rangle",
1842        _ => delim,
1843    }
1844}
1845
1846/// Return true if delimiter should be rendered as a single vertical bar SVG path.
1847fn is_vert_delim(delim: &str) -> bool {
1848    matches!(delim, "|" | "\\vert" | "\\lvert" | "\\rvert")
1849}
1850
1851/// Return true if delimiter should be rendered as a double vertical bar SVG path.
1852fn is_double_vert_delim(delim: &str) -> bool {
1853    matches!(delim, "\\|" | "\\Vert" | "\\lVert" | "\\rVert")
1854}
1855
1856/// Build a vertical-bar delimiter LayoutBox using an SVG filled path.
1857/// `total_height` is the full height (height+depth) in em.
1858/// For single vert: viewBoxWidth = 0.333em; for double: 0.556em.
1859fn make_vert_delim_box(total_height: f64, is_double: bool, options: &LayoutOptions) -> LayoutBox {
1860    let axis = options.metrics().axis_height;
1861    let depth = (total_height / 2.0 - axis).max(0.0);
1862    let height = total_height - depth;
1863    let width = if is_double { 0.556 } else { 0.333 };
1864
1865    let commands = if is_double {
1866        double_vert_delim_path(height, depth)
1867    } else {
1868        vert_delim_path(height, depth)
1869    };
1870
1871    LayoutBox {
1872        width,
1873        height,
1874        depth,
1875        content: BoxContent::SvgPath { commands, fill: true },
1876        color: options.color,
1877    }
1878}
1879
1880/// SVG path for single vertical bar delimiter in RaTeX em coordinates.
1881/// Baseline at y=0, y-axis points down.
1882fn vert_delim_path(height: f64, depth: f64) -> Vec<PathCommand> {
1883    // Thin filled rectangle: x ∈ [0.145, 0.188] em (= 43/1000 em wide)
1884    let xl = 0.145_f64;
1885    let xr = 0.188_f64;
1886    vec![
1887        PathCommand::MoveTo { x: xl, y: -height },
1888        PathCommand::LineTo { x: xr, y: -height },
1889        PathCommand::LineTo { x: xr, y: depth },
1890        PathCommand::LineTo { x: xl, y: depth },
1891        PathCommand::Close,
1892    ]
1893}
1894
1895/// SVG path for double vertical bar delimiter in RaTeX em coordinates.
1896fn double_vert_delim_path(height: f64, depth: f64) -> Vec<PathCommand> {
1897    let (xl1, xr1) = (0.145_f64, 0.188_f64);
1898    let (xl2, xr2) = (0.367_f64, 0.410_f64);
1899    vec![
1900        PathCommand::MoveTo { x: xl1, y: -height },
1901        PathCommand::LineTo { x: xr1, y: -height },
1902        PathCommand::LineTo { x: xr1, y: depth },
1903        PathCommand::LineTo { x: xl1, y: depth },
1904        PathCommand::Close,
1905        PathCommand::MoveTo { x: xl2, y: -height },
1906        PathCommand::LineTo { x: xr2, y: -height },
1907        PathCommand::LineTo { x: xr2, y: depth },
1908        PathCommand::LineTo { x: xl2, y: depth },
1909        PathCommand::Close,
1910    ]
1911}
1912
1913/// Select a delimiter glyph large enough for the given total height.
1914fn make_stretchy_delim(delim: &str, total_height: f64, options: &LayoutOptions) -> LayoutBox {
1915    if delim == "." || delim.is_empty() {
1916        return LayoutBox::new_kern(0.0);
1917    }
1918
1919    // stackAlwaysDelimiters: use SVG path only when the required height exceeds
1920    // the natural font-glyph height (1.0em for single vert, same for double).
1921    // When the content is small enough, fall through to the normal font glyph.
1922    const VERT_NATURAL_HEIGHT: f64 = 1.0; // MainRegular |: 0.75+0.25
1923    if is_vert_delim(delim) && total_height > VERT_NATURAL_HEIGHT {
1924        return make_vert_delim_box(total_height, false, options);
1925    }
1926    if is_double_vert_delim(delim) && total_height > VERT_NATURAL_HEIGHT {
1927        return make_vert_delim_box(total_height, true, options);
1928    }
1929
1930    // Normalize < > to \langle \rangle for proper angle bracket glyphs
1931    let delim = normalize_delim(delim);
1932
1933    let ch = resolve_symbol_char(delim, Mode::Math);
1934    let char_code = ch as u32;
1935
1936    let mut best_font = FontId::MainRegular;
1937    let mut best_w = 0.4;
1938    let mut best_h = 0.7;
1939    let mut best_d = 0.2;
1940
1941    for &font_id in &DELIM_FONT_SEQUENCE {
1942        if let Some(m) = get_char_metrics(font_id, char_code) {
1943            best_font = font_id;
1944            best_w = m.width;
1945            best_h = m.height;
1946            best_d = m.depth;
1947            if best_h + best_d >= total_height {
1948                break;
1949            }
1950        }
1951    }
1952
1953    let best_total = best_h + best_d;
1954    if let Some(stacked) = make_stacked_delim_if_needed(delim, total_height, best_total, options) {
1955        return stacked;
1956    }
1957
1958    LayoutBox {
1959        width: best_w,
1960        height: best_h,
1961        depth: best_d,
1962        content: BoxContent::Glyph {
1963            font_id: best_font,
1964            char_code,
1965        },
1966        color: options.color,
1967    }
1968}
1969
1970/// Fixed total heights for \big/\Big/\bigg/\Bigg (sizeToMaxHeight from KaTeX).
1971const SIZE_TO_MAX_HEIGHT: [f64; 5] = [0.0, 1.2, 1.8, 2.4, 3.0];
1972
1973/// Layout \big, \Big, \bigg, \Bigg delimiters.
1974fn layout_delim_sizing(size: u8, delim: &str, options: &LayoutOptions) -> LayoutBox {
1975    if delim == "." || delim.is_empty() {
1976        return LayoutBox::new_kern(0.0);
1977    }
1978
1979    // stackAlwaysDelimiters: render as SVG path at the fixed size height
1980    if is_vert_delim(delim) {
1981        let total = SIZE_TO_MAX_HEIGHT[size.min(4) as usize];
1982        return make_vert_delim_box(total, false, options);
1983    }
1984    if is_double_vert_delim(delim) {
1985        let total = SIZE_TO_MAX_HEIGHT[size.min(4) as usize];
1986        return make_vert_delim_box(total, true, options);
1987    }
1988
1989    // Normalize angle brackets to proper math angle bracket glyphs
1990    let delim = normalize_delim(delim);
1991
1992    let ch = resolve_symbol_char(delim, Mode::Math);
1993    let char_code = ch as u32;
1994
1995    let font_id = match size {
1996        1 => FontId::Size1Regular,
1997        2 => FontId::Size2Regular,
1998        3 => FontId::Size3Regular,
1999        4 => FontId::Size4Regular,
2000        _ => FontId::Size1Regular,
2001    };
2002
2003    let metrics = get_char_metrics(font_id, char_code);
2004    let (width, height, depth, actual_font) = match metrics {
2005        Some(m) => (m.width, m.height, m.depth, font_id),
2006        None => {
2007            let m = get_char_metrics(FontId::MainRegular, char_code);
2008            match m {
2009                Some(m) => (m.width, m.height, m.depth, FontId::MainRegular),
2010                None => (0.4, 0.7, 0.2, FontId::MainRegular),
2011            }
2012        }
2013    };
2014
2015    LayoutBox {
2016        width,
2017        height,
2018        depth,
2019        content: BoxContent::Glyph {
2020            font_id: actual_font,
2021            char_code,
2022        },
2023        color: options.color,
2024    }
2025}
2026
2027// ============================================================================
2028// Array / Matrix layout
2029// ============================================================================
2030
2031#[allow(clippy::too_many_arguments)]
2032fn layout_array(
2033    body: &[Vec<ParseNode>],
2034    cols: Option<&[ratex_parser::parse_node::AlignSpec]>,
2035    arraystretch: f64,
2036    add_jot: bool,
2037    row_gaps: &[Option<ratex_parser::parse_node::Measurement>],
2038    hlines: &[Vec<bool>],
2039    col_sep_type: Option<&str>,
2040    hskip: bool,
2041    options: &LayoutOptions,
2042) -> LayoutBox {
2043    let metrics = options.metrics();
2044    let pt = 1.0 / metrics.pt_per_em;
2045    let baselineskip = 12.0 * pt;
2046    let jot = 3.0 * pt;
2047    let arrayskip = arraystretch * baselineskip;
2048    let arstrut_h = 0.7 * arrayskip;
2049    let arstrut_d = 0.3 * arrayskip;
2050    // align/aligned/alignedat: use thin space (3mu) so "x" and "=" are closer,
2051    // and cap relation spacing in cells to 3mu so spacing before/after "=" is equal.
2052    const ALIGN_RELATION_MU: f64 = 3.0;
2053    let col_gap = match col_sep_type {
2054        Some("align") | Some("alignat") => mu_to_em(ALIGN_RELATION_MU, metrics.quad),
2055        _ => 2.0 * 5.0 * pt, // 2 × arraycolsep
2056    };
2057    let cell_options = match col_sep_type {
2058        Some("align") | Some("alignat") => LayoutOptions {
2059            align_relation_spacing: Some(ALIGN_RELATION_MU),
2060            ..options.clone()
2061        },
2062        _ => options.clone(),
2063    };
2064
2065    let num_rows = body.len();
2066    if num_rows == 0 {
2067        return LayoutBox::new_empty();
2068    }
2069
2070    let num_cols = body.iter().map(|r| r.len()).max().unwrap_or(0);
2071
2072    // Extract per-column alignment and column separators from cols spec.
2073    use ratex_parser::parse_node::AlignType;
2074    let col_aligns: Vec<u8> = {
2075        let align_specs: Vec<&ratex_parser::parse_node::AlignSpec> = cols
2076            .map(|cs| {
2077                cs.iter()
2078                    .filter(|s| matches!(s.align_type, AlignType::Align))
2079                    .collect()
2080            })
2081            .unwrap_or_default();
2082        (0..num_cols)
2083            .map(|c| {
2084                align_specs
2085                    .get(c)
2086                    .and_then(|s| s.align.as_deref())
2087                    .and_then(|a| a.bytes().next())
2088                    .unwrap_or(b'c')
2089            })
2090            .collect()
2091    };
2092
2093    // Detect vertical separator ('|') positions in the column spec.
2094    // col_separators[i] = true means there is a '|' at boundary i (0 = before col 0, num_cols = after last col).
2095    let col_separators: Vec<bool> = {
2096        let mut seps = vec![false; num_cols + 1];
2097        let mut align_count = 0usize;
2098        if let Some(cs) = cols {
2099            for spec in cs {
2100                match spec.align_type {
2101                    AlignType::Align => align_count += 1,
2102                    AlignType::Separator if spec.align.as_deref() == Some("|") => {
2103                        if align_count <= num_cols {
2104                            seps[align_count] = true;
2105                        }
2106                    }
2107                    _ => {}
2108                }
2109            }
2110        }
2111        seps
2112    };
2113
2114    let rule_thickness = 0.4 * pt;
2115    let double_rule_sep = metrics.double_rule_sep;
2116
2117    // Layout all cells
2118    let mut cell_boxes: Vec<Vec<LayoutBox>> = Vec::with_capacity(num_rows);
2119    let mut col_widths = vec![0.0_f64; num_cols];
2120    let mut row_heights = Vec::with_capacity(num_rows);
2121    let mut row_depths = Vec::with_capacity(num_rows);
2122
2123    for row in body {
2124        let mut row_boxes = Vec::with_capacity(num_cols);
2125        let mut rh = arstrut_h;
2126        let mut rd = arstrut_d;
2127
2128        for (c, cell) in row.iter().enumerate() {
2129            let cell_nodes = match cell {
2130                ParseNode::OrdGroup { body, .. } => body.as_slice(),
2131                other => std::slice::from_ref(other),
2132            };
2133            let cell_box = layout_expression(cell_nodes, &cell_options, true);
2134            rh = rh.max(cell_box.height);
2135            rd = rd.max(cell_box.depth);
2136            if c < num_cols {
2137                col_widths[c] = col_widths[c].max(cell_box.width);
2138            }
2139            row_boxes.push(cell_box);
2140        }
2141
2142        // Pad missing columns
2143        while row_boxes.len() < num_cols {
2144            row_boxes.push(LayoutBox::new_empty());
2145        }
2146
2147        if add_jot {
2148            rd += jot;
2149        }
2150
2151        row_heights.push(rh);
2152        row_depths.push(rd);
2153        cell_boxes.push(row_boxes);
2154    }
2155
2156    // Apply row gaps
2157    for (r, gap) in row_gaps.iter().enumerate() {
2158        if r < row_depths.len() {
2159            if let Some(m) = gap {
2160                let gap_em = measurement_to_em(m, options);
2161                if gap_em > 0.0 {
2162                    row_depths[r] = row_depths[r].max(gap_em + arstrut_d);
2163                }
2164            }
2165        }
2166    }
2167
2168    // Ensure hlines_before_row has num_rows + 1 entries.
2169    let mut hlines_before_row: Vec<Vec<bool>> = hlines.to_vec();
2170    while hlines_before_row.len() < num_rows + 1 {
2171        hlines_before_row.push(vec![]);
2172    }
2173
2174    // For n > 1 consecutive hlines before row r, add extra vertical space so the
2175    // lines don't overlap with content.  Each extra line needs (rule_thickness +
2176    // double_rule_sep) of room.
2177    //   - r == 0: extra hlines appear above the first row → add to row_heights[0].
2178    //   - r >= 1: extra hlines appear in the gap above row r → add to row_depths[r-1].
2179    for r in 0..=num_rows {
2180        let n = hlines_before_row[r].len();
2181        if n > 1 {
2182            let extra = (n - 1) as f64 * (rule_thickness + double_rule_sep);
2183            if r == 0 {
2184                if num_rows > 0 {
2185                    row_heights[0] += extra;
2186                }
2187            } else {
2188                row_depths[r - 1] += extra;
2189            }
2190        }
2191    }
2192
2193    // Total height and offset (computed after extra hline spacing is applied).
2194    let mut total_height = 0.0;
2195    let mut row_positions = Vec::with_capacity(num_rows);
2196    for r in 0..num_rows {
2197        total_height += row_heights[r];
2198        row_positions.push(total_height);
2199        total_height += row_depths[r];
2200    }
2201
2202    let offset = total_height / 2.0 + metrics.axis_height;
2203
2204    // Extra x padding before col 0 and after last col (hskip_before_and_after).
2205    let content_x_offset = if hskip { col_gap / 2.0 } else { 0.0 };
2206
2207    // Total width including outer padding.
2208    let total_width: f64 = col_widths.iter().sum::<f64>()
2209        + col_gap * (num_cols.saturating_sub(1)) as f64
2210        + 2.0 * content_x_offset;
2211
2212    let height = offset;
2213    let depth = total_height - offset;
2214
2215    LayoutBox {
2216        width: total_width,
2217        height,
2218        depth,
2219        content: BoxContent::Array {
2220            cells: cell_boxes,
2221            col_widths: col_widths.clone(),
2222            col_aligns,
2223            row_heights: row_heights.clone(),
2224            row_depths: row_depths.clone(),
2225            col_gap,
2226            offset,
2227            content_x_offset,
2228            col_separators,
2229            hlines_before_row,
2230            rule_thickness,
2231            double_rule_sep,
2232        },
2233        color: options.color,
2234    }
2235}
2236
2237// ============================================================================
2238// Sizing / Text / Font
2239// ============================================================================
2240
2241fn layout_sizing(size: u8, body: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
2242    // KaTeX sizing: size 1-11, maps to multipliers
2243    let multiplier = match size {
2244        1 => 0.5,
2245        2 => 0.6,
2246        3 => 0.7,
2247        4 => 0.8,
2248        5 => 0.9,
2249        6 => 1.0,
2250        7 => 1.2,
2251        8 => 1.44,
2252        9 => 1.728,
2253        10 => 2.074,
2254        11 => 2.488,
2255        _ => 1.0,
2256    };
2257
2258    let inner = layout_expression(body, options, true);
2259    let ratio = multiplier / options.size_multiplier();
2260    if (ratio - 1.0).abs() < 0.001 {
2261        inner
2262    } else {
2263        LayoutBox {
2264            width: inner.width * ratio,
2265            height: inner.height * ratio,
2266            depth: inner.depth * ratio,
2267            content: BoxContent::Scaled {
2268                body: Box::new(inner),
2269                child_scale: ratio,
2270            },
2271            color: options.color,
2272        }
2273    }
2274}
2275
2276/// Layout \verb and \verb* — verbatim text in typewriter font.
2277/// \verb* shows spaces as a visible character (U+2423 OPEN BOX).
2278fn layout_verb(body: &str, star: bool, options: &LayoutOptions) -> LayoutBox {
2279    let metrics = options.metrics();
2280    let mut children = Vec::new();
2281    for c in body.chars() {
2282        let ch = if star && c == ' ' {
2283            '\u{2423}' // OPEN BOX, visible space
2284        } else {
2285            c
2286        };
2287        let code = ch as u32;
2288        let (font_id, w, h, d) = match get_char_metrics(FontId::TypewriterRegular, code) {
2289            Some(m) => (FontId::TypewriterRegular, m.width, m.height, m.depth),
2290            None => match get_char_metrics(FontId::MainRegular, code) {
2291                Some(m) => (FontId::MainRegular, m.width, m.height, m.depth),
2292                None => (
2293                    FontId::TypewriterRegular,
2294                    0.5,
2295                    metrics.x_height,
2296                    0.0,
2297                ),
2298            },
2299        };
2300        children.push(LayoutBox {
2301            width: w,
2302            height: h,
2303            depth: d,
2304            content: BoxContent::Glyph {
2305                font_id,
2306                char_code: code,
2307            },
2308            color: options.color,
2309        });
2310    }
2311    let mut hbox = make_hbox(children);
2312    hbox.color = options.color;
2313    hbox
2314}
2315
2316fn layout_text(body: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
2317    let mut children = Vec::new();
2318    for node in body {
2319        match node {
2320            ParseNode::TextOrd { text, .. } | ParseNode::MathOrd { text, .. } => {
2321                let ch = resolve_symbol_char(text, Mode::Text);
2322                let char_code = ch as u32;
2323                let m = get_char_metrics(FontId::MainRegular, char_code);
2324                let (w, h, d) = match m {
2325                    Some(m) => (m.width, m.height, m.depth),
2326                    None => missing_glyph_metrics_fallback(ch, options),
2327                };
2328                children.push(LayoutBox {
2329                    width: w,
2330                    height: h,
2331                    depth: d,
2332                    content: BoxContent::Glyph {
2333                        font_id: FontId::MainRegular,
2334                        char_code,
2335                    },
2336                    color: options.color,
2337                });
2338            }
2339            ParseNode::SpacingNode { text, .. } => {
2340                children.push(layout_spacing_command(text, options));
2341            }
2342            _ => {
2343                children.push(layout_node(node, options));
2344            }
2345        }
2346    }
2347    make_hbox(children)
2348}
2349
2350/// Layout \pmb — poor man's bold via CSS-style text shadow.
2351/// Renders the body twice: once normally, once offset by (0.02em, 0.01em).
2352fn layout_pmb(body: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
2353    let base = layout_expression(body, options, true);
2354    let w = base.width;
2355    let h = base.height;
2356    let d = base.depth;
2357
2358    // Shadow copy shifted right 0.02em, down 0.01em — same content, same color
2359    let shadow = layout_expression(body, options, true);
2360    let shadow_shift_x = 0.02_f64;
2361    let _shadow_shift_y = 0.01_f64;
2362
2363    // Combine: place shadow first (behind), then base on top
2364    // Shadow is placed at an HBox offset — we use a VBox/kern trick:
2365    // Instead, represent as HBox where shadow overlaps base via negative kern
2366    let kern_back = LayoutBox::new_kern(-w);
2367    let kern_x = LayoutBox::new_kern(shadow_shift_x);
2368
2369    // We create: [shadow | kern(-w) | base] in an HBox
2370    // But shadow needs to be shifted down by shadow_shift_y.
2371    // Use a raised box trick: wrap shadow in a VBox with a small kern.
2372    // Simplest approximation: just render body once (the shadow is < 1px at normal size)
2373    // but with a tiny kern to hint at bold width.
2374    // Better: use a simple 2-layer HBox with overlap.
2375    let children = vec![
2376        kern_x,
2377        shadow,
2378        kern_back,
2379        base,
2380    ];
2381    // Width should be original base width, not doubled
2382    let hbox = make_hbox(children);
2383    // Return a box with original dimensions (shadow overflow is clipped)
2384    LayoutBox {
2385        width: w,
2386        height: h,
2387        depth: d,
2388        content: hbox.content,
2389        color: options.color,
2390    }
2391}
2392
2393/// Layout \fbox, \colorbox, \fcolorbox — framed/colored box.
2394/// Also handles \phase, \cancel, \sout, \bcancel, \xcancel.
2395fn layout_enclose(
2396    label: &str,
2397    background_color: Option<&str>,
2398    border_color: Option<&str>,
2399    body: &ParseNode,
2400    options: &LayoutOptions,
2401) -> LayoutBox {
2402    use crate::layout_box::BoxContent;
2403    use ratex_types::color::Color;
2404
2405    // \phase: angle mark (diagonal line) below the body with underline
2406    if label == "\\phase" {
2407        return layout_phase(body, options);
2408    }
2409
2410    // \angl: actuarial angle — arc/roof above the body (KaTeX actuarialangle-style)
2411    if label == "\\angl" {
2412        return layout_angl(body, options);
2413    }
2414
2415    // \cancel, \bcancel, \xcancel, \sout: strike-through overlays
2416    if matches!(label, "\\cancel" | "\\bcancel" | "\\xcancel" | "\\sout") {
2417        return layout_cancel(label, body, options);
2418    }
2419
2420    // KaTeX defaults: fboxpad = 3pt, fboxrule = 0.4pt
2421    let metrics = options.metrics();
2422    let padding = 3.0 / metrics.pt_per_em;
2423    let border_thickness = 0.4 / metrics.pt_per_em;
2424
2425    let has_border = matches!(label, "\\fbox" | "\\fcolorbox");
2426
2427    let bg = background_color.and_then(|c| Color::from_name(c).or_else(|| Color::from_hex(c)));
2428    let border = border_color
2429        .and_then(|c| Color::from_name(c).or_else(|| Color::from_hex(c)))
2430        .unwrap_or(Color::BLACK);
2431
2432    let inner = layout_node(body, options);
2433    let outer_pad = padding + if has_border { border_thickness } else { 0.0 };
2434
2435    let width = inner.width + 2.0 * outer_pad;
2436    let height = inner.height + outer_pad;
2437    let depth = inner.depth + outer_pad;
2438
2439    LayoutBox {
2440        width,
2441        height,
2442        depth,
2443        content: BoxContent::Framed {
2444            body: Box::new(inner),
2445            padding,
2446            border_thickness,
2447            has_border,
2448            bg_color: bg,
2449            border_color: border,
2450        },
2451        color: options.color,
2452    }
2453}
2454
2455/// Layout \raisebox{dy}{body} — shift content vertically.
2456fn layout_raisebox(shift: f64, body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
2457    use crate::layout_box::BoxContent;
2458    let inner = layout_node(body, options);
2459    // Positive shift moves content up → height increases, depth decreases
2460    let height = inner.height + shift;
2461    let depth = (inner.depth - shift).max(0.0);
2462    let width = inner.width;
2463    LayoutBox {
2464        width,
2465        height,
2466        depth,
2467        content: BoxContent::RaiseBox {
2468            body: Box::new(inner),
2469            shift,
2470        },
2471        color: options.color,
2472    }
2473}
2474
2475/// Returns true if the parse node is a single character box (atom / mathord / textord),
2476/// mirroring KaTeX's `isCharacterBox` + `getBaseElem` logic.
2477fn is_single_char_body(node: &ParseNode) -> bool {
2478    use ratex_parser::parse_node::ParseNode as PN;
2479    match node {
2480        // Unwrap single-element ord-groups and styling nodes.
2481        PN::OrdGroup { body, .. } if body.len() == 1 => is_single_char_body(&body[0]),
2482        PN::Styling { body, .. } if body.len() == 1 => is_single_char_body(&body[0]),
2483        // Bare character nodes.
2484        PN::Atom { .. } | PN::MathOrd { .. } | PN::TextOrd { .. } => true,
2485        _ => false,
2486    }
2487}
2488
2489/// Layout \cancel, \bcancel, \xcancel, \sout — body with strike-through line(s) overlay.
2490///
2491/// Matches KaTeX `enclose.ts` + `stretchy.ts` geometry:
2492///   • single char  → v_pad = 0.2em, h_pad = 0   (line corner-to-corner of w × (h+d+0.4) box)
2493///   • multi char   → v_pad = 0,     h_pad = 0.2em (cancel-pad: line extends 0.2em each side)
2494fn layout_cancel(
2495    label: &str,
2496    body: &ParseNode,
2497    options: &LayoutOptions,
2498) -> LayoutBox {
2499    use crate::layout_box::BoxContent;
2500    let inner = layout_node(body, options);
2501    let w = inner.width.max(0.01);
2502    let h = inner.height;
2503    let d = inner.depth;
2504
2505    // KaTeX padding: single character gets vertical extension, multi-char gets horizontal.
2506    let single = is_single_char_body(body);
2507    let v_pad = if single { 0.2 } else { 0.0 };
2508    let h_pad = if single { 0.0 } else { 0.2 };
2509
2510    // Path coordinates: y=0 at baseline, y<0 above (height), y>0 below (depth).
2511    // \cancel  = "/" diagonal: bottom-left → top-right
2512    // \bcancel = "\" diagonal: top-left → bottom-right
2513    let commands: Vec<PathCommand> = match label {
2514        "\\cancel" => vec![
2515            PathCommand::MoveTo { x: -h_pad,     y: d + v_pad  },  // bottom-left
2516            PathCommand::LineTo { x: w + h_pad,  y: -h - v_pad },  // top-right
2517        ],
2518        "\\bcancel" => vec![
2519            PathCommand::MoveTo { x: -h_pad,     y: -h - v_pad },  // top-left
2520            PathCommand::LineTo { x: w + h_pad,  y: d + v_pad  },  // bottom-right
2521        ],
2522        "\\xcancel" => vec![
2523            PathCommand::MoveTo { x: -h_pad,     y: d + v_pad  },
2524            PathCommand::LineTo { x: w + h_pad,  y: -h - v_pad },
2525            PathCommand::MoveTo { x: -h_pad,     y: -h - v_pad },
2526            PathCommand::LineTo { x: w + h_pad,  y: d + v_pad  },
2527        ],
2528        "\\sout" => {
2529            // Horizontal line at –0.5× x-height, extended to content edges.
2530            let mid_y = -0.5 * options.metrics().x_height;
2531            vec![
2532                PathCommand::MoveTo { x: 0.0, y: mid_y },
2533                PathCommand::LineTo { x: w,   y: mid_y },
2534            ]
2535        }
2536        _ => vec![],
2537    };
2538
2539    let line_w = w + 2.0 * h_pad;
2540    let line_h = h + v_pad;
2541    let line_d = d + v_pad;
2542    let line_box = LayoutBox {
2543        width: line_w,
2544        height: line_h,
2545        depth: line_d,
2546        content: BoxContent::SvgPath { commands, fill: false },
2547        color: options.color,
2548    };
2549
2550    // For multi-char the body is inset by h_pad from the line-box's left edge.
2551    let body_kern = -(line_w - h_pad);
2552    let body_shifted = make_hbox(vec![LayoutBox::new_kern(body_kern), inner]);
2553    LayoutBox {
2554        width: w,
2555        height: h,
2556        depth: d,
2557        content: BoxContent::HBox(vec![line_box, body_shifted]),
2558        color: options.color,
2559    }
2560}
2561
2562/// Layout \phase{body} — angle notation: body with a diagonal angle mark + underline.
2563/// Matches KaTeX `enclose.ts` + `phasePath(y)` (steinmetz): dynamic viewBox height, `x = y/2` at the peak.
2564fn layout_phase(body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
2565    use crate::layout_box::BoxContent;
2566    let metrics = options.metrics();
2567    let inner = layout_node(body, options);
2568    // KaTeX: lineWeight = 0.6pt, clearance = 0.35ex; angleHeight = inner.h + inner.d + both
2569    let line_weight = 0.6_f64 / metrics.pt_per_em;
2570    let clearance = 0.35_f64 * metrics.x_height;
2571    let angle_height = inner.height + inner.depth + line_weight + clearance;
2572    let left_pad = angle_height / 2.0 + line_weight;
2573    let width = inner.width + left_pad;
2574
2575    // KaTeX: viewBoxHeight = floor(1000 * angleHeight * scale); base sizing uses scale → 1 here.
2576    let y_svg = (1000.0 * angle_height).floor().max(80.0);
2577
2578    // Vertical: viewBox height y_svg → angle_height em (baseline mapping below).
2579    let sy = angle_height / y_svg;
2580    // Horizontal: KaTeX SVG uses preserveAspectRatio xMinYMin slice — scale follows viewBox height,
2581    // so x grows ~sy per SVG unit (not width/400000). That keeps the left angle visible; clip to `width`.
2582    let sx = sy;
2583    let right_x = (400_000.0_f64 * sx).min(width);
2584
2585    // Baseline: peak at svg y=0 → -inner.height; bottom at y=y_svg → inner.depth + line_weight + clearance
2586    let bottom_y = inner.depth + line_weight + clearance;
2587    let vy = |y_sv: f64| -> f64 { bottom_y - (y_svg - y_sv) * sy };
2588
2589    // phasePath(y): M400000 y H0 L y/2 0 l65 45 L145 y-80 H400000z
2590    let x_peak = y_svg / 2.0;
2591    let commands = vec![
2592        PathCommand::MoveTo { x: right_x, y: vy(y_svg) },
2593        PathCommand::LineTo { x: 0.0, y: vy(y_svg) },
2594        PathCommand::LineTo { x: x_peak * sx, y: vy(0.0) },
2595        PathCommand::LineTo { x: (x_peak + 65.0) * sx, y: vy(45.0) },
2596        PathCommand::LineTo {
2597            x: 145.0 * sx,
2598            y: vy(y_svg - 80.0),
2599        },
2600        PathCommand::LineTo {
2601            x: right_x,
2602            y: vy(y_svg - 80.0),
2603        },
2604        PathCommand::Close,
2605    ];
2606
2607    let body_shifted = make_hbox(vec![
2608        LayoutBox::new_kern(left_pad),
2609        inner.clone(),
2610    ]);
2611
2612    let path_height = inner.height;
2613    let path_depth = bottom_y;
2614
2615    LayoutBox {
2616        width,
2617        height: path_height,
2618        depth: path_depth,
2619        content: BoxContent::HBox(vec![
2620            LayoutBox {
2621                width,
2622                height: path_height,
2623                depth: path_depth,
2624                content: BoxContent::SvgPath { commands, fill: true },
2625                color: options.color,
2626            },
2627            LayoutBox::new_kern(-width),
2628            body_shifted,
2629        ]),
2630        color: options.color,
2631    }
2632}
2633
2634/// Layout \angl{body} — actuarial angle: horizontal roof line above body + vertical bar on the right (KaTeX/fixture style).
2635/// Path and body share the same baseline; vertical bar runs from roof down through baseline to bottom of body.
2636fn layout_angl(body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
2637    use crate::layout_box::BoxContent;
2638    let inner = layout_node(body, options);
2639    let w = inner.width.max(0.3);
2640    // Roof line a bit higher: body_height + clearance
2641    let clearance = 0.1_f64;
2642    let arc_h = inner.height + clearance;
2643
2644    // Path: horizontal roof (0,-arc_h) to (w,-arc_h), then vertical (w,-arc_h) down to (w, depth) so bar extends below baseline
2645    let path_commands = vec![
2646        PathCommand::MoveTo { x: 0.0, y: -arc_h },
2647        PathCommand::LineTo { x: w, y: -arc_h },
2648        PathCommand::LineTo { x: w, y: inner.depth + 0.3_f64},
2649    ];
2650
2651    let height = arc_h;
2652    LayoutBox {
2653        width: w,
2654        height,
2655        depth: inner.depth,
2656        content: BoxContent::Angl {
2657            path_commands,
2658            body: Box::new(inner),
2659        },
2660        color: options.color,
2661    }
2662}
2663
2664fn layout_font(font: &str, body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
2665    let font_id = match font {
2666        "mathrm" | "\\mathrm" | "textrm" | "\\textrm" | "rm" | "\\rm" => Some(FontId::MainRegular),
2667        "mathbf" | "\\mathbf" | "textbf" | "\\textbf" | "bf" | "\\bf" => Some(FontId::MainBold),
2668        "mathit" | "\\mathit" | "textit" | "\\textit" => Some(FontId::MainItalic),
2669        "mathsf" | "\\mathsf" | "textsf" | "\\textsf" => Some(FontId::SansSerifRegular),
2670        "mathtt" | "\\mathtt" | "texttt" | "\\texttt" => Some(FontId::TypewriterRegular),
2671        "mathcal" | "\\mathcal" | "cal" | "\\cal" => Some(FontId::CaligraphicRegular),
2672        "mathfrak" | "\\mathfrak" | "frak" | "\\frak" => Some(FontId::FrakturRegular),
2673        "mathscr" | "\\mathscr" => Some(FontId::ScriptRegular),
2674        "mathbb" | "\\mathbb" => Some(FontId::AmsRegular),
2675        "boldsymbol" | "\\boldsymbol" | "bm" | "\\bm" => Some(FontId::MathBoldItalic),
2676        _ => None,
2677    };
2678
2679    if let Some(fid) = font_id {
2680        layout_with_font(body, fid, options)
2681    } else {
2682        layout_node(body, options)
2683    }
2684}
2685
2686fn layout_with_font(node: &ParseNode, font_id: FontId, options: &LayoutOptions) -> LayoutBox {
2687    match node {
2688        ParseNode::OrdGroup { body, .. } => {
2689            let children: Vec<LayoutBox> = body.iter().map(|n| layout_with_font(n, font_id, options)).collect();
2690            make_hbox(children)
2691        }
2692        ParseNode::SupSub {
2693            base, sup, sub, ..
2694        } => {
2695            if let Some(base_node) = base.as_deref() {
2696                if should_use_op_limits(base_node, options) {
2697                    return layout_op_with_limits(base_node, sup.as_deref(), sub.as_deref(), options);
2698                }
2699            }
2700            layout_supsub(base.as_deref(), sup.as_deref(), sub.as_deref(), options, Some(font_id))
2701        }
2702        ParseNode::MathOrd { text, .. }
2703        | ParseNode::TextOrd { text, .. }
2704        | ParseNode::Atom { text, .. } => {
2705            let ch = resolve_symbol_char(text, Mode::Math);
2706            let char_code = ch as u32;
2707            if let Some(m) = get_char_metrics(font_id, char_code) {
2708                LayoutBox {
2709                    width: m.width,
2710                    height: m.height,
2711                    depth: m.depth,
2712                    content: BoxContent::Glyph { font_id, char_code },
2713                    color: options.color,
2714                }
2715            } else {
2716                // Glyph not in requested font — fall back to default math rendering
2717                layout_node(node, options)
2718            }
2719        }
2720        _ => layout_node(node, options),
2721    }
2722}
2723
2724// ============================================================================
2725// Overline / Underline
2726// ============================================================================
2727
2728fn layout_overline(body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
2729    let cramped = options.with_style(options.style.cramped());
2730    let body_box = layout_node(body, &cramped);
2731    let metrics = options.metrics();
2732    let rule = metrics.default_rule_thickness;
2733
2734    // Total height: body height + 2*rule clearance + rule thickness = body.height + 3*rule
2735    let height = body_box.height + 3.0 * rule;
2736    LayoutBox {
2737        width: body_box.width,
2738        height,
2739        depth: body_box.depth,
2740        content: BoxContent::Overline {
2741            body: Box::new(body_box),
2742            rule_thickness: rule,
2743        },
2744        color: options.color,
2745    }
2746}
2747
2748fn layout_underline(body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
2749    let body_box = layout_node(body, options);
2750    let metrics = options.metrics();
2751    let rule = metrics.default_rule_thickness;
2752
2753    // Total depth: body depth + 2*rule clearance + rule thickness = body.depth + 3*rule
2754    let depth = body_box.depth + 3.0 * rule;
2755    LayoutBox {
2756        width: body_box.width,
2757        height: body_box.height,
2758        depth,
2759        content: BoxContent::Underline {
2760            body: Box::new(body_box),
2761            rule_thickness: rule,
2762        },
2763        color: options.color,
2764    }
2765}
2766
2767/// `\href` / `\url`: link color on the glyphs and an underline in the same color (KaTeX-style).
2768fn layout_href(body: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
2769    let link_color = Color::from_name("blue").unwrap_or_else(|| Color::rgb(0.0, 0.0, 1.0));
2770    let body_opts = options.with_color(link_color);
2771    let body_box = layout_expression(body, &body_opts, true);
2772    layout_underline_laid_out(body_box, options, link_color)
2773}
2774
2775/// Same geometry as [`layout_underline`], but for an already computed inner box.
2776fn layout_underline_laid_out(body_box: LayoutBox, options: &LayoutOptions, color: Color) -> LayoutBox {
2777    let metrics = options.metrics();
2778    let rule = metrics.default_rule_thickness;
2779    let depth = body_box.depth + 3.0 * rule;
2780    LayoutBox {
2781        width: body_box.width,
2782        height: body_box.height,
2783        depth,
2784        content: BoxContent::Underline {
2785            body: Box::new(body_box),
2786            rule_thickness: rule,
2787        },
2788        color,
2789    }
2790}
2791
2792// ============================================================================
2793// Spacing commands
2794// ============================================================================
2795
2796fn layout_spacing_command(text: &str, options: &LayoutOptions) -> LayoutBox {
2797    let metrics = options.metrics();
2798    let mu = metrics.css_em_per_mu();
2799
2800    let width = match text {
2801        "\\," | "\\thinspace" => 3.0 * mu,
2802        "\\:" | "\\medspace" => 4.0 * mu,
2803        "\\;" | "\\thickspace" => 5.0 * mu,
2804        "\\!" | "\\negthinspace" => -3.0 * mu,
2805        "\\negmedspace" => -4.0 * mu,
2806        "\\negthickspace" => -5.0 * mu,
2807        " " | "~" | "\\nobreakspace" | "\\ " | "\\space" => {
2808            // KaTeX renders these by placing the U+00A0 glyph (char 160) via mathsym.
2809            // Look up its width from MainRegular; fall back to 0.25em (the font-defined value).
2810            // Literal space in `\text{ … }` becomes SpacingNode with text " ".
2811            get_char_metrics(FontId::MainRegular, 160)
2812                .map(|m| m.width)
2813                .unwrap_or(0.25)
2814        }
2815        "\\quad" => metrics.quad,
2816        "\\qquad" => 2.0 * metrics.quad,
2817        "\\enspace" => metrics.quad / 2.0,
2818        _ => 0.0,
2819    };
2820
2821    LayoutBox::new_kern(width)
2822}
2823
2824// ============================================================================
2825// Measurement conversion
2826// ============================================================================
2827
2828fn measurement_to_em(m: &ratex_parser::parse_node::Measurement, options: &LayoutOptions) -> f64 {
2829    let metrics = options.metrics();
2830    match m.unit.as_str() {
2831        "em" => m.number,
2832        "ex" => m.number * metrics.x_height,
2833        "mu" => m.number * metrics.css_em_per_mu(),
2834        "pt" => m.number / metrics.pt_per_em,
2835        "mm" => m.number * 7227.0 / 2540.0 / metrics.pt_per_em,
2836        "cm" => m.number * 7227.0 / 254.0 / metrics.pt_per_em,
2837        "in" => m.number * 72.27 / metrics.pt_per_em,
2838        "bp" => m.number * 803.0 / 800.0 / metrics.pt_per_em,
2839        "pc" => m.number * 12.0 / metrics.pt_per_em,
2840        "dd" => m.number * 1238.0 / 1157.0 / metrics.pt_per_em,
2841        "cc" => m.number * 14856.0 / 1157.0 / metrics.pt_per_em,
2842        "nd" => m.number * 685.0 / 642.0 / metrics.pt_per_em,
2843        "nc" => m.number * 1370.0 / 107.0 / metrics.pt_per_em,
2844        "sp" => m.number / 65536.0 / metrics.pt_per_em,
2845        _ => m.number,
2846    }
2847}
2848
2849// ============================================================================
2850// Math class determination
2851// ============================================================================
2852
2853/// Determine the math class of a ParseNode for spacing purposes.
2854fn node_math_class(node: &ParseNode) -> Option<MathClass> {
2855    match node {
2856        ParseNode::MathOrd { .. } | ParseNode::TextOrd { .. } => Some(MathClass::Ord),
2857        ParseNode::Atom { family, .. } => Some(family_to_math_class(*family)),
2858        ParseNode::OpToken { .. } | ParseNode::Op { .. } | ParseNode::OperatorName { .. } => Some(MathClass::Op),
2859        ParseNode::OrdGroup { .. } => Some(MathClass::Ord),
2860        ParseNode::GenFrac { .. } => Some(MathClass::Inner),
2861        ParseNode::Sqrt { .. } => Some(MathClass::Ord),
2862        ParseNode::SupSub { base, .. } => {
2863            base.as_ref().and_then(|b| node_math_class(b))
2864        }
2865        ParseNode::MClass { mclass, .. } => Some(mclass_str_to_math_class(mclass)),
2866        ParseNode::SpacingNode { .. } => None,
2867        ParseNode::Kern { .. } => None,
2868        ParseNode::HtmlMathMl { html, .. } => {
2869            // Derive math class from the first meaningful child in the HTML branch
2870            for child in html {
2871                if let Some(cls) = node_math_class(child) {
2872                    return Some(cls);
2873                }
2874            }
2875            None
2876        }
2877        ParseNode::Lap { .. } => None,
2878        ParseNode::LeftRight { .. } => Some(MathClass::Inner),
2879        ParseNode::AccentToken { .. } => Some(MathClass::Ord),
2880        // \xrightarrow etc. are mathrel in TeX/KaTeX; without this they collapse to Ord–Ord (no kern).
2881        ParseNode::XArrow { .. } => Some(MathClass::Rel),
2882        _ => Some(MathClass::Ord),
2883    }
2884}
2885
2886fn mclass_str_to_math_class(mclass: &str) -> MathClass {
2887    match mclass {
2888        "mord" => MathClass::Ord,
2889        "mop" => MathClass::Op,
2890        "mbin" => MathClass::Bin,
2891        "mrel" => MathClass::Rel,
2892        "mopen" => MathClass::Open,
2893        "mclose" => MathClass::Close,
2894        "mpunct" => MathClass::Punct,
2895        "minner" => MathClass::Inner,
2896        _ => MathClass::Ord,
2897    }
2898}
2899
2900/// Check if a ParseNode is a single character box (affects sup/sub positioning).
2901fn is_character_box(node: &ParseNode) -> bool {
2902    matches!(
2903        node,
2904        ParseNode::MathOrd { .. }
2905            | ParseNode::TextOrd { .. }
2906            | ParseNode::Atom { .. }
2907            | ParseNode::AccentToken { .. }
2908    )
2909}
2910
2911fn family_to_math_class(family: AtomFamily) -> MathClass {
2912    match family {
2913        AtomFamily::Bin => MathClass::Bin,
2914        AtomFamily::Rel => MathClass::Rel,
2915        AtomFamily::Open => MathClass::Open,
2916        AtomFamily::Close => MathClass::Close,
2917        AtomFamily::Punct => MathClass::Punct,
2918        AtomFamily::Inner => MathClass::Inner,
2919    }
2920}
2921
2922// ============================================================================
2923// Horizontal brace layout (\overbrace, \underbrace)
2924// ============================================================================
2925
2926fn layout_horiz_brace(
2927    base: &ParseNode,
2928    is_over: bool,
2929    options: &LayoutOptions,
2930) -> LayoutBox {
2931    let body_box = layout_node(base, options);
2932    let w = body_box.width.max(0.5);
2933
2934    let label = if is_over { "overbrace" } else { "underbrace" };
2935    // KaTeXSize4 brace glyphs are closed contours meant for fill (like stretchy arrows).
2936    // fill=false strokes outlines → hollow “wireframe” and exaggerated bar ends.
2937    let (raw_commands, brace_h, brace_fill) =
2938        match crate::katex_svg::katex_stretchy_path(label, w) {
2939            Some((c, h)) => (c, h, true),
2940            None => {
2941                let h = 0.35_f64;
2942                (horiz_brace_path(w, h, is_over), h, false)
2943            }
2944        };
2945
2946    // Shift y-coordinates: centered commands → positioned for over/under
2947    // For overbrace: foot at y=0 (bottom), peak goes up → shift by -brace_h/2
2948    // For underbrace: foot at y=0 (top), peak goes down → shift by +brace_h/2
2949    let y_shift = if is_over { -brace_h / 2.0 } else { brace_h / 2.0 };
2950    let commands = shift_path_y(raw_commands, y_shift);
2951
2952    let brace_box = LayoutBox {
2953        width: w,
2954        height: if is_over { brace_h } else { 0.0 },
2955        depth: if is_over { 0.0 } else { brace_h },
2956        content: BoxContent::SvgPath {
2957            commands,
2958            fill: brace_fill,
2959        },
2960        color: options.color,
2961    };
2962
2963    let gap = 0.1;
2964    let (height, depth) = if is_over {
2965        (body_box.height + brace_h + gap, body_box.depth)
2966    } else {
2967        (body_box.height, body_box.depth + brace_h + gap)
2968    };
2969
2970    let clearance = if is_over {
2971        height - brace_h
2972    } else {
2973        body_box.height + body_box.depth + gap
2974    };
2975    let total_w = body_box.width;
2976
2977    LayoutBox {
2978        width: total_w,
2979        height,
2980        depth,
2981        content: BoxContent::Accent {
2982            base: Box::new(body_box),
2983            accent: Box::new(brace_box),
2984            clearance,
2985            skew: 0.0,
2986            is_below: !is_over,
2987        },
2988        color: options.color,
2989    }
2990}
2991
2992// ============================================================================
2993// XArrow layout (\xrightarrow, \xleftarrow, etc.)
2994// ============================================================================
2995
2996fn layout_xarrow(
2997    label: &str,
2998    body: &ParseNode,
2999    below: Option<&ParseNode>,
3000    options: &LayoutOptions,
3001) -> LayoutBox {
3002    let sup_style = options.style.superscript();
3003    let sub_style = options.style.subscript();
3004    let sup_ratio = sup_style.size_multiplier() / options.style.size_multiplier();
3005    let sub_ratio = sub_style.size_multiplier() / options.style.size_multiplier();
3006
3007    let sup_opts = options.with_style(sup_style);
3008    let body_box = layout_node(body, &sup_opts);
3009    let body_w = body_box.width * sup_ratio;
3010
3011    let below_box = below.map(|b| {
3012        let sub_opts = options.with_style(sub_style);
3013        layout_node(b, &sub_opts)
3014    });
3015    let below_w = below_box
3016        .as_ref()
3017        .map(|b| b.width * sub_ratio)
3018        .unwrap_or(0.0);
3019
3020    // KaTeX `katexImagesData` minWidth on the stretchy SVG, plus `.x-arrow-pad { padding: 0 0.5em }`
3021    // on each label row (em = that row's font). In parent em: +0.5·sup_ratio + 0.5·sup_ratio, etc.
3022    let min_w = crate::katex_svg::katex_stretchy_min_width_em(label).unwrap_or(1.0);
3023    let upper_w = body_w + sup_ratio;
3024    let lower_w = if below_box.is_some() {
3025        below_w + sub_ratio
3026    } else {
3027        0.0
3028    };
3029    let arrow_w = upper_w.max(lower_w).max(min_w);
3030    let arrow_h = 0.3;
3031
3032    let (commands, actual_arrow_h, fill_arrow) =
3033        match crate::katex_svg::katex_stretchy_path(label, arrow_w) {
3034            Some((c, h)) => (c, h, true),
3035            None => (
3036                stretchy_accent_path(label, arrow_w, arrow_h),
3037                arrow_h,
3038                label == "\\xtwoheadrightarrow" || label == "\\xtwoheadleftarrow",
3039            ),
3040        };
3041    let arrow_box = LayoutBox {
3042        width: arrow_w,
3043        height: actual_arrow_h / 2.0,
3044        depth: actual_arrow_h / 2.0,
3045        content: BoxContent::SvgPath {
3046            commands,
3047            fill: fill_arrow,
3048        },
3049        color: options.color,
3050    };
3051
3052    // KaTeX positions xarrows centered on the math axis, with a 0.111em (2mu) gap
3053    // between the arrow and the text above/below (see amsmath.dtx reference).
3054    let metrics = options.metrics();
3055    let axis = metrics.axis_height;        // 0.25em
3056    let arrow_half = actual_arrow_h / 2.0;
3057    let gap = 0.111;                       // 2mu gap (KaTeX constant)
3058
3059    // Center the arrow on the math axis by shifting it up.
3060    let base_shift = -axis;
3061
3062    // sup_kern: gap between arrow top and text bottom.
3063    // In the OpLimits renderer:
3064    //   sup_y = y - (arrow_half - base_shift) - sup_kern - sup_box.depth * ratio
3065    //         = y - (arrow_half + axis) - sup_kern - sup_box.depth * ratio
3066    // KaTeX: text_baseline = -(axis + arrow_half + gap)
3067    //   (with extra -= depth when depth > 0.25, but that's rare for typical text)
3068    // Matching: sup_kern = gap
3069    let sup_kern = gap;
3070    let sub_kern = gap;
3071
3072    let sup_h = body_box.height * sup_ratio;
3073    let sup_d = body_box.depth * sup_ratio;
3074
3075    // Height: from baseline to top of upper text
3076    let height = axis + arrow_half + gap + sup_h + sup_d;
3077    // Depth: arrow bottom below baseline = arrow_half - axis
3078    let mut depth = (arrow_half - axis).max(0.0);
3079
3080    if let Some(ref bel) = below_box {
3081        let sub_h = bel.height * sub_ratio;
3082        let sub_d = bel.depth * sub_ratio;
3083        // Lower text positioned symmetrically below the arrow
3084        depth = (arrow_half - axis) + gap + sub_h + sub_d;
3085    }
3086
3087    LayoutBox {
3088        width: arrow_w,
3089        height,
3090        depth,
3091        content: BoxContent::OpLimits {
3092            base: Box::new(arrow_box),
3093            sup: Some(Box::new(body_box)),
3094            sub: below_box.map(Box::new),
3095            base_shift,
3096            sup_kern,
3097            sub_kern,
3098            slant: 0.0,
3099            sup_scale: sup_ratio,
3100            sub_scale: sub_ratio,
3101        },
3102        color: options.color,
3103    }
3104}
3105
3106// ============================================================================
3107// \textcircled layout
3108// ============================================================================
3109
3110fn layout_textcircled(body_box: LayoutBox, options: &LayoutOptions) -> LayoutBox {
3111    // Draw a circle around the content, similar to KaTeX's CSS-based approach
3112    let pad = 0.1_f64; // padding around the content
3113    let total_h = body_box.height + body_box.depth;
3114    let radius = (body_box.width.max(total_h) / 2.0 + pad).max(0.35);
3115    let diameter = radius * 2.0;
3116
3117    // Build a circle path using cubic Bezier approximation
3118    let cx = radius;
3119    let cy = -(body_box.height - total_h / 2.0); // center at vertical center of content
3120    let k = 0.5523; // cubic Bezier approximation of circle: 4*(sqrt(2)-1)/3
3121    let r = radius;
3122
3123    let circle_commands = vec![
3124        PathCommand::MoveTo { x: cx + r, y: cy },
3125        PathCommand::CubicTo {
3126            x1: cx + r, y1: cy - k * r,
3127            x2: cx + k * r, y2: cy - r,
3128            x: cx, y: cy - r,
3129        },
3130        PathCommand::CubicTo {
3131            x1: cx - k * r, y1: cy - r,
3132            x2: cx - r, y2: cy - k * r,
3133            x: cx - r, y: cy,
3134        },
3135        PathCommand::CubicTo {
3136            x1: cx - r, y1: cy + k * r,
3137            x2: cx - k * r, y2: cy + r,
3138            x: cx, y: cy + r,
3139        },
3140        PathCommand::CubicTo {
3141            x1: cx + k * r, y1: cy + r,
3142            x2: cx + r, y2: cy + k * r,
3143            x: cx + r, y: cy,
3144        },
3145        PathCommand::Close,
3146    ];
3147
3148    let circle_box = LayoutBox {
3149        width: diameter,
3150        height: r - cy.min(0.0),
3151        depth: (r + cy).max(0.0),
3152        content: BoxContent::SvgPath { commands: circle_commands, fill: false },
3153        color: options.color,
3154    };
3155
3156    // Center the content inside the circle
3157    let content_shift = (diameter - body_box.width) / 2.0;
3158    // Shift content to the right to center it
3159    let children = vec![
3160        circle_box,
3161        LayoutBox::new_kern(-(diameter) + content_shift),
3162        body_box.clone(),
3163    ];
3164
3165    let height = r - cy.min(0.0);
3166    let depth = (r + cy).max(0.0);
3167
3168    LayoutBox {
3169        width: diameter,
3170        height,
3171        depth,
3172        content: BoxContent::HBox(children),
3173        color: options.color,
3174    }
3175}
3176
3177// ============================================================================
3178// Path generation helpers
3179// ============================================================================
3180
3181/// Build path commands for a horizontal ellipse (circle overlay for \oiint, \oiiint).
3182/// Box-local coords: origin at baseline-left, x right, y down (positive = below baseline).
3183/// Ellipse is centered in the box and spans most of the integral width.
3184fn ellipse_overlay_path(width: f64, height: f64, depth: f64) -> Vec<PathCommand> {
3185    let cx = width / 2.0;
3186    let cy = (depth - height) / 2.0; // vertical center
3187    let a = width * 0.402_f64; // horizontal semi-axis (0.36 * 1.2)
3188    let b = 0.3_f64;          // vertical semi-axis (0.1 * 2)
3189    let k = 0.62_f64;          // Bezier factor: larger = fuller ellipse (0.5523 ≈ exact circle)
3190    vec![
3191        PathCommand::MoveTo { x: cx + a, y: cy },
3192        PathCommand::CubicTo {
3193            x1: cx + a,
3194            y1: cy - k * b,
3195            x2: cx + k * a,
3196            y2: cy - b,
3197            x: cx,
3198            y: cy - b,
3199        },
3200        PathCommand::CubicTo {
3201            x1: cx - k * a,
3202            y1: cy - b,
3203            x2: cx - a,
3204            y2: cy - k * b,
3205            x: cx - a,
3206            y: cy,
3207        },
3208        PathCommand::CubicTo {
3209            x1: cx - a,
3210            y1: cy + k * b,
3211            x2: cx - k * a,
3212            y2: cy + b,
3213            x: cx,
3214            y: cy + b,
3215        },
3216        PathCommand::CubicTo {
3217            x1: cx + k * a,
3218            y1: cy + b,
3219            x2: cx + a,
3220            y2: cy + k * b,
3221            x: cx + a,
3222            y: cy,
3223        },
3224        PathCommand::Close,
3225    ]
3226}
3227
3228fn shift_path_y(cmds: Vec<PathCommand>, dy: f64) -> Vec<PathCommand> {
3229    cmds.into_iter().map(|c| match c {
3230        PathCommand::MoveTo { x, y } => PathCommand::MoveTo { x, y: y + dy },
3231        PathCommand::LineTo { x, y } => PathCommand::LineTo { x, y: y + dy },
3232        PathCommand::CubicTo { x1, y1, x2, y2, x, y } => PathCommand::CubicTo {
3233            x1, y1: y1 + dy, x2, y2: y2 + dy, x, y: y + dy,
3234        },
3235        PathCommand::QuadTo { x1, y1, x, y } => PathCommand::QuadTo {
3236            x1, y1: y1 + dy, x, y: y + dy,
3237        },
3238        PathCommand::Close => PathCommand::Close,
3239    }).collect()
3240}
3241
3242fn stretchy_accent_path(label: &str, width: f64, height: f64) -> Vec<PathCommand> {
3243    if let Some(commands) = crate::katex_svg::katex_stretchy_arrow_path(label, width, height) {
3244        return commands;
3245    }
3246    let ah = height * 0.35; // arrowhead size
3247    let mid_y = -height / 2.0;
3248
3249    match label {
3250        "\\overleftarrow" | "\\underleftarrow" | "\\xleftarrow" | "\\xLeftarrow" => {
3251            vec![
3252                PathCommand::MoveTo { x: ah, y: mid_y - ah },
3253                PathCommand::LineTo { x: 0.0, y: mid_y },
3254                PathCommand::LineTo { x: ah, y: mid_y + ah },
3255                PathCommand::MoveTo { x: 0.0, y: mid_y },
3256                PathCommand::LineTo { x: width, y: mid_y },
3257            ]
3258        }
3259        "\\overleftrightarrow" | "\\underleftrightarrow"
3260        | "\\xleftrightarrow" | "\\xLeftrightarrow" => {
3261            vec![
3262                PathCommand::MoveTo { x: ah, y: mid_y - ah },
3263                PathCommand::LineTo { x: 0.0, y: mid_y },
3264                PathCommand::LineTo { x: ah, y: mid_y + ah },
3265                PathCommand::MoveTo { x: 0.0, y: mid_y },
3266                PathCommand::LineTo { x: width, y: mid_y },
3267                PathCommand::MoveTo { x: width - ah, y: mid_y - ah },
3268                PathCommand::LineTo { x: width, y: mid_y },
3269                PathCommand::LineTo { x: width - ah, y: mid_y + ah },
3270            ]
3271        }
3272        "\\xlongequal" => {
3273            let gap = 0.04;
3274            vec![
3275                PathCommand::MoveTo { x: 0.0, y: mid_y - gap },
3276                PathCommand::LineTo { x: width, y: mid_y - gap },
3277                PathCommand::MoveTo { x: 0.0, y: mid_y + gap },
3278                PathCommand::LineTo { x: width, y: mid_y + gap },
3279            ]
3280        }
3281        "\\xhookleftarrow" => {
3282            vec![
3283                PathCommand::MoveTo { x: ah, y: mid_y - ah },
3284                PathCommand::LineTo { x: 0.0, y: mid_y },
3285                PathCommand::LineTo { x: ah, y: mid_y + ah },
3286                PathCommand::MoveTo { x: 0.0, y: mid_y },
3287                PathCommand::LineTo { x: width, y: mid_y },
3288                PathCommand::QuadTo { x1: width + ah, y1: mid_y, x: width + ah, y: mid_y + ah },
3289            ]
3290        }
3291        "\\xhookrightarrow" => {
3292            vec![
3293                PathCommand::MoveTo { x: 0.0 - ah, y: mid_y - ah },
3294                PathCommand::QuadTo { x1: 0.0 - ah, y1: mid_y, x: 0.0, y: mid_y },
3295                PathCommand::LineTo { x: width, y: mid_y },
3296                PathCommand::MoveTo { x: width - ah, y: mid_y - ah },
3297                PathCommand::LineTo { x: width, y: mid_y },
3298                PathCommand::LineTo { x: width - ah, y: mid_y + ah },
3299            ]
3300        }
3301        "\\xrightharpoonup" | "\\xleftharpoonup" => {
3302            let right = label.contains("right");
3303            if right {
3304                vec![
3305                    PathCommand::MoveTo { x: 0.0, y: mid_y },
3306                    PathCommand::LineTo { x: width, y: mid_y },
3307                    PathCommand::MoveTo { x: width - ah, y: mid_y - ah },
3308                    PathCommand::LineTo { x: width, y: mid_y },
3309                ]
3310            } else {
3311                vec![
3312                    PathCommand::MoveTo { x: ah, y: mid_y - ah },
3313                    PathCommand::LineTo { x: 0.0, y: mid_y },
3314                    PathCommand::LineTo { x: width, y: mid_y },
3315                ]
3316            }
3317        }
3318        "\\xrightharpoondown" | "\\xleftharpoondown" => {
3319            let right = label.contains("right");
3320            if right {
3321                vec![
3322                    PathCommand::MoveTo { x: 0.0, y: mid_y },
3323                    PathCommand::LineTo { x: width, y: mid_y },
3324                    PathCommand::MoveTo { x: width - ah, y: mid_y + ah },
3325                    PathCommand::LineTo { x: width, y: mid_y },
3326                ]
3327            } else {
3328                vec![
3329                    PathCommand::MoveTo { x: ah, y: mid_y + ah },
3330                    PathCommand::LineTo { x: 0.0, y: mid_y },
3331                    PathCommand::LineTo { x: width, y: mid_y },
3332                ]
3333            }
3334        }
3335        "\\xrightleftharpoons" | "\\xleftrightharpoons" => {
3336            let gap = 0.06;
3337            vec![
3338                PathCommand::MoveTo { x: 0.0, y: mid_y - gap },
3339                PathCommand::LineTo { x: width, y: mid_y - gap },
3340                PathCommand::MoveTo { x: width - ah, y: mid_y - gap - ah },
3341                PathCommand::LineTo { x: width, y: mid_y - gap },
3342                PathCommand::MoveTo { x: width, y: mid_y + gap },
3343                PathCommand::LineTo { x: 0.0, y: mid_y + gap },
3344                PathCommand::MoveTo { x: ah, y: mid_y + gap + ah },
3345                PathCommand::LineTo { x: 0.0, y: mid_y + gap },
3346            ]
3347        }
3348        "\\xtofrom" | "\\xrightleftarrows" => {
3349            let gap = 0.06;
3350            vec![
3351                PathCommand::MoveTo { x: 0.0, y: mid_y - gap },
3352                PathCommand::LineTo { x: width, y: mid_y - gap },
3353                PathCommand::MoveTo { x: width - ah, y: mid_y - gap - ah },
3354                PathCommand::LineTo { x: width, y: mid_y - gap },
3355                PathCommand::LineTo { x: width - ah, y: mid_y - gap + ah },
3356                PathCommand::MoveTo { x: width, y: mid_y + gap },
3357                PathCommand::LineTo { x: 0.0, y: mid_y + gap },
3358                PathCommand::MoveTo { x: ah, y: mid_y + gap - ah },
3359                PathCommand::LineTo { x: 0.0, y: mid_y + gap },
3360                PathCommand::LineTo { x: ah, y: mid_y + gap + ah },
3361            ]
3362        }
3363        "\\overlinesegment" | "\\underlinesegment" => {
3364            vec![
3365                PathCommand::MoveTo { x: 0.0, y: mid_y },
3366                PathCommand::LineTo { x: width, y: mid_y },
3367            ]
3368        }
3369        _ => {
3370            vec![
3371                PathCommand::MoveTo { x: 0.0, y: mid_y },
3372                PathCommand::LineTo { x: width, y: mid_y },
3373                PathCommand::MoveTo { x: width - ah, y: mid_y - ah },
3374                PathCommand::LineTo { x: width, y: mid_y },
3375                PathCommand::LineTo { x: width - ah, y: mid_y + ah },
3376            ]
3377        }
3378    }
3379}
3380
3381fn horiz_brace_path(width: f64, height: f64, is_over: bool) -> Vec<PathCommand> {
3382    let mid = width / 2.0;
3383    let q = height * 0.6;
3384    if is_over {
3385        vec![
3386            PathCommand::MoveTo { x: 0.0, y: 0.0 },
3387            PathCommand::QuadTo { x1: 0.0, y1: -q, x: mid * 0.4, y: -q },
3388            PathCommand::LineTo { x: mid - 0.05, y: -q },
3389            PathCommand::LineTo { x: mid, y: -height },
3390            PathCommand::LineTo { x: mid + 0.05, y: -q },
3391            PathCommand::LineTo { x: width - mid * 0.4, y: -q },
3392            PathCommand::QuadTo { x1: width, y1: -q, x: width, y: 0.0 },
3393        ]
3394    } else {
3395        vec![
3396            PathCommand::MoveTo { x: 0.0, y: 0.0 },
3397            PathCommand::QuadTo { x1: 0.0, y1: q, x: mid * 0.4, y: q },
3398            PathCommand::LineTo { x: mid - 0.05, y: q },
3399            PathCommand::LineTo { x: mid, y: height },
3400            PathCommand::LineTo { x: mid + 0.05, y: q },
3401            PathCommand::LineTo { x: width - mid * 0.4, y: q },
3402            PathCommand::QuadTo { x1: width, y1: q, x: width, y: 0.0 },
3403        ]
3404    }
3405}