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