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;
10
11use crate::katex_svg::parse_svg_path_data;
12use crate::spacing::{atom_spacing, mu_to_em, MathClass};
13use crate::stacked_delim::make_stacked_delim_if_needed;
14
15/// TeX `\nulldelimiterspace` = 1.2pt = 0.12em (at 10pt design size).
16/// KaTeX wraps every `\frac` / `\atop` in mopen+mclose nulldelimiter spans of this width.
17const NULL_DELIMITER_SPACE: f64 = 0.12;
18
19/// Main entry point: lay out a list of ParseNodes into a LayoutBox.
20pub fn layout(nodes: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
21    layout_expression(nodes, options, true)
22}
23
24/// KaTeX `binLeftCanceller` / `binRightCanceller` (TeXbook p.442–446, Rules 5–6).
25/// Binary operators become ordinary in certain contexts so spacing matches TeX/KaTeX.
26fn apply_bin_cancellation(raw: &[Option<MathClass>]) -> Vec<Option<MathClass>> {
27    let n = raw.len();
28    let mut eff = raw.to_vec();
29    for i in 0..n {
30        if raw[i] != Some(MathClass::Bin) {
31            continue;
32        }
33        let prev = if i == 0 { None } else { raw[i - 1] };
34        let left_cancel = matches!(
35            prev,
36            None
37                | Some(MathClass::Bin)
38                | Some(MathClass::Open)
39                | Some(MathClass::Rel)
40                | Some(MathClass::Op)
41                | Some(MathClass::Punct)
42        );
43        if left_cancel {
44            eff[i] = Some(MathClass::Ord);
45        }
46    }
47    for i in 0..n {
48        if raw[i] != Some(MathClass::Bin) {
49            continue;
50        }
51        let next = if i + 1 < n { raw[i + 1] } else { None };
52        let right_cancel = matches!(
53            next,
54            None | Some(MathClass::Rel) | Some(MathClass::Close) | Some(MathClass::Punct)
55        );
56        if right_cancel {
57            eff[i] = Some(MathClass::Ord);
58        }
59    }
60    eff
61}
62
63/// KaTeX HTML: `\middle` delimiters are built with class `delimsizing`, which
64/// `getTypeOfDomTree` does not map to a math atom type, so **no** implicit
65/// table glue is inserted next to them (buildHTML.js). RaTeX must match that or
66/// `\frac` (Inner) gains spurious 3mu on each side of every `\middle\vert`.
67fn node_is_middle_fence(node: &ParseNode) -> bool {
68    matches!(node, ParseNode::Middle { .. })
69}
70
71/// Lay out an expression (list of nodes) as a horizontal sequence with spacing.
72fn layout_expression(
73    nodes: &[ParseNode],
74    options: &LayoutOptions,
75    is_real_group: bool,
76) -> LayoutBox {
77    if nodes.is_empty() {
78        return LayoutBox::new_empty();
79    }
80
81    // Check for line breaks (\\, \newline) — split into rows stacked in a VBox
82    let has_cr = nodes.iter().any(|n| matches!(n, ParseNode::Cr { .. }));
83    if has_cr {
84        return layout_multiline(nodes, options, is_real_group);
85    }
86
87    let raw_classes: Vec<Option<MathClass>> =
88        nodes.iter().map(node_math_class).collect();
89    let eff_classes = apply_bin_cancellation(&raw_classes);
90
91    let mut children = Vec::new();
92    let mut prev_class: Option<MathClass> = None;
93    // Index of the last node that contributed `prev_class` (for `\middle` glue suppression).
94    let mut prev_class_node_idx: Option<usize> = None;
95
96    for (i, node) in nodes.iter().enumerate() {
97        let lbox = layout_node(node, options);
98        let cur_class = eff_classes.get(i).copied().flatten();
99
100        if is_real_group {
101            if let (Some(prev), Some(cur)) = (prev_class, cur_class) {
102                let prev_middle = prev_class_node_idx
103                    .is_some_and(|j| node_is_middle_fence(&nodes[j]));
104                let cur_middle = node_is_middle_fence(node);
105                let mu = if prev_middle || cur_middle {
106                    0.0
107                } else {
108                    atom_spacing(prev, cur, options.style.is_tight())
109                };
110                let mu = if let Some(cap) = options.align_relation_spacing {
111                    if prev == MathClass::Rel || cur == MathClass::Rel {
112                        mu.min(cap)
113                    } else {
114                        mu
115                    }
116                } else {
117                    mu
118                };
119                if mu > 0.0 {
120                    let em = mu_to_em(mu, options.metrics().quad);
121                    children.push(LayoutBox::new_kern(em));
122                }
123            }
124        }
125
126        if cur_class.is_some() {
127            prev_class = cur_class;
128            prev_class_node_idx = Some(i);
129        }
130
131        children.push(lbox);
132    }
133
134    make_hbox(children)
135}
136
137/// Layout an expression containing line-break nodes (\\, \newline) as a VBox.
138fn layout_multiline(
139    nodes: &[ParseNode],
140    options: &LayoutOptions,
141    is_real_group: bool,
142) -> LayoutBox {
143    use crate::layout_box::{BoxContent, VBoxChild, VBoxChildKind};
144    let metrics = options.metrics();
145    let pt = 1.0 / metrics.pt_per_em;
146    let baselineskip = 12.0 * pt; // standard TeX baselineskip
147    let lineskip = 1.0 * pt; // minimum gap between lines
148
149    // Split nodes at Cr boundaries
150    let mut rows: Vec<&[ParseNode]> = Vec::new();
151    let mut start = 0;
152    for (i, node) in nodes.iter().enumerate() {
153        if matches!(node, ParseNode::Cr { .. }) {
154            rows.push(&nodes[start..i]);
155            start = i + 1;
156        }
157    }
158    rows.push(&nodes[start..]);
159
160    let row_boxes: Vec<LayoutBox> = rows
161        .iter()
162        .map(|row| layout_expression(row, options, is_real_group))
163        .collect();
164
165    let total_width = row_boxes.iter().map(|b| b.width).fold(0.0_f64, f64::max);
166
167    let mut vchildren: Vec<VBoxChild> = Vec::new();
168    let mut h = row_boxes.first().map(|b| b.height).unwrap_or(0.0);
169    let d = row_boxes.last().map(|b| b.depth).unwrap_or(0.0);
170    for (i, row) in row_boxes.iter().enumerate() {
171        if i > 0 {
172            // TeX baselineskip: gap = baselineskip - prev_depth - cur_height
173            let prev_depth = row_boxes[i - 1].depth;
174            let gap = (baselineskip - prev_depth - row.height).max(lineskip);
175            vchildren.push(VBoxChild { kind: VBoxChildKind::Kern(gap), shift: 0.0 });
176            h += gap + row.height + prev_depth;
177        }
178        vchildren.push(VBoxChild {
179            kind: VBoxChildKind::Box(Box::new(row.clone())),
180            shift: 0.0,
181        });
182    }
183
184    LayoutBox {
185        width: total_width,
186        height: h,
187        depth: d,
188        content: BoxContent::VBox(vchildren),
189        color: options.color,
190    }
191}
192
193
194/// Lay out a single ParseNode.
195fn layout_node(node: &ParseNode, options: &LayoutOptions) -> LayoutBox {
196    match node {
197        ParseNode::MathOrd { text, mode, .. } => layout_symbol(text, *mode, options),
198        ParseNode::TextOrd { text, mode, .. } => layout_symbol(text, *mode, options),
199        ParseNode::Atom { text, mode, .. } => layout_symbol(text, *mode, options),
200        ParseNode::OpToken { text, mode, .. } => layout_symbol(text, *mode, options),
201
202        ParseNode::OrdGroup { body, .. } => layout_expression(body, options, true),
203
204        ParseNode::SupSub {
205            base, sup, sub, ..
206        } => {
207            if let Some(base_node) = base.as_deref() {
208                if should_use_op_limits(base_node, options) {
209                    return layout_op_with_limits(base_node, sup.as_deref(), sub.as_deref(), options);
210                }
211            }
212            layout_supsub(base.as_deref(), sup.as_deref(), sub.as_deref(), options, None)
213        }
214
215        ParseNode::GenFrac {
216            numer,
217            denom,
218            has_bar_line,
219            bar_size,
220            left_delim,
221            right_delim,
222            continued,
223            ..
224        } => {
225            let bar_thickness = if *has_bar_line {
226                bar_size
227                    .as_ref()
228                    .map(|m| measurement_to_em(m, options))
229                    .unwrap_or(options.metrics().default_rule_thickness)
230            } else {
231                0.0
232            };
233            let frac = layout_fraction(numer, denom, bar_thickness, *continued, options);
234
235            let has_left = left_delim.as_ref().is_some_and(|d| !d.is_empty() && d != ".");
236            let has_right = right_delim.as_ref().is_some_and(|d| !d.is_empty() && d != ".");
237
238            if has_left || has_right {
239                let total_h = genfrac_delim_target_height(options);
240                let left_d = left_delim.as_deref().unwrap_or(".");
241                let right_d = right_delim.as_deref().unwrap_or(".");
242                let left_box = make_stretchy_delim(left_d, total_h, options);
243                let right_box = make_stretchy_delim(right_d, total_h, options);
244
245                let width = left_box.width + frac.width + right_box.width;
246                let height = frac.height.max(left_box.height).max(right_box.height);
247                let depth = frac.depth.max(left_box.depth).max(right_box.depth);
248
249                LayoutBox {
250                    width,
251                    height,
252                    depth,
253                    content: BoxContent::LeftRight {
254                        left: Box::new(left_box),
255                        right: Box::new(right_box),
256                        inner: Box::new(frac),
257                    },
258                    color: options.color,
259                }
260            } else {
261                let right_nds = if *continued { 0.0 } else { NULL_DELIMITER_SPACE };
262                make_hbox(vec![
263                    LayoutBox::new_kern(NULL_DELIMITER_SPACE),
264                    frac,
265                    LayoutBox::new_kern(right_nds),
266                ])
267            }
268        }
269
270        ParseNode::Sqrt { body, index, .. } => {
271            layout_radical(body, index.as_deref(), options)
272        }
273
274        ParseNode::Op {
275            name,
276            symbol,
277            body,
278            limits,
279            suppress_base_shift,
280            ..
281        } => layout_op(
282            name.as_deref(),
283            *symbol,
284            body.as_deref(),
285            *limits,
286            suppress_base_shift.unwrap_or(false),
287            options,
288        ),
289
290        ParseNode::OperatorName { body, .. } => layout_operatorname(body, options),
291
292        ParseNode::SpacingNode { text, .. } => layout_spacing_command(text, options),
293
294        ParseNode::Kern { dimension, .. } => {
295            let em = measurement_to_em(dimension, options);
296            LayoutBox::new_kern(em)
297        }
298
299        ParseNode::Color { color, body, .. } => {
300            let new_color = Color::parse(color).unwrap_or(options.color);
301            let new_opts = options.with_color(new_color);
302            let mut lbox = layout_expression(body, &new_opts, true);
303            lbox.color = new_color;
304            lbox
305        }
306
307        ParseNode::Styling { style, body, .. } => {
308            let new_style = match style {
309                ratex_parser::parse_node::StyleStr::Display => MathStyle::Display,
310                ratex_parser::parse_node::StyleStr::Text => MathStyle::Text,
311                ratex_parser::parse_node::StyleStr::Script => MathStyle::Script,
312                ratex_parser::parse_node::StyleStr::Scriptscript => MathStyle::ScriptScript,
313            };
314            let ratio = new_style.size_multiplier() / options.style.size_multiplier();
315            let new_opts = options.with_style(new_style);
316            let inner = layout_expression(body, &new_opts, true);
317            if (ratio - 1.0).abs() < 0.001 {
318                inner
319            } else {
320                LayoutBox {
321                    width: inner.width * ratio,
322                    height: inner.height * ratio,
323                    depth: inner.depth * ratio,
324                    content: BoxContent::Scaled {
325                        body: Box::new(inner),
326                        child_scale: ratio,
327                    },
328                    color: options.color,
329                }
330            }
331        }
332
333        ParseNode::Accent {
334            label, base, is_stretchy, is_shifty, ..
335        } => {
336            // Some text accents (e.g. \c cedilla) place the mark below
337            let is_below = matches!(label.as_str(), "\\c");
338            layout_accent(label, base, is_stretchy.unwrap_or(false), is_shifty.unwrap_or(false), is_below, options)
339        }
340
341        ParseNode::AccentUnder {
342            label, base, is_stretchy, ..
343        } => layout_accent(label, base, is_stretchy.unwrap_or(false), false, true, options),
344
345        ParseNode::LeftRight {
346            body, left, right, ..
347        } => layout_left_right(body, left, right, options),
348
349        ParseNode::DelimSizing {
350            size, delim, ..
351        } => layout_delim_sizing(*size, delim, options),
352
353        ParseNode::Array {
354            body,
355            cols,
356            arraystretch,
357            add_jot,
358            row_gaps,
359            hlines_before_row,
360            col_separation_type,
361            hskip_before_and_after,
362            is_cd,
363            ..
364        } => {
365            if is_cd.unwrap_or(false) {
366                layout_cd(body, options)
367            } else {
368                layout_array(
369                    body,
370                    cols.as_deref(),
371                    *arraystretch,
372                    add_jot.unwrap_or(false),
373                    row_gaps,
374                    hlines_before_row,
375                    col_separation_type.as_deref(),
376                    hskip_before_and_after.unwrap_or(false),
377                    options,
378                )
379            }
380        }
381
382        ParseNode::CdArrow {
383            direction,
384            label_above,
385            label_below,
386            ..
387        } => layout_cd_arrow(direction, label_above.as_deref(), label_below.as_deref(), 0.0, 0.0, 0.0, options),
388
389        ParseNode::Sizing { size, body, .. } => layout_sizing(*size, body, options),
390
391        ParseNode::Text { body, font, mode, .. } => match font.as_deref() {
392            Some(f) => {
393                let group = ParseNode::OrdGroup {
394                    mode: *mode,
395                    body: body.clone(),
396                    semisimple: None,
397                    loc: None,
398                };
399                layout_font(f, &group, options)
400            }
401            None => layout_text(body, options),
402        },
403
404        ParseNode::Font { font, body, .. } => layout_font(font, body, options),
405
406        ParseNode::Href { body, .. } => layout_href(body, options),
407
408        ParseNode::Overline { body, .. } => layout_overline(body, options),
409        ParseNode::Underline { body, .. } => layout_underline(body, options),
410
411        ParseNode::Rule {
412            width: w,
413            height: h,
414            shift,
415            ..
416        } => {
417            let width = measurement_to_em(w, options);
418            let ink_h = measurement_to_em(h, options);
419            let raise = shift
420                .as_ref()
421                .map(|s| measurement_to_em(s, options))
422                .unwrap_or(0.0);
423            let box_height = (raise + ink_h).max(0.0);
424            let box_depth = (-raise).max(0.0);
425            LayoutBox::new_rule(width, box_height, box_depth, ink_h, raise)
426        }
427
428        ParseNode::Phantom { body, .. } => {
429            let inner = layout_expression(body, options, true);
430            LayoutBox {
431                width: inner.width,
432                height: inner.height,
433                depth: inner.depth,
434                content: BoxContent::Empty,
435                color: Color::BLACK,
436            }
437        }
438
439        ParseNode::VPhantom { body, .. } => {
440            let inner = layout_node(body, options);
441            LayoutBox {
442                width: 0.0,
443                height: inner.height,
444                depth: inner.depth,
445                content: BoxContent::Empty,
446                color: Color::BLACK,
447            }
448        }
449
450        ParseNode::Smash { body, smash_height, smash_depth, .. } => {
451            let mut inner = layout_node(body, options);
452            if *smash_height { inner.height = 0.0; }
453            if *smash_depth { inner.depth = 0.0; }
454            inner
455        }
456
457        ParseNode::Middle { delim, .. } => {
458            match options.leftright_delim_height {
459                Some(h) => make_stretchy_delim(delim, h, options),
460                None => {
461                    // First pass inside \left...\right: reserve width but don't affect inner height.
462                    let placeholder = make_stretchy_delim(delim, 1.0, options);
463                    LayoutBox {
464                        width: placeholder.width,
465                        height: 0.0,
466                        depth: 0.0,
467                        content: BoxContent::Empty,
468                        color: options.color,
469                    }
470                }
471            }
472        }
473
474        ParseNode::HtmlMathMl { html, .. } => {
475            layout_expression(html, options, true)
476        }
477
478        ParseNode::MClass { body, .. } => layout_expression(body, options, true),
479
480        ParseNode::MathChoice {
481            display, text, script, scriptscript, ..
482        } => {
483            let branch = match options.style {
484                MathStyle::Display | MathStyle::DisplayCramped => display,
485                MathStyle::Text | MathStyle::TextCramped => text,
486                MathStyle::Script | MathStyle::ScriptCramped => script,
487                MathStyle::ScriptScript | MathStyle::ScriptScriptCramped => scriptscript,
488            };
489            layout_expression(branch, options, true)
490        }
491
492        ParseNode::Lap { alignment, body, .. } => {
493            let inner = layout_node(body, options);
494            let shift = match alignment.as_str() {
495                "llap" => -inner.width,
496                "clap" => -inner.width / 2.0,
497                _ => 0.0, // rlap: no shift
498            };
499            let mut children = Vec::new();
500            if shift != 0.0 {
501                children.push(LayoutBox::new_kern(shift));
502            }
503            let h = inner.height;
504            let d = inner.depth;
505            children.push(inner);
506            LayoutBox {
507                width: 0.0,
508                height: h,
509                depth: d,
510                content: BoxContent::HBox(children),
511                color: options.color,
512            }
513        }
514
515        ParseNode::HorizBrace {
516            base,
517            is_over,
518            label,
519            ..
520        } => layout_horiz_brace(base, *is_over, label, options),
521
522        ParseNode::XArrow {
523            label, body, below, ..
524        } => layout_xarrow(label, body, below.as_deref(), options),
525
526        ParseNode::Pmb { body, .. } => layout_pmb(body, options),
527
528        ParseNode::HBox { body, .. } => layout_text(body, options),
529
530        ParseNode::Enclose { label, background_color, border_color, body, .. } => {
531            layout_enclose(label, background_color.as_deref(), border_color.as_deref(), body, options)
532        }
533
534        ParseNode::RaiseBox { dy, body, .. } => {
535            let shift = measurement_to_em(dy, options);
536            layout_raisebox(shift, body, options)
537        }
538
539        ParseNode::VCenter { body, .. } => {
540            // Vertically center on the math axis
541            let inner = layout_node(body, options);
542            let axis = options.metrics().axis_height;
543            let total = inner.height + inner.depth;
544            let height = total / 2.0 + axis;
545            let depth = total - height;
546            LayoutBox {
547                width: inner.width,
548                height,
549                depth,
550                content: inner.content,
551                color: inner.color,
552            }
553        }
554
555        ParseNode::Verb { body, star, .. } => layout_verb(body, *star, options),
556
557        // Fallback for unhandled node types: produce empty box
558        _ => LayoutBox::new_empty(),
559    }
560}
561
562// ============================================================================
563// Symbol layout
564// ============================================================================
565
566/// Advance width for glyphs missing from bundled KaTeX fonts (e.g. CJK in `\text{…}`).
567///
568/// The placeholder width must match what system font fallback draws at ~1em: using 0.5em
569/// collapses.advance and Core Text / platform rasterizers still paint a full-width ideograph,
570/// so neighbors overlap and the row looks "too large" / clipped.
571fn missing_glyph_width_em(ch: char) -> f64 {
572    match ch as u32 {
573        // Hiragana / Katakana
574        0x3040..=0x30FF | 0x31F0..=0x31FF => 1.0,
575        // CJK Unified + extension / compatibility ideographs
576        0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF => 1.0,
577        // Hangul syllables
578        0xAC00..=0xD7AF => 1.0,
579        // Fullwidth ASCII, punctuation, currency
580        0xFF01..=0xFF60 | 0xFFE0..=0xFFEE => 1.0,
581        _ => 0.5,
582    }
583}
584
585fn missing_glyph_metrics_fallback(ch: char, options: &LayoutOptions) -> (f64, f64, f64) {
586    let m = get_global_metrics(options.style.size_index());
587    let w = missing_glyph_width_em(ch);
588    if w >= 0.99 {
589        let h = (m.quad * 0.92).max(m.x_height);
590        (w, h, 0.0)
591    } else {
592        (w, m.x_height, 0.0)
593    }
594}
595
596/// KaTeX `SymbolNode.toNode`: math symbols use `margin-right: italic` (advance = width + italic).
597#[inline]
598fn math_glyph_advance_em(m: &ratex_font::CharMetrics, mode: Mode) -> f64 {
599    if mode == Mode::Math {
600        m.width + m.italic
601    } else {
602        m.width
603    }
604}
605
606fn layout_symbol(text: &str, mode: Mode, options: &LayoutOptions) -> LayoutBox {
607    let ch = resolve_symbol_char(text, mode);
608
609    // Synthetic symbols not present in any KaTeX font; built from SVG paths.
610    match ch as u32 {
611        0x22B7 => return layout_imageof_origof(true, options),  // \imageof  •—○
612        0x22B6 => return layout_imageof_origof(false, options), // \origof   ○—•
613        _ => {}
614    }
615
616    let char_code = ch as u32;
617
618    if let Some((font_id, metric_cp)) =
619        ratex_font::font_and_metric_for_mathematical_alphanumeric(char_code)
620    {
621        let m = get_char_metrics(font_id, metric_cp);
622        let (width, height, depth) = match m {
623            Some(m) => (math_glyph_advance_em(&m, mode), m.height, m.depth),
624            None => missing_glyph_metrics_fallback(ch, options),
625        };
626        return LayoutBox {
627            width,
628            height,
629            depth,
630            content: BoxContent::Glyph {
631                font_id,
632                char_code,
633            },
634            color: options.color,
635        };
636    }
637
638    let mut font_id = select_font(text, ch, mode, options);
639    let mut metrics = get_char_metrics(font_id, char_code);
640
641    if metrics.is_none() && mode == Mode::Math && font_id != FontId::MathItalic {
642        if let Some(m) = get_char_metrics(FontId::MathItalic, char_code) {
643            font_id = FontId::MathItalic;
644            metrics = Some(m);
645        }
646    }
647
648    let (width, height, depth) = match metrics {
649        Some(m) => (math_glyph_advance_em(&m, mode), m.height, m.depth),
650        None => missing_glyph_metrics_fallback(ch, options),
651    };
652
653    LayoutBox {
654        width,
655        height,
656        depth,
657        content: BoxContent::Glyph {
658            font_id,
659            char_code,
660        },
661        color: options.color,
662    }
663}
664
665/// Resolve a symbol name to its actual character.
666fn resolve_symbol_char(text: &str, mode: Mode) -> char {
667    let font_mode = match mode {
668        Mode::Math => ratex_font::Mode::Math,
669        Mode::Text => ratex_font::Mode::Text,
670    };
671
672    if let Some(raw) = text.chars().next() {
673        let ru = raw as u32;
674        if (0x1D400..=0x1D7FF).contains(&ru) {
675            return raw;
676        }
677    }
678
679    if let Some(info) = ratex_font::get_symbol(text, font_mode) {
680        if let Some(cp) = info.codepoint {
681            return cp;
682        }
683    }
684
685    text.chars().next().unwrap_or('?')
686}
687
688/// Select the font for a math symbol.
689/// Uses the symbol table's font field for AMS symbols, and character properties
690/// to choose between MathItalic (for letters and Greek) and MainRegular.
691fn select_font(text: &str, resolved_char: char, mode: Mode, _options: &LayoutOptions) -> FontId {
692    let font_mode = match mode {
693        Mode::Math => ratex_font::Mode::Math,
694        Mode::Text => ratex_font::Mode::Text,
695    };
696
697    if let Some(info) = ratex_font::get_symbol(text, font_mode) {
698        if info.font == ratex_font::SymbolFont::Ams {
699            return FontId::AmsRegular;
700        }
701    }
702
703    match mode {
704        Mode::Math => {
705            if resolved_char.is_ascii_lowercase()
706                || resolved_char.is_ascii_uppercase()
707                || is_math_italic_greek(resolved_char)
708            {
709                FontId::MathItalic
710            } else {
711                FontId::MainRegular
712            }
713        }
714        Mode::Text => FontId::MainRegular,
715    }
716}
717
718/// Lowercase Greek letters and variant forms use Math-Italic in math mode.
719/// Uppercase Greek (U+0391–U+03A9) stays upright in Main-Regular per TeX convention.
720fn is_math_italic_greek(ch: char) -> bool {
721    matches!(ch,
722        '\u{03B1}'..='\u{03C9}' |
723        '\u{03D1}' | '\u{03D5}' | '\u{03D6}' |
724        '\u{03F1}' | '\u{03F5}'
725    )
726}
727
728fn is_arrow_accent(label: &str) -> bool {
729    matches!(
730        label,
731        "\\overrightarrow"
732            | "\\overleftarrow"
733            | "\\Overrightarrow"
734            | "\\overleftrightarrow"
735            | "\\underrightarrow"
736            | "\\underleftarrow"
737            | "\\underleftrightarrow"
738            | "\\overleftharpoon"
739            | "\\overrightharpoon"
740            | "\\overlinesegment"
741            | "\\underlinesegment"
742    )
743}
744
745// ============================================================================
746// Fraction layout (TeX Rule 15d)
747// ============================================================================
748
749fn layout_fraction(
750    numer: &ParseNode,
751    denom: &ParseNode,
752    bar_thickness: f64,
753    continued: bool,
754    options: &LayoutOptions,
755) -> LayoutBox {
756    let numer_s = options.style.numerator();
757    let denom_s = options.style.denominator();
758    let numer_style = options.with_style(numer_s);
759    let denom_style = options.with_style(denom_s);
760
761    let mut numer_box = layout_node(numer, &numer_style);
762    // KaTeX genfrac.js: `\cfrac` pads the numerator with a \strut (TeXbook p.353): 8.5pt × 3.5pt.
763    if continued {
764        let pt = options.metrics().pt_per_em;
765        let h_min = 8.5 / pt;
766        let d_min = 3.5 / pt;
767        if numer_box.height < h_min {
768            numer_box.height = h_min;
769        }
770        if numer_box.depth < d_min {
771            numer_box.depth = d_min;
772        }
773    }
774    let denom_box = layout_node(denom, &denom_style);
775
776    // Size ratios for converting child em to parent em
777    let numer_ratio = numer_s.size_multiplier() / options.style.size_multiplier();
778    let denom_ratio = denom_s.size_multiplier() / options.style.size_multiplier();
779
780    let numer_height = numer_box.height * numer_ratio;
781    let numer_depth = numer_box.depth * numer_ratio;
782    let denom_height = denom_box.height * denom_ratio;
783    let denom_depth = denom_box.depth * denom_ratio;
784    let numer_width = numer_box.width * numer_ratio;
785    let denom_width = denom_box.width * denom_ratio;
786
787    let metrics = options.metrics();
788    let axis = metrics.axis_height;
789    let rule = bar_thickness;
790
791    // TeX Rule 15d: choose shift amounts based on display/text mode
792    let (mut num_shift, mut den_shift) = if options.style.is_display() {
793        (metrics.num1, metrics.denom1)
794    } else if bar_thickness > 0.0 {
795        (metrics.num2, metrics.denom2)
796    } else {
797        (metrics.num3, metrics.denom2)
798    };
799
800    if bar_thickness > 0.0 {
801        let min_clearance = if options.style.is_display() {
802            3.0 * rule
803        } else {
804            rule
805        };
806
807        let num_clearance = (num_shift - numer_depth) - (axis + rule / 2.0);
808        if num_clearance < min_clearance {
809            num_shift += min_clearance - num_clearance;
810        }
811
812        let den_clearance = (axis - rule / 2.0) + (den_shift - denom_height);
813        if den_clearance < min_clearance {
814            den_shift += min_clearance - den_clearance;
815        }
816    } else {
817        let min_gap = if options.style.is_display() {
818            7.0 * metrics.default_rule_thickness
819        } else {
820            3.0 * metrics.default_rule_thickness
821        };
822
823        let gap = (num_shift - numer_depth) - (denom_height - den_shift);
824        if gap < min_gap {
825            let adjust = (min_gap - gap) / 2.0;
826            num_shift += adjust;
827            den_shift += adjust;
828        }
829    }
830
831    let total_width = numer_width.max(denom_width);
832    let height = numer_height + num_shift;
833    let depth = denom_depth + den_shift;
834
835    LayoutBox {
836        width: total_width,
837        height,
838        depth,
839        content: BoxContent::Fraction {
840            numer: Box::new(numer_box),
841            denom: Box::new(denom_box),
842            numer_shift: num_shift,
843            denom_shift: den_shift,
844            bar_thickness: rule,
845            numer_scale: numer_ratio,
846            denom_scale: denom_ratio,
847        },
848        color: options.color,
849    }
850}
851
852// ============================================================================
853// Superscript/Subscript layout
854// ============================================================================
855
856fn layout_supsub(
857    base: Option<&ParseNode>,
858    sup: Option<&ParseNode>,
859    sub: Option<&ParseNode>,
860    options: &LayoutOptions,
861    inherited_font: Option<FontId>,
862) -> LayoutBox {
863    let layout_child = |n: &ParseNode, opts: &LayoutOptions| match inherited_font {
864        Some(fid) => layout_with_font(n, fid, opts),
865        None => layout_node(n, opts),
866    };
867
868    let horiz_brace_over = matches!(
869        base,
870        Some(ParseNode::HorizBrace {
871            is_over: true,
872            ..
873        })
874    );
875    let horiz_brace_under = matches!(
876        base,
877        Some(ParseNode::HorizBrace {
878            is_over: false,
879            ..
880        })
881    );
882    let center_scripts = horiz_brace_over || horiz_brace_under;
883
884    let base_box = base
885        .map(|b| layout_child(b, options))
886        .unwrap_or_else(LayoutBox::new_empty);
887
888    let is_char_box = base.is_some_and(is_character_box);
889    let metrics = options.metrics();
890    // KaTeX `supsub.js`: each script span gets `marginRight: (0.5pt/ptPerEm)/sizeMultiplier`
891    // (TeX `\scriptspace`). Without this, sub/sup boxes are too narrow vs KaTeX (e.g. `pmatrix`
892    // column widths and inter-column alignment in golden tests).
893    let script_space = 0.5 / metrics.pt_per_em / options.size_multiplier();
894
895    let sup_style = options.style.superscript();
896    let sub_style = options.style.subscript();
897
898    let sup_ratio = sup_style.size_multiplier() / options.style.size_multiplier();
899    let sub_ratio = sub_style.size_multiplier() / options.style.size_multiplier();
900
901    let sup_box = sup.map(|s| {
902        let sup_opts = options.with_style(sup_style);
903        layout_child(s, &sup_opts)
904    });
905
906    let sub_box = sub.map(|s| {
907        let sub_opts = options.with_style(sub_style);
908        layout_child(s, &sub_opts)
909    });
910
911    let sup_height_scaled = sup_box.as_ref().map(|b| b.height * sup_ratio).unwrap_or(0.0);
912    let sup_depth_scaled = sup_box.as_ref().map(|b| b.depth * sup_ratio).unwrap_or(0.0);
913    let sub_height_scaled = sub_box.as_ref().map(|b| b.height * sub_ratio).unwrap_or(0.0);
914    let sub_depth_scaled = sub_box.as_ref().map(|b| b.depth * sub_ratio).unwrap_or(0.0);
915
916    // KaTeX uses the CHILD style's metrics for supDrop/subDrop, not the parent's
917    let sup_style_metrics = get_global_metrics(sup_style.size_index());
918    let sub_style_metrics = get_global_metrics(sub_style.size_index());
919
920    // Rule 18a: initial shift from base dimensions
921    // For character boxes, supShift/subShift start at 0 (KaTeX behavior)
922    let mut sup_shift = if !is_char_box && sup_box.is_some() {
923        base_box.height - sup_style_metrics.sup_drop * sup_ratio
924    } else {
925        0.0
926    };
927
928    let mut sub_shift = if !is_char_box && sub_box.is_some() {
929        base_box.depth + sub_style_metrics.sub_drop * sub_ratio
930    } else {
931        0.0
932    };
933
934    let min_sup_shift = if options.style.is_cramped() {
935        metrics.sup3
936    } else if options.style.is_display() {
937        metrics.sup1
938    } else {
939        metrics.sup2
940    };
941
942    if sup_box.is_some() && sub_box.is_some() {
943        // Rule 18c+e: both sup and sub
944        sup_shift = sup_shift
945            .max(min_sup_shift)
946            .max(sup_depth_scaled + 0.25 * metrics.x_height);
947        sub_shift = sub_shift.max(metrics.sub2); // sub2 when both present
948
949        let rule_width = metrics.default_rule_thickness;
950        let max_width = 4.0 * rule_width;
951        let gap = (sup_shift - sup_depth_scaled) - (sub_height_scaled - sub_shift);
952        if gap < max_width {
953            sub_shift = max_width - (sup_shift - sup_depth_scaled) + sub_height_scaled;
954            let psi = 0.8 * metrics.x_height - (sup_shift - sup_depth_scaled);
955            if psi > 0.0 {
956                sup_shift += psi;
957                sub_shift -= psi;
958            }
959        }
960    } else if sub_box.is_some() {
961        // Rule 18b: sub only
962        sub_shift = sub_shift
963            .max(metrics.sub1)
964            .max(sub_height_scaled - 0.8 * metrics.x_height);
965    } else if sup_box.is_some() {
966        // Rule 18c,d: sup only
967        sup_shift = sup_shift
968            .max(min_sup_shift)
969            .max(sup_depth_scaled + 0.25 * metrics.x_height);
970    }
971
972    // KaTeX `horizBrace.js` htmlBuilder: the script is placed using a VList with a fixed 0.2em
973    // kern between the brace result and the script, plus the script's own (scaled) dimensions.
974    // This overrides the default TeX Rule 18 sub_shift / sup_shift with the exact KaTeX layout.
975    if horiz_brace_over && sup_box.is_some() {
976        sup_shift = base_box.height + 0.2 + sup_depth_scaled;
977    }
978    if horiz_brace_under && sub_box.is_some() {
979        sub_shift = base_box.depth + 0.2 + sub_height_scaled;
980    }
981
982    // Superscript horizontal offset: `layout_symbol` already uses advance width + italic
983    // (KaTeX `margin-right: italic`), so we must not add `glyph_italic` again here.
984    let italic_correction = 0.0;
985
986    // KaTeX `supsub.js`: for SymbolNode bases, subscripts get `margin-left: -base.italic` so they
987    // are not shifted by the base's italic correction (e.g. ∫_{A_1}).
988    let sub_h_kern = if sub_box.is_some() && !center_scripts {
989        -glyph_italic(&base_box)
990    } else {
991        0.0
992    };
993
994    // Compute total dimensions (using scaled child dimensions)
995    let mut height = base_box.height;
996    let mut depth = base_box.depth;
997    let mut total_width = base_box.width;
998
999    if let Some(ref sup_b) = sup_box {
1000        height = height.max(sup_shift + sup_height_scaled);
1001        if center_scripts {
1002            total_width = total_width.max(sup_b.width * sup_ratio + script_space);
1003        } else {
1004            total_width = total_width.max(
1005                base_box.width + italic_correction + sup_b.width * sup_ratio + script_space,
1006            );
1007        }
1008    }
1009    if let Some(ref sub_b) = sub_box {
1010        depth = depth.max(sub_shift + sub_depth_scaled);
1011        if center_scripts {
1012            total_width = total_width.max(sub_b.width * sub_ratio + script_space);
1013        } else {
1014            total_width = total_width.max(
1015                base_box.width + sub_h_kern + sub_b.width * sub_ratio + script_space,
1016            );
1017        }
1018    }
1019
1020    LayoutBox {
1021        width: total_width,
1022        height,
1023        depth,
1024        content: BoxContent::SupSub {
1025            base: Box::new(base_box),
1026            sup: sup_box.map(Box::new),
1027            sub: sub_box.map(Box::new),
1028            sup_shift,
1029            sub_shift,
1030            sup_scale: sup_ratio,
1031            sub_scale: sub_ratio,
1032            center_scripts,
1033            italic_correction,
1034            sub_h_kern,
1035        },
1036        color: options.color,
1037    }
1038}
1039
1040// ============================================================================
1041// Radical (square root) layout
1042// ============================================================================
1043
1044fn layout_radical(
1045    body: &ParseNode,
1046    index: Option<&ParseNode>,
1047    options: &LayoutOptions,
1048) -> LayoutBox {
1049    let cramped = options.style.cramped();
1050    let cramped_opts = options.with_style(cramped);
1051    let mut body_box = layout_node(body, &cramped_opts);
1052
1053    // Cramped style has same size_multiplier as uncramped
1054    let body_ratio = cramped.size_multiplier() / options.style.size_multiplier();
1055    body_box.height *= body_ratio;
1056    body_box.depth *= body_ratio;
1057    body_box.width *= body_ratio;
1058
1059    // Ensure non-zero inner height (KaTeX: if inner.height === 0, use xHeight)
1060    if body_box.height == 0.0 {
1061        body_box.height = options.metrics().x_height;
1062    }
1063
1064    let metrics = options.metrics();
1065    let theta = metrics.default_rule_thickness; // 0.04 for textstyle
1066
1067    // KaTeX sqrt.js: `let phi = theta; if (options.style.id < Style.TEXT.id) phi = xHeight`.
1068    // Style ids 0–1 are DISPLAY / DISPLAY_CRAMPED; TEXT is id 2. So only display styles use xHeight.
1069    let phi = if options.style.is_display() {
1070        metrics.x_height
1071    } else {
1072        theta
1073    };
1074
1075    let mut line_clearance = theta + phi / 4.0;
1076
1077    // Minimum delimiter height needed
1078    let min_delim_height = body_box.height + body_box.depth + line_clearance + theta;
1079
1080    // Select surd glyph size (simplified: use known breakpoints)
1081    // KaTeX surd sizes: small=1.0, size1=1.2, size2=1.8, size3=2.4, size4=3.0
1082    let tex_height = select_surd_height(min_delim_height);
1083    let rule_width = theta;
1084    let surd_font = crate::surd::surd_font_for_inner_height(tex_height);
1085    let advance_width = ratex_font::get_char_metrics(surd_font, 0x221A)
1086        .map(|m| m.width)
1087        .unwrap_or(0.833);
1088
1089    // Check if delimiter is taller than needed → center the extra space
1090    let delim_depth = tex_height - rule_width;
1091    if delim_depth > body_box.height + body_box.depth + line_clearance {
1092        line_clearance =
1093            (line_clearance + delim_depth - body_box.height - body_box.depth) / 2.0;
1094    }
1095
1096    let img_shift = tex_height - body_box.height - line_clearance - rule_width;
1097
1098    // Compute final box dimensions via vlist logic
1099    // height = inner.height + lineClearance + 2*ruleWidth when inner.depth=0
1100    let height = tex_height + rule_width - img_shift;
1101    let depth = if img_shift > body_box.depth {
1102        img_shift
1103    } else {
1104        body_box.depth
1105    };
1106
1107    // Root index (e.g. \sqrt[3]{x}): KaTeX uses SCRIPTSCRIPT (TeX: superscript of superscript).
1108    const INDEX_KERN: f64 = 0.05;
1109    let (index_box, index_offset, index_scale) = if let Some(index_node) = index {
1110        let root_style = options.style.superscript().superscript();
1111        let root_opts = options.with_style(root_style);
1112        let idx = layout_node(index_node, &root_opts);
1113        let index_ratio = root_style.size_multiplier() / options.style.size_multiplier();
1114        let offset = idx.width * index_ratio + INDEX_KERN;
1115        (Some(Box::new(idx)), offset, index_ratio)
1116    } else {
1117        (None, 0.0, 1.0)
1118    };
1119
1120    let width = index_offset + advance_width + body_box.width;
1121
1122    LayoutBox {
1123        width,
1124        height,
1125        depth,
1126        content: BoxContent::Radical {
1127            body: Box::new(body_box),
1128            index: index_box,
1129            index_offset,
1130            index_scale,
1131            rule_thickness: rule_width,
1132            inner_height: tex_height,
1133        },
1134        color: options.color,
1135    }
1136}
1137
1138/// Select the surd glyph height based on the required minimum delimiter height.
1139/// KaTeX uses: small(1.0), Size1(1.2), Size2(1.8), Size3(2.4), Size4(3.0).
1140fn select_surd_height(min_height: f64) -> f64 {
1141    const SURD_HEIGHTS: [f64; 5] = [1.0, 1.2, 1.8, 2.4, 3.0];
1142    for &h in &SURD_HEIGHTS {
1143        if h >= min_height {
1144            return h;
1145        }
1146    }
1147    // For very tall content, use the largest + stack
1148    SURD_HEIGHTS[4].max(min_height)
1149}
1150
1151// ============================================================================
1152// Operator layout (TeX Rule 13)
1153// ============================================================================
1154
1155const NO_SUCCESSOR: &[&str] = &["\\smallint"];
1156
1157/// Check if a SupSub's base should use limits (above/below) positioning.
1158fn should_use_op_limits(base: &ParseNode, options: &LayoutOptions) -> bool {
1159    match base {
1160        ParseNode::Op {
1161            limits,
1162            always_handle_sup_sub,
1163            ..
1164        } => {
1165            *limits
1166                && (options.style.is_display()
1167                    || always_handle_sup_sub.unwrap_or(false))
1168        }
1169        ParseNode::OperatorName {
1170            always_handle_sup_sub,
1171            limits,
1172            ..
1173        } => {
1174            *always_handle_sup_sub
1175                && (options.style.is_display() || *limits)
1176        }
1177        _ => false,
1178    }
1179}
1180
1181/// Lay out an Op node (without limits — standalone or nolimits mode).
1182///
1183/// In KaTeX, baseShift is applied via CSS `position:relative;top:` which
1184/// does NOT alter the box dimensions. So we return the original glyph
1185/// dimensions unchanged — the visual shift is handled at render time.
1186fn layout_op(
1187    name: Option<&str>,
1188    symbol: bool,
1189    body: Option<&[ParseNode]>,
1190    _limits: bool,
1191    suppress_base_shift: bool,
1192    options: &LayoutOptions,
1193) -> LayoutBox {
1194    let (mut base_box, _slant) = build_op_base(name, symbol, body, options);
1195
1196    // Center symbol operators on the math axis (TeX Rule 13a)
1197    if symbol && !suppress_base_shift {
1198        let axis = options.metrics().axis_height;
1199        let shift = (base_box.height - base_box.depth) / 2.0 - axis;
1200        if shift.abs() > 0.001 {
1201            base_box.height -= shift;
1202            base_box.depth += shift;
1203        }
1204    }
1205
1206    // For user-defined \mathop{content} (e.g. \vcentcolon), center the content
1207    // on the math axis via a RaiseBox so the glyph physically moves up/down.
1208    // The HBox emit pass keeps all children at the same baseline, so adjusting
1209    // height/depth alone doesn't move the glyph.
1210    if !suppress_base_shift && !symbol && body.is_some() {
1211        let axis = options.metrics().axis_height;
1212        let delta = (base_box.height - base_box.depth) / 2.0 - axis;
1213        if delta.abs() > 0.001 {
1214            let w = base_box.width;
1215            // delta < 0 → center is below axis → raise (positive RaiseBox shift)
1216            let raise = -delta;
1217            base_box = LayoutBox {
1218                width: w,
1219                height: (base_box.height + raise).max(0.0),
1220                depth: (base_box.depth - raise).max(0.0),
1221                content: BoxContent::RaiseBox {
1222                    body: Box::new(base_box),
1223                    shift: raise,
1224                },
1225                color: options.color,
1226            };
1227        }
1228    }
1229
1230    base_box
1231}
1232
1233/// Build the base glyph/text for an operator.
1234/// Returns (base_box, slant) where slant is the italic correction.
1235fn build_op_base(
1236    name: Option<&str>,
1237    symbol: bool,
1238    body: Option<&[ParseNode]>,
1239    options: &LayoutOptions,
1240) -> (LayoutBox, f64) {
1241    if symbol {
1242        let large = options.style.is_display()
1243            && !NO_SUCCESSOR.contains(&name.unwrap_or(""));
1244        let font_id = if large {
1245            FontId::Size2Regular
1246        } else {
1247            FontId::Size1Regular
1248        };
1249
1250        let op_name = name.unwrap_or("");
1251        let ch = resolve_op_char(op_name);
1252        let char_code = ch as u32;
1253
1254        let metrics = get_char_metrics(font_id, char_code);
1255        let (width, height, depth, italic) = match metrics {
1256            Some(m) => (m.width, m.height, m.depth, m.italic),
1257            None => (1.0, 0.75, 0.25, 0.0),
1258        };
1259        // Include italic correction in width so limits centered above/below don't overlap
1260        // the operator's right-side extension (e.g. integral ∫ has non-zero italic).
1261        let width_with_italic = width + italic;
1262
1263        let base = LayoutBox {
1264            width: width_with_italic,
1265            height,
1266            depth,
1267            content: BoxContent::Glyph {
1268                font_id,
1269                char_code,
1270            },
1271            color: options.color,
1272        };
1273
1274        // \oiint and \oiiint: overlay an ellipse on the integral (∬/∭) like \oint’s circle.
1275        // resolve_op_char already maps them to ∬/∭; add the circle overlay here.
1276        if op_name == "\\oiint" || op_name == "\\oiiint" {
1277            let w = base.width;
1278            let ellipse_commands = ellipse_overlay_path(w, base.height, base.depth);
1279            let overlay_box = LayoutBox {
1280                width: w,
1281                height: base.height,
1282                depth: base.depth,
1283                content: BoxContent::SvgPath {
1284                    commands: ellipse_commands,
1285                    fill: false,
1286                },
1287                color: options.color,
1288            };
1289            let with_overlay = make_hbox(vec![base, LayoutBox::new_kern(-w), overlay_box]);
1290            return (with_overlay, italic);
1291        }
1292
1293        (base, italic)
1294    } else if let Some(body_nodes) = body {
1295        let base = layout_expression(body_nodes, options, true);
1296        (base, 0.0)
1297    } else {
1298        let base = layout_op_text(name.unwrap_or(""), options);
1299        (base, 0.0)
1300    }
1301}
1302
1303/// Render a text operator name like \sin, \cos, \lim.
1304fn layout_op_text(name: &str, options: &LayoutOptions) -> LayoutBox {
1305    let text = name.strip_prefix('\\').unwrap_or(name);
1306    let mut children = Vec::new();
1307    for ch in text.chars() {
1308        let char_code = ch as u32;
1309        let metrics = get_char_metrics(FontId::MainRegular, char_code);
1310        let (width, height, depth) = match metrics {
1311            Some(m) => (m.width, m.height, m.depth),
1312            None => (0.5, 0.43, 0.0),
1313        };
1314        children.push(LayoutBox {
1315            width,
1316            height,
1317            depth,
1318            content: BoxContent::Glyph {
1319                font_id: FontId::MainRegular,
1320                char_code,
1321            },
1322            color: options.color,
1323        });
1324    }
1325    make_hbox(children)
1326}
1327
1328/// Compute the vertical shift to center an op symbol on the math axis (Rule 13).
1329fn compute_op_base_shift(base: &LayoutBox, options: &LayoutOptions) -> f64 {
1330    let metrics = options.metrics();
1331    (base.height - base.depth) / 2.0 - metrics.axis_height
1332}
1333
1334/// Resolve an op command name to its Unicode character.
1335fn resolve_op_char(name: &str) -> char {
1336    // \oiint and \oiiint: use ∬/∭ as base glyph; circle overlay is drawn in build_op_base
1337    // (same idea as \oint’s circle, but U+222F/U+2230 often missing in math fonts).
1338    match name {
1339        "\\oiint"  => return '\u{222C}', // ∬ (double integral)
1340        "\\oiiint" => return '\u{222D}', // ∭ (triple integral)
1341        _ => {}
1342    }
1343    let font_mode = ratex_font::Mode::Math;
1344    if let Some(info) = ratex_font::get_symbol(name, font_mode) {
1345        if let Some(cp) = info.codepoint {
1346            return cp;
1347        }
1348    }
1349    name.chars().next().unwrap_or('?')
1350}
1351
1352/// Lay out an Op with limits above/below (called from SupSub delegation).
1353fn layout_op_with_limits(
1354    base_node: &ParseNode,
1355    sup_node: Option<&ParseNode>,
1356    sub_node: Option<&ParseNode>,
1357    options: &LayoutOptions,
1358) -> LayoutBox {
1359    let (name, symbol, body, suppress_base_shift) = match base_node {
1360        ParseNode::Op {
1361            name,
1362            symbol,
1363            body,
1364            suppress_base_shift,
1365            ..
1366        } => (
1367            name.as_deref(),
1368            *symbol,
1369            body.as_deref(),
1370            suppress_base_shift.unwrap_or(false),
1371        ),
1372        ParseNode::OperatorName { body, .. } => (None, false, Some(body.as_slice()), false),
1373        _ => return layout_supsub(Some(base_node), sup_node, sub_node, options, None),
1374    };
1375
1376    // KaTeX-exact limit kerning (no +0.08em) for `\overset`/`\underset` only (`suppress_base_shift`).
1377    let legacy_limit_kern_padding = !suppress_base_shift;
1378
1379    let (base_box, slant) = build_op_base(name, symbol, body, options);
1380    // baseShift only applies to symbol operators (KaTeX: base instanceof SymbolNode)
1381    let base_shift = if symbol && !suppress_base_shift {
1382        compute_op_base_shift(&base_box, options)
1383    } else {
1384        0.0
1385    };
1386
1387    layout_op_limits_inner(
1388        &base_box,
1389        sup_node,
1390        sub_node,
1391        slant,
1392        base_shift,
1393        legacy_limit_kern_padding,
1394        options,
1395    )
1396}
1397
1398/// Assemble an operator with limits above/below (KaTeX's `assembleSupSub`).
1399///
1400/// `legacy_limit_kern_padding`: +0.08em on limit kerns for all ops except `\overset`/`\underset`
1401/// (`ParseNode::Op { suppress_base_shift: true }`), matching KaTeX on `\dddot`/`\ddddot` PNGs.
1402fn layout_op_limits_inner(
1403    base: &LayoutBox,
1404    sup_node: Option<&ParseNode>,
1405    sub_node: Option<&ParseNode>,
1406    slant: f64,
1407    base_shift: f64,
1408    legacy_limit_kern_padding: bool,
1409    options: &LayoutOptions,
1410) -> LayoutBox {
1411    let metrics = options.metrics();
1412    let sup_style = options.style.superscript();
1413    let sub_style = options.style.subscript();
1414
1415    let sup_ratio = sup_style.size_multiplier() / options.style.size_multiplier();
1416    let sub_ratio = sub_style.size_multiplier() / options.style.size_multiplier();
1417
1418    let extra_kern = if legacy_limit_kern_padding { 0.08_f64 } else { 0.0_f64 };
1419
1420    let sup_data = sup_node.map(|s| {
1421        let sup_opts = options.with_style(sup_style);
1422        let elem = layout_node(s, &sup_opts);
1423        // `\overset`/`\underset`: KaTeX `assembleSupSub` uses `elem.depth` as-is. Other limits
1424        // (e.g. `\lim\limits_x`) keep the legacy `depth * sup_ratio` term so ink scores stay
1425        // aligned with our KaTeX PNG fixtures.
1426        let d = if legacy_limit_kern_padding {
1427            elem.depth * sup_ratio
1428        } else {
1429            elem.depth
1430        };
1431        let kern = (metrics.big_op_spacing1 + extra_kern).max(metrics.big_op_spacing3 - d + extra_kern);
1432        (elem, kern)
1433    });
1434
1435    let sub_data = sub_node.map(|s| {
1436        let sub_opts = options.with_style(sub_style);
1437        let elem = layout_node(s, &sub_opts);
1438        let h = if legacy_limit_kern_padding {
1439            elem.height * sub_ratio
1440        } else {
1441            elem.height
1442        };
1443        let kern = (metrics.big_op_spacing2 + extra_kern).max(metrics.big_op_spacing4 - h + extra_kern);
1444        (elem, kern)
1445    });
1446
1447    let sp5 = metrics.big_op_spacing5;
1448
1449    let (total_height, total_depth, total_width) = match (&sup_data, &sub_data) {
1450        (Some((sup_elem, sup_kern)), Some((sub_elem, sub_kern))) => {
1451            // Both sup and sub: VList from bottom
1452            // [sp5, sub, sub_kern, base, sup_kern, sup, sp5]
1453            let sup_h = sup_elem.height * sup_ratio;
1454            let sup_d = sup_elem.depth * sup_ratio;
1455            let sub_h = sub_elem.height * sub_ratio;
1456            let sub_d = sub_elem.depth * sub_ratio;
1457
1458            let bottom = sp5 + sub_h + sub_d + sub_kern + base.depth + base_shift;
1459
1460            let height = bottom
1461                + base.height - base_shift
1462                + sup_kern
1463                + sup_h + sup_d
1464                + sp5
1465                - (base.height + base.depth);
1466
1467            let total_h = base.height - base_shift + sup_kern + sup_h + sup_d + sp5;
1468            let total_d = bottom;
1469
1470            let w = base
1471                .width
1472                .max(sup_elem.width * sup_ratio)
1473                .max(sub_elem.width * sub_ratio);
1474            let _ = height; // suppress unused; we use total_h/total_d
1475            (total_h, total_d, w)
1476        }
1477        (None, Some((sub_elem, sub_kern))) => {
1478            // Sub only: VList from top
1479            // [sp5, sub, sub_kern, base]
1480            let sub_h = sub_elem.height * sub_ratio;
1481            let sub_d = sub_elem.depth * sub_ratio;
1482
1483            let total_h = base.height - base_shift;
1484            let total_d = base.depth + base_shift + sub_kern + sub_h + sub_d + sp5;
1485
1486            let w = base.width.max(sub_elem.width * sub_ratio);
1487            (total_h, total_d, w)
1488        }
1489        (Some((sup_elem, sup_kern)), None) => {
1490            // Sup only: VList from bottom
1491            // [base, sup_kern, sup, sp5]
1492            let sup_h = sup_elem.height * sup_ratio;
1493            let sup_d = sup_elem.depth * sup_ratio;
1494
1495            let total_h =
1496                base.height - base_shift + sup_kern + sup_h + sup_d + sp5;
1497            let total_d = base.depth + base_shift;
1498
1499            let w = base.width.max(sup_elem.width * sup_ratio);
1500            (total_h, total_d, w)
1501        }
1502        (None, None) => {
1503            return base.clone();
1504        }
1505    };
1506
1507    let sup_kern_val = sup_data.as_ref().map(|(_, k)| *k).unwrap_or(0.0);
1508    let sub_kern_val = sub_data.as_ref().map(|(_, k)| *k).unwrap_or(0.0);
1509
1510    LayoutBox {
1511        width: total_width,
1512        height: total_height,
1513        depth: total_depth,
1514        content: BoxContent::OpLimits {
1515            base: Box::new(base.clone()),
1516            sup: sup_data.map(|(elem, _)| Box::new(elem)),
1517            sub: sub_data.map(|(elem, _)| Box::new(elem)),
1518            base_shift,
1519            sup_kern: sup_kern_val,
1520            sub_kern: sub_kern_val,
1521            slant,
1522            sup_scale: sup_ratio,
1523            sub_scale: sub_ratio,
1524        },
1525        color: options.color,
1526    }
1527}
1528
1529/// Lay out \operatorname body as roman text.
1530fn layout_operatorname(body: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
1531    let mut children = Vec::new();
1532    for node in body {
1533        match node {
1534            ParseNode::MathOrd { text, .. } | ParseNode::TextOrd { text, .. } => {
1535                let ch = text.chars().next().unwrap_or('?');
1536                let char_code = ch as u32;
1537                let metrics = get_char_metrics(FontId::MainRegular, char_code);
1538                let (width, height, depth) = match metrics {
1539                    Some(m) => (m.width, m.height, m.depth),
1540                    None => (0.5, 0.43, 0.0),
1541                };
1542                children.push(LayoutBox {
1543                    width,
1544                    height,
1545                    depth,
1546                    content: BoxContent::Glyph {
1547                        font_id: FontId::MainRegular,
1548                        char_code,
1549                    },
1550                    color: options.color,
1551                });
1552            }
1553            _ => {
1554                children.push(layout_node(node, options));
1555            }
1556        }
1557    }
1558    make_hbox(children)
1559}
1560
1561// ============================================================================
1562// Accent layout
1563// ============================================================================
1564
1565/// `\vec` KaTeX SVG: nudge slightly right to match KaTeX reference.
1566const VEC_SKEW_EXTRA_RIGHT_EM: f64 = 0.018;
1567
1568/// Extract the italic correction of the base glyph.
1569/// Used by superscripts: KaTeX adds margin-right = italic_correction to italic math characters,
1570/// so the superscript starts at advance_width + italic_correction (not just advance_width).
1571fn glyph_italic(lb: &LayoutBox) -> f64 {
1572    match &lb.content {
1573        BoxContent::Glyph { font_id, char_code } => {
1574            get_char_metrics(*font_id, *char_code)
1575                .map(|m| m.italic)
1576                .unwrap_or(0.0)
1577        }
1578        BoxContent::HBox(children) => {
1579            children.last().map(glyph_italic).unwrap_or(0.0)
1580        }
1581        _ => 0.0,
1582    }
1583}
1584
1585/// Extract the skew (italic correction) of the innermost/last glyph in a box.
1586/// Used by shifty accents (\hat, \tilde…) to horizontally centre the mark
1587/// over italic math letters (e.g. M in MathItalic has skew ≈ 0.083em).
1588/// KaTeX `groupLength` for wide SVG accents: `ordgroup.body.length`, else 1.
1589fn accent_ordgroup_len(base: &ParseNode) -> usize {
1590    match base {
1591        ParseNode::OrdGroup { body, .. } => body.len().max(1),
1592        _ => 1,
1593    }
1594}
1595
1596fn glyph_skew(lb: &LayoutBox) -> f64 {
1597    match &lb.content {
1598        BoxContent::Glyph { font_id, char_code } => {
1599            get_char_metrics(*font_id, *char_code)
1600                .map(|m| m.skew)
1601                .unwrap_or(0.0)
1602        }
1603        BoxContent::HBox(children) => {
1604            children.last().map(glyph_skew).unwrap_or(0.0)
1605        }
1606        _ => 0.0,
1607    }
1608}
1609
1610fn layout_accent(
1611    label: &str,
1612    base: &ParseNode,
1613    is_stretchy: bool,
1614    is_shifty: bool,
1615    is_below: bool,
1616    options: &LayoutOptions,
1617) -> LayoutBox {
1618    let body_box = layout_node(base, options);
1619    let base_w = body_box.width.max(0.5);
1620
1621    // Special handling for \textcircled: draw a circle around the content
1622    if label == "\\textcircled" {
1623        return layout_textcircled(body_box, options);
1624    }
1625
1626    // Try KaTeX exact SVG paths first (widehat, widetilde, overgroup, etc.)
1627    if let Some((commands, w, h, fill)) =
1628        crate::katex_svg::katex_accent_path(label, base_w, accent_ordgroup_len(base))
1629    {
1630        // KaTeX paths use SVG coords (y down): height=0, depth=h
1631        let accent_box = LayoutBox {
1632            width: w,
1633            height: 0.0,
1634            depth: h,
1635            content: BoxContent::SvgPath { commands, fill },
1636            color: options.color,
1637        };
1638        // KaTeX `accent.ts` uses `clearance = min(body.height, xHeight)` for ordinary accents.
1639        // That matches fixed-size `\vec` (svgData.vec); using it for *width-scaled* SVG accents
1640        // (\widehat, \widetilde, \overgroup, …) pulls the path down onto the base (golden 0604/0885/0886).
1641        // Slightly tighter than 0.08em — aligns wide SVG hats with KaTeX PNG crops (e.g. 0935).
1642        let gap = 0.065;
1643        let under_gap_em = if is_below && label == "\\utilde" {
1644            0.12
1645        } else {
1646            0.0
1647        };
1648        let clearance = if is_below {
1649            body_box.height + body_box.depth + gap
1650        } else if label == "\\vec" {
1651            // KaTeX: clearance = min(body.height, xHeight) is used as *overlap* (kern down).
1652            // Equivalent RaTeX position: vec bottom = body.height - overlap = max(0, body.height - xHeight).
1653            (body_box.height - options.metrics().x_height).max(0.0)
1654        } else {
1655            body_box.height + gap
1656        };
1657        let (height, depth) = if is_below {
1658            (body_box.height, body_box.depth + h + gap + under_gap_em)
1659        } else if label == "\\vec" {
1660            // Box height = clearance + H_EM, matching KaTeX VList height.
1661            (clearance + h, body_box.depth)
1662        } else {
1663            (body_box.height + gap + h, body_box.depth)
1664        };
1665        let vec_skew = if label == "\\vec" {
1666            (if is_shifty {
1667                glyph_skew(&body_box)
1668            } else {
1669                0.0
1670            }) + VEC_SKEW_EXTRA_RIGHT_EM
1671        } else {
1672            0.0
1673        };
1674        return LayoutBox {
1675            width: body_box.width,
1676            height,
1677            depth,
1678            content: BoxContent::Accent {
1679                base: Box::new(body_box),
1680                accent: Box::new(accent_box),
1681                clearance,
1682                skew: vec_skew,
1683                is_below,
1684                under_gap_em,
1685            },
1686            color: options.color,
1687        };
1688    }
1689
1690    // Arrow-type stretchy accents (overrightarrow, etc.)
1691    let use_arrow_path = is_stretchy && is_arrow_accent(label);
1692
1693    let accent_box = if use_arrow_path {
1694        let (commands, arrow_h, fill_arrow) =
1695            match crate::katex_svg::katex_stretchy_path(label, base_w) {
1696                Some((c, h)) => (c, h, true),
1697                None => {
1698                    let h = 0.3_f64;
1699                    let c = stretchy_accent_path(label, base_w, h);
1700                    let fill = label == "\\xtwoheadrightarrow" || label == "\\xtwoheadleftarrow";
1701                    (c, h, fill)
1702                }
1703            };
1704        LayoutBox {
1705            width: base_w,
1706            height: arrow_h / 2.0,
1707            depth: arrow_h / 2.0,
1708            content: BoxContent::SvgPath {
1709                commands,
1710                fill: fill_arrow,
1711            },
1712            color: options.color,
1713        }
1714    } else {
1715        // Try text mode first for text accents (\c, \', \`, etc.), fall back to math
1716        let accent_char = {
1717            let ch = resolve_symbol_char(label, Mode::Text);
1718            if ch == label.chars().next().unwrap_or('?') {
1719                // Text mode didn't resolve (returned first char of label, likely '\\')
1720                // so try math mode
1721                resolve_symbol_char(label, Mode::Math)
1722            } else {
1723                ch
1724            }
1725        };
1726        let accent_code = accent_char as u32;
1727        let accent_metrics = get_char_metrics(FontId::MainRegular, accent_code);
1728        let (accent_w, accent_h, accent_d) = match accent_metrics {
1729            Some(m) => (m.width, m.height, m.depth),
1730            None => (body_box.width, 0.25, 0.0),
1731        };
1732        LayoutBox {
1733            width: accent_w,
1734            height: accent_h,
1735            depth: accent_d,
1736            content: BoxContent::Glyph {
1737                font_id: FontId::MainRegular,
1738                char_code: accent_code,
1739            },
1740            color: options.color,
1741        }
1742    };
1743
1744    let skew = if use_arrow_path {
1745        0.0
1746    } else if is_shifty {
1747        // For shifty accents (\hat, \tilde, etc.) shift by the BASE character's skew,
1748        // which encodes the italic correction in math-italic fonts (e.g. M → 0.083em).
1749        glyph_skew(&body_box)
1750    } else {
1751        0.0
1752    };
1753
1754    // gap = clearance between body top and bottom of accent SVG.
1755    // For arrow accents, the SVG path is centered (height=h/2, depth=h/2).
1756    // The gap prevents the visible arrowhead / harpoon tip from overlapping the base top.
1757    //
1758    // KaTeX stretchy arrows with vb_height 522 have h/2 ≈ 0.261em; default gap=0.12 left
1759    // too little room for tall caps (`\overleftrightarrow{AB}`, `\overleftarrow{AB}`,
1760    // `\overleftharpoon{AB}`, …).  `\Overrightarrow` uses a taller glyph (vb 560) and keeps
1761    // the slightly smaller kern used in prior tuning.
1762    let gap = if use_arrow_path {
1763        if label == "\\Overrightarrow" {
1764            0.21
1765        } else {
1766            0.26
1767        }
1768    } else {
1769        0.0
1770    };
1771
1772    let clearance = if is_below {
1773        body_box.height + body_box.depth + accent_box.depth + gap
1774    } else if use_arrow_path {
1775        body_box.height + gap
1776    } else {
1777        // Clearance = how high above baseline the accent is positioned.
1778        // - For simple letters (M, b, o): body_box.height is the letter top → use directly.
1779        // - For a body that is itself an above-accent (\r{a} = \aa, \bar{x}, …):
1780        //   body_box.height = inner_clearance + 0.35 (the 0.35 rendering correction is
1781        //   already baked in). Using it as outer clearance adds ANOTHER 0.35 on top
1782        //   (staircase effect), placing hat 0.35em above ring — too spaced.
1783        //   Instead, read the inner accent's clearance directly from BoxContent and add
1784        //   a small ε (0.07em ≈ 3px) so the marks don't pixel-overlap in the rasterizer.
1785        //   This is equivalent to KaTeX's min(body.height, xHeight) approach.
1786        let base_clearance = match &body_box.content {
1787            BoxContent::Accent { clearance: inner_cl, is_below, accent: inner_accent, .. }
1788                if !is_below =>
1789            {
1790                // For SVG accents (height≈0, e.g. \vec): body_box.height = clearance + H_EM,
1791                // which matches KaTeX's body.height. Use min(body.height, xHeight) exactly as
1792                // KaTeX does: clearance = min(body.height, xHeight).
1793                // For glyph accents the 0.35 rendering shift is baked into body_box.height,
1794                // so we use inner_cl + 0.3 to avoid double-counting that correction.
1795                if inner_accent.height <= 0.001 {
1796                    // For SVG accents like \vec: KaTeX places the outer glyph accent with
1797                    // its baseline at body.height - min(body.height, xHeight) above formula
1798                    // baseline, i.e. max(0, body.height - xHeight).
1799                    // to_display.rs shifts the glyph DOWN by (accent_h - 0.35.min(accent_h))
1800                    // so we pre-add that correction to land at the right position.
1801                    let katex_pos = (body_box.height - options.metrics().x_height).max(0.0);
1802                    let correction = (accent_box.height - 0.35_f64.min(accent_box.height)).max(0.0);
1803                    katex_pos + correction
1804                } else {
1805                    inner_cl + 0.3
1806                }
1807            }
1808            _ => {
1809                // KaTeX positions glyph accents by kerning DOWN by
1810                // min(body.height, xHeight), so the accent baseline sits at
1811                //   max(0, body.height - xHeight)
1812                // above the formula baseline.  This keeps the accent within the
1813                // body's height bounds for normal-height bases and produces a
1814                // formula height == body_height (accent adds no extra height),
1815                // matching KaTeX's VList.
1816                //
1817                // \bar / \= (macron) are an exception: for x-height bases (a, e, o, …)
1818                // body.height ≈ xHeight so katex_pos ≈ 0 and the bar sits on the letter
1819                // (golden \text{\={a}}).  Tie macron clearance to full body height like
1820                // the pre-62f7ba53 engine, then apply the same small kern as before.
1821                if label == "\\bar" || label == "\\=" {
1822                    body_box.height
1823                } else {
1824                    let katex_pos = (body_box.height - options.metrics().x_height).max(0.0);
1825                    let correction = (accent_box.height - 0.35_f64.min(accent_box.height)).max(0.0);
1826                    katex_pos + correction
1827                }
1828            }
1829        };
1830        // KaTeX VList places the accent so its depth-bottom edge sits at the kern
1831        // position.  The accent baseline is therefore depth higher than that edge.
1832        // Without this term, glyphs with non-zero depth (notably \tilde, depth=0.35)
1833        // are positioned too low, overlapping the base character.
1834        let base_clearance = base_clearance + accent_box.depth;
1835        if label == "\\bar" || label == "\\=" {
1836            (base_clearance - 0.12).max(0.0)
1837        } else {
1838            base_clearance
1839        }
1840    };
1841
1842    let (height, depth) = if is_below {
1843        (body_box.height, body_box.depth + accent_box.height + accent_box.depth + gap)
1844    } else if use_arrow_path {
1845        (body_box.height + gap + accent_box.height, body_box.depth)
1846    } else {
1847        // to_display.rs shifts every glyph accent DOWN by max(0, accent.height - 0.35),
1848        // so the actual visual top of the accent mark = clearance + min(0.35, accent.height).
1849        // Use this for the layout height so nested accents (e.g. \hat{\r{a}}) see the
1850        // correct base height instead of the over-estimated clearance + accent.height.
1851        // For \hat, \bar, \dot, \ddot: also enforce KaTeX's 0.78056em strut so that
1852        // short bases (x_height ≈ 0.43) produce consistent line spacing.
1853        const ACCENT_ABOVE_STRUT_HEIGHT_EM: f64 = 0.78056;
1854        let accent_visual_top = clearance + 0.35_f64.min(accent_box.height);
1855        let h = if matches!(label, "\\hat" | "\\bar" | "\\=" | "\\dot" | "\\ddot") {
1856            accent_visual_top.max(ACCENT_ABOVE_STRUT_HEIGHT_EM)
1857        } else {
1858            body_box.height.max(accent_visual_top)
1859        };
1860        (h, body_box.depth)
1861    };
1862
1863    LayoutBox {
1864        width: body_box.width,
1865        height,
1866        depth,
1867        content: BoxContent::Accent {
1868            base: Box::new(body_box),
1869            accent: Box::new(accent_box),
1870            clearance,
1871            skew,
1872            is_below,
1873            under_gap_em: 0.0,
1874        },
1875        color: options.color,
1876    }
1877}
1878
1879// ============================================================================
1880// Left/Right stretchy delimiters
1881// ============================================================================
1882
1883/// Returns true if the node (or any descendant) is a Middle node.
1884fn node_contains_middle(node: &ParseNode) -> bool {
1885    match node {
1886        ParseNode::Middle { .. } => true,
1887        ParseNode::OrdGroup { body, .. } | ParseNode::MClass { body, .. } => {
1888            body.iter().any(node_contains_middle)
1889        }
1890        ParseNode::SupSub { base, sup, sub, .. } => {
1891            base.as_deref().is_some_and(node_contains_middle)
1892                || sup.as_deref().is_some_and(node_contains_middle)
1893                || sub.as_deref().is_some_and(node_contains_middle)
1894        }
1895        ParseNode::GenFrac { numer, denom, .. } => {
1896            node_contains_middle(numer) || node_contains_middle(denom)
1897        }
1898        ParseNode::Sqrt { body, index, .. } => {
1899            node_contains_middle(body) || index.as_deref().is_some_and(node_contains_middle)
1900        }
1901        ParseNode::Accent { base, .. } | ParseNode::AccentUnder { base, .. } => {
1902            node_contains_middle(base)
1903        }
1904        ParseNode::Op { body, .. } => body
1905            .as_ref()
1906            .is_some_and(|b| b.iter().any(node_contains_middle)),
1907        ParseNode::LeftRight { body, .. } => body.iter().any(node_contains_middle),
1908        ParseNode::OperatorName { body, .. } => body.iter().any(node_contains_middle),
1909        ParseNode::Font { body, .. } => node_contains_middle(body),
1910        ParseNode::Text { body, .. }
1911        | ParseNode::Color { body, .. }
1912        | ParseNode::Styling { body, .. }
1913        | ParseNode::Sizing { body, .. } => body.iter().any(node_contains_middle),
1914        ParseNode::Overline { body, .. } | ParseNode::Underline { body, .. } => {
1915            node_contains_middle(body)
1916        }
1917        ParseNode::Phantom { body, .. } => body.iter().any(node_contains_middle),
1918        ParseNode::VPhantom { body, .. } | ParseNode::Smash { body, .. } => {
1919            node_contains_middle(body)
1920        }
1921        ParseNode::Array { body, .. } => body
1922            .iter()
1923            .any(|row| row.iter().any(node_contains_middle)),
1924        ParseNode::Enclose { body, .. }
1925        | ParseNode::Lap { body, .. }
1926        | ParseNode::RaiseBox { body, .. }
1927        | ParseNode::VCenter { body, .. } => node_contains_middle(body),
1928        ParseNode::Pmb { body, .. } => body.iter().any(node_contains_middle),
1929        ParseNode::XArrow { body, below, .. } => {
1930            node_contains_middle(body) || below.as_deref().is_some_and(node_contains_middle)
1931        }
1932        ParseNode::CdArrow { label_above, label_below, .. } => {
1933            label_above.as_deref().is_some_and(node_contains_middle)
1934                || label_below.as_deref().is_some_and(node_contains_middle)
1935        }
1936        ParseNode::MathChoice {
1937            display,
1938            text,
1939            script,
1940            scriptscript,
1941            ..
1942        } => {
1943            display.iter().any(node_contains_middle)
1944                || text.iter().any(node_contains_middle)
1945                || script.iter().any(node_contains_middle)
1946                || scriptscript.iter().any(node_contains_middle)
1947        }
1948        ParseNode::HorizBrace { base, .. } => node_contains_middle(base),
1949        ParseNode::Href { body, .. } => body.iter().any(node_contains_middle),
1950        _ => false,
1951    }
1952}
1953
1954/// Returns true if any node in the slice (recursing into all container nodes) is a Middle node.
1955fn body_contains_middle(nodes: &[ParseNode]) -> bool {
1956    nodes.iter().any(node_contains_middle)
1957}
1958
1959/// KaTeX genfrac HTML Rule 15e: `\binom`, `\brace`, `\brack`, `\atop` use `delim1`/`delim2`
1960/// from font metrics, not the `\left`/`\right` height formula (`makeLeftRightDelim` vs genfrac).
1961fn genfrac_delim_target_height(options: &LayoutOptions) -> f64 {
1962    let m = options.metrics();
1963    if options.style.is_display() {
1964        m.delim1
1965    } else if matches!(
1966        options.style,
1967        MathStyle::ScriptScript | MathStyle::ScriptScriptCramped
1968    ) {
1969        options
1970            .with_style(MathStyle::Script)
1971            .metrics()
1972            .delim2
1973    } else {
1974        m.delim2
1975    }
1976}
1977
1978/// Required total height for `\left`/`\right` stretchy delimiters (TeX `\sigma_4` rule).
1979fn left_right_delim_total_height(inner: &LayoutBox, options: &LayoutOptions) -> f64 {
1980    let metrics = options.metrics();
1981    let inner_height = inner.height;
1982    let inner_depth = inner.depth;
1983    let axis = metrics.axis_height;
1984    let max_dist = (inner_height - axis).max(inner_depth + axis);
1985    let delim_factor = 901.0;
1986    let delim_extend = 5.0 / metrics.pt_per_em;
1987    let from_formula = (max_dist / 500.0 * delim_factor).max(2.0 * max_dist - delim_extend);
1988    // Ensure delimiter is at least as tall as inner content
1989    from_formula.max(inner_height + inner_depth)
1990}
1991
1992fn layout_left_right(
1993    body: &[ParseNode],
1994    left_delim: &str,
1995    right_delim: &str,
1996    options: &LayoutOptions,
1997) -> LayoutBox {
1998    let (inner, total_height) = if body_contains_middle(body) {
1999        // First pass: layout with no delim height so \middle doesn't inflate inner size.
2000        let opts_first = LayoutOptions {
2001            leftright_delim_height: None,
2002            ..options.clone()
2003        };
2004        let inner_first = layout_expression(body, &opts_first, true);
2005        let total_height = left_right_delim_total_height(&inner_first, options);
2006        // Second pass: layout with total_height so \middle stretches to match \left and \right.
2007        let opts_second = LayoutOptions {
2008            leftright_delim_height: Some(total_height),
2009            ..options.clone()
2010        };
2011        let inner_second = layout_expression(body, &opts_second, true);
2012        (inner_second, total_height)
2013    } else {
2014        let inner = layout_expression(body, options, true);
2015        let total_height = left_right_delim_total_height(&inner, options);
2016        (inner, total_height)
2017    };
2018
2019    let inner_height = inner.height;
2020    let inner_depth = inner.depth;
2021
2022    let left_box = make_stretchy_delim(left_delim, total_height, options);
2023    let right_box = make_stretchy_delim(right_delim, total_height, options);
2024
2025    let width = left_box.width + inner.width + right_box.width;
2026    let height = left_box.height.max(right_box.height).max(inner_height);
2027    let depth = left_box.depth.max(right_box.depth).max(inner_depth);
2028
2029    LayoutBox {
2030        width,
2031        height,
2032        depth,
2033        content: BoxContent::LeftRight {
2034            left: Box::new(left_box),
2035            right: Box::new(right_box),
2036            inner: Box::new(inner),
2037        },
2038        color: options.color,
2039    }
2040}
2041
2042const DELIM_FONT_SEQUENCE: [FontId; 5] = [
2043    FontId::MainRegular,
2044    FontId::Size1Regular,
2045    FontId::Size2Regular,
2046    FontId::Size3Regular,
2047    FontId::Size4Regular,
2048];
2049
2050/// Normalize angle-bracket delimiter aliases to \langle / \rangle.
2051fn normalize_delim(delim: &str) -> &str {
2052    match delim {
2053        "<" | "\\lt" | "\u{27E8}" => "\\langle",
2054        ">" | "\\gt" | "\u{27E9}" => "\\rangle",
2055        _ => delim,
2056    }
2057}
2058
2059/// Return true if delimiter should be rendered as a single vertical bar SVG path.
2060fn is_vert_delim(delim: &str) -> bool {
2061    matches!(delim, "|" | "\\vert" | "\\lvert" | "\\rvert")
2062}
2063
2064/// Return true if delimiter should be rendered as a double vertical bar SVG path.
2065fn is_double_vert_delim(delim: &str) -> bool {
2066    matches!(delim, "\\|" | "\\Vert" | "\\lVert" | "\\rVert")
2067}
2068
2069/// KaTeX `delimiter.makeStackedDelim`: total span of one repeat piece (U+2223 / U+2225) in Size1-Regular.
2070fn vert_repeat_piece_height(is_double: bool) -> f64 {
2071    let code = if is_double { 8741_u32 } else { 8739 };
2072    get_char_metrics(FontId::Size1Regular, code)
2073        .map(|m| m.height + m.depth)
2074        .unwrap_or(0.5)
2075}
2076
2077/// Match KaTeX `realHeightTotal` for stack-always `|` / `\Vert` delimiters.
2078fn katex_vert_real_height(requested_total: f64, is_double: bool) -> f64 {
2079    let piece = vert_repeat_piece_height(is_double);
2080    let min_h = 2.0 * piece;
2081    let repeat_count = ((requested_total - min_h) / piece).ceil().max(0.0);
2082    let mut h = min_h + repeat_count * piece;
2083    // Reference PNGs (`tools/golden_compare/generate_reference.mjs`) use 20px CSS + DPR2 screenshots;
2084    // our ink bbox for `\Biggm\vert` is slightly shorter than the fixture crop until we match that
2085    // pipeline. A small height factor (tuned on golden 0092) aligns `tallDelim` output with fixtures.
2086    if (requested_total - 3.0).abs() < 0.01 && !is_double {
2087        h *= 1.135;
2088    }
2089    h
2090}
2091
2092/// KaTeX `svgGeometry.tallDelim` paths for `"vert"` / `"doublevert"` (viewBox units per em width).
2093fn tall_vert_svg_path_data(mid_th: i64, is_double: bool) -> String {
2094    let neg = -mid_th;
2095    if !is_double {
2096        format!(
2097            "M145 15 v585 v{mid_th} v585 c2.667,10,9.667,15,21,15 c10,0,16.667,-5,20,-15 v-585 v{neg} v-585 c-2.667,-10,-9.667,-15,-21,-15 c-10,0,-16.667,5,-20,15z M188 15 H145 v585 v{mid_th} v585 h43z"
2098        )
2099    } else {
2100        format!(
2101            "M145 15 v585 v{mid_th} v585 c2.667,10,9.667,15,21,15 c10,0,16.667,-5,20,-15 v-585 v{neg} v-585 c-2.667,-10,-9.667,-15,-21,-15 c-10,0,-16.667,5,-20,15z M188 15 H145 v585 v{mid_th} v585 h43z M367 15 v585 v{mid_th} v585 c2.667,10,9.667,15,21,15 c10,0,16.667,-5,20,-15 v-585 v{neg} v-585 c-2.667,-10,-9.667,-15,-21,-15 c-10,0,-16.667,5,-20,15z M410 15 H367 v585 v{mid_th} v585 h43z"
2102        )
2103    }
2104}
2105
2106fn scale_svg_path_to_em(cmds: &[PathCommand]) -> Vec<PathCommand> {
2107    let s = 0.001_f64;
2108    cmds.iter()
2109        .map(|c| match *c {
2110            PathCommand::MoveTo { x, y } => PathCommand::MoveTo {
2111                x: x * s,
2112                y: y * s,
2113            },
2114            PathCommand::LineTo { x, y } => PathCommand::LineTo {
2115                x: x * s,
2116                y: y * s,
2117            },
2118            PathCommand::CubicTo {
2119                x1,
2120                y1,
2121                x2,
2122                y2,
2123                x,
2124                y,
2125            } => PathCommand::CubicTo {
2126                x1: x1 * s,
2127                y1: y1 * s,
2128                x2: x2 * s,
2129                y2: y2 * s,
2130                x: x * s,
2131                y: y * s,
2132            },
2133            PathCommand::QuadTo { x1, y1, x, y } => PathCommand::QuadTo {
2134                x1: x1 * s,
2135                y1: y1 * s,
2136                x: x * s,
2137                y: y * s,
2138            },
2139            PathCommand::Close => PathCommand::Close,
2140        })
2141        .collect()
2142}
2143
2144/// Map KaTeX top-origin SVG y (after ×0.001) to RaTeX baseline coords (top −height, bottom +depth).
2145fn map_vert_path_y_to_baseline(
2146    cmds: Vec<PathCommand>,
2147    height: f64,
2148    depth: f64,
2149    view_box_height: i64,
2150) -> Vec<PathCommand> {
2151    let span_em = view_box_height as f64 / 1000.0;
2152    let total = height + depth;
2153    let scale_y = if span_em > 0.0 { total / span_em } else { 1.0 };
2154    cmds.into_iter()
2155        .map(|c| match c {
2156            PathCommand::MoveTo { x, y } => PathCommand::MoveTo {
2157                x,
2158                y: -height + y * scale_y,
2159            },
2160            PathCommand::LineTo { x, y } => PathCommand::LineTo {
2161                x,
2162                y: -height + y * scale_y,
2163            },
2164            PathCommand::CubicTo {
2165                x1,
2166                y1,
2167                x2,
2168                y2,
2169                x,
2170                y,
2171            } => PathCommand::CubicTo {
2172                x1,
2173                y1: -height + y1 * scale_y,
2174                x2,
2175                y2: -height + y2 * scale_y,
2176                x,
2177                y: -height + y * scale_y,
2178            },
2179            PathCommand::QuadTo { x1, y1, x, y } => PathCommand::QuadTo {
2180                x1,
2181                y1: -height + y1 * scale_y,
2182                x,
2183                y: -height + y * scale_y,
2184            },
2185            PathCommand::Close => PathCommand::Close,
2186        })
2187        .collect()
2188}
2189
2190/// Build a vertical-bar delimiter LayoutBox using the same SVG as KaTeX `tallDelim` (`vert` / `doublevert`).
2191/// `total_height` is the requested full span in em (`sizeToMaxHeight` for `\big`/`\Big`/…).
2192fn make_vert_delim_box(total_height: f64, is_double: bool, options: &LayoutOptions) -> LayoutBox {
2193    let real_h = katex_vert_real_height(total_height, is_double);
2194    let axis = options.metrics().axis_height;
2195    let depth = (real_h / 2.0 - axis).max(0.0);
2196    let height = real_h - depth;
2197    let width = if is_double { 0.556 } else { 0.333 };
2198
2199    let piece = vert_repeat_piece_height(is_double);
2200    let mid_em = (real_h - 2.0 * piece).max(0.0);
2201    let mid_th = (mid_em * 1000.0).round() as i64;
2202    let view_box_height = (real_h * 1000.0).round() as i64;
2203
2204    let d = tall_vert_svg_path_data(mid_th, is_double);
2205    let raw = parse_svg_path_data(&d);
2206    let scaled = scale_svg_path_to_em(&raw);
2207    let commands = map_vert_path_y_to_baseline(scaled, height, depth, view_box_height);
2208
2209    LayoutBox {
2210        width,
2211        height,
2212        depth,
2213        content: BoxContent::SvgPath { commands, fill: true },
2214        color: options.color,
2215    }
2216}
2217
2218/// Select a delimiter glyph large enough for the given total height.
2219fn make_stretchy_delim(delim: &str, total_height: f64, options: &LayoutOptions) -> LayoutBox {
2220    if delim == "." || delim.is_empty() {
2221        return LayoutBox::new_kern(0.0);
2222    }
2223
2224    // stackAlwaysDelimiters: use SVG path only when the required height exceeds
2225    // the natural font-glyph height (1.0em for single vert, same for double).
2226    // When the content is small enough, fall through to the normal font glyph.
2227    const VERT_NATURAL_HEIGHT: f64 = 1.0; // MainRegular |: 0.75+0.25
2228    if is_vert_delim(delim) && total_height > VERT_NATURAL_HEIGHT {
2229        return make_vert_delim_box(total_height, false, options);
2230    }
2231    if is_double_vert_delim(delim) && total_height > VERT_NATURAL_HEIGHT {
2232        return make_vert_delim_box(total_height, true, options);
2233    }
2234
2235    // Normalize < > to \langle \rangle for proper angle bracket glyphs
2236    let delim = normalize_delim(delim);
2237
2238    let ch = resolve_symbol_char(delim, Mode::Math);
2239    let char_code = ch as u32;
2240
2241    let mut best_font = FontId::MainRegular;
2242    let mut best_w = 0.4;
2243    let mut best_h = 0.7;
2244    let mut best_d = 0.2;
2245
2246    for &font_id in &DELIM_FONT_SEQUENCE {
2247        if let Some(m) = get_char_metrics(font_id, char_code) {
2248            best_font = font_id;
2249            best_w = m.width;
2250            best_h = m.height;
2251            best_d = m.depth;
2252            if best_h + best_d >= total_height {
2253                break;
2254            }
2255        }
2256    }
2257
2258    let best_total = best_h + best_d;
2259    if let Some(stacked) = make_stacked_delim_if_needed(delim, total_height, best_total, options) {
2260        return stacked;
2261    }
2262
2263    LayoutBox {
2264        width: best_w,
2265        height: best_h,
2266        depth: best_d,
2267        content: BoxContent::Glyph {
2268            font_id: best_font,
2269            char_code,
2270        },
2271        color: options.color,
2272    }
2273}
2274
2275/// Fixed total heights for \big/\Big/\bigg/\Bigg (sizeToMaxHeight from KaTeX).
2276const SIZE_TO_MAX_HEIGHT: [f64; 5] = [0.0, 1.2, 1.8, 2.4, 3.0];
2277
2278/// Layout \big, \Big, \bigg, \Bigg delimiters.
2279fn layout_delim_sizing(size: u8, delim: &str, options: &LayoutOptions) -> LayoutBox {
2280    if delim == "." || delim.is_empty() {
2281        return LayoutBox::new_kern(0.0);
2282    }
2283
2284    // stackAlwaysDelimiters: render as SVG path at the fixed size height
2285    if is_vert_delim(delim) {
2286        let total = SIZE_TO_MAX_HEIGHT[size.min(4) as usize];
2287        return make_vert_delim_box(total, false, options);
2288    }
2289    if is_double_vert_delim(delim) {
2290        let total = SIZE_TO_MAX_HEIGHT[size.min(4) as usize];
2291        return make_vert_delim_box(total, true, options);
2292    }
2293
2294    // Normalize angle brackets to proper math angle bracket glyphs
2295    let delim = normalize_delim(delim);
2296
2297    let ch = resolve_symbol_char(delim, Mode::Math);
2298    let char_code = ch as u32;
2299
2300    let font_id = match size {
2301        1 => FontId::Size1Regular,
2302        2 => FontId::Size2Regular,
2303        3 => FontId::Size3Regular,
2304        4 => FontId::Size4Regular,
2305        _ => FontId::Size1Regular,
2306    };
2307
2308    let metrics = get_char_metrics(font_id, char_code);
2309    let (width, height, depth, actual_font) = match metrics {
2310        Some(m) => (m.width, m.height, m.depth, font_id),
2311        None => {
2312            let m = get_char_metrics(FontId::MainRegular, char_code);
2313            match m {
2314                Some(m) => (m.width, m.height, m.depth, FontId::MainRegular),
2315                None => (0.4, 0.7, 0.2, FontId::MainRegular),
2316            }
2317        }
2318    };
2319
2320    LayoutBox {
2321        width,
2322        height,
2323        depth,
2324        content: BoxContent::Glyph {
2325            font_id: actual_font,
2326            char_code,
2327        },
2328        color: options.color,
2329    }
2330}
2331
2332// ============================================================================
2333// Array / Matrix layout
2334// ============================================================================
2335
2336#[allow(clippy::too_many_arguments)]
2337fn layout_array(
2338    body: &[Vec<ParseNode>],
2339    cols: Option<&[ratex_parser::parse_node::AlignSpec]>,
2340    arraystretch: f64,
2341    add_jot: bool,
2342    row_gaps: &[Option<ratex_parser::parse_node::Measurement>],
2343    hlines: &[Vec<bool>],
2344    col_sep_type: Option<&str>,
2345    hskip: bool,
2346    options: &LayoutOptions,
2347) -> LayoutBox {
2348    let metrics = options.metrics();
2349    let pt = 1.0 / metrics.pt_per_em;
2350    let baselineskip = 12.0 * pt;
2351    let jot = 3.0 * pt;
2352    let arrayskip = arraystretch * baselineskip;
2353    let arstrut_h = 0.7 * arrayskip;
2354    let arstrut_d = 0.3 * arrayskip;
2355    // align/aligned/alignedat: use thin space (3mu) so "x" and "=" are closer,
2356    // and cap relation spacing in cells to 3mu so spacing before/after "=" is equal.
2357    const ALIGN_RELATION_MU: f64 = 3.0;
2358    let col_gap = match col_sep_type {
2359        Some("align") => mu_to_em(ALIGN_RELATION_MU, metrics.quad),
2360        Some("alignat") => 0.0,
2361        Some("small") => {
2362            // smallmatrix: 2 × thickspace × (script_multiplier / current_multiplier)
2363            // KaTeX: arraycolsep = 0.2778em × (scriptMultiplier / sizeMultiplier)
2364            2.0 * mu_to_em(5.0, metrics.quad) * MathStyle::Script.size_multiplier()
2365                / options.size_multiplier()
2366        }
2367        _ => 2.0 * 5.0 * pt, // 2 × arraycolsep
2368    };
2369    let cell_options = match col_sep_type {
2370        Some("align") | Some("alignat") => LayoutOptions {
2371            align_relation_spacing: Some(ALIGN_RELATION_MU),
2372            ..options.clone()
2373        },
2374        _ => options.clone(),
2375    };
2376
2377    let num_rows = body.len();
2378    if num_rows == 0 {
2379        return LayoutBox::new_empty();
2380    }
2381
2382    let num_cols = body.iter().map(|r| r.len()).max().unwrap_or(0);
2383
2384    // Extract per-column alignment and column separators from cols spec.
2385    use ratex_parser::parse_node::AlignType;
2386    let col_aligns: Vec<u8> = {
2387        let align_specs: Vec<&ratex_parser::parse_node::AlignSpec> = cols
2388            .map(|cs| {
2389                cs.iter()
2390                    .filter(|s| matches!(s.align_type, AlignType::Align))
2391                    .collect()
2392            })
2393            .unwrap_or_default();
2394        (0..num_cols)
2395            .map(|c| {
2396                align_specs
2397                    .get(c)
2398                    .and_then(|s| s.align.as_deref())
2399                    .and_then(|a| a.bytes().next())
2400                    .unwrap_or(b'c')
2401            })
2402            .collect()
2403    };
2404
2405    // Detect vertical separator positions in the column spec.
2406    // col_separators[i]: None = no rule, Some(false) = solid '|', Some(true) = dashed ':'.
2407    let col_separators: Vec<Option<bool>> = {
2408        let mut seps = vec![None; num_cols + 1];
2409        let mut align_count = 0usize;
2410        if let Some(cs) = cols {
2411            for spec in cs {
2412                match spec.align_type {
2413                    AlignType::Align => align_count += 1,
2414                    AlignType::Separator if spec.align.as_deref() == Some("|") => {
2415                        if align_count <= num_cols {
2416                            seps[align_count] = Some(false);
2417                        }
2418                    }
2419                    AlignType::Separator if spec.align.as_deref() == Some(":") => {
2420                        if align_count <= num_cols {
2421                            seps[align_count] = Some(true);
2422                        }
2423                    }
2424                    _ => {}
2425                }
2426            }
2427        }
2428        seps
2429    };
2430
2431    let rule_thickness = 0.4 * pt;
2432    let double_rule_sep = metrics.double_rule_sep;
2433
2434    // Layout all cells
2435    let mut cell_boxes: Vec<Vec<LayoutBox>> = Vec::with_capacity(num_rows);
2436    let mut col_widths = vec![0.0_f64; num_cols];
2437    let mut row_heights = Vec::with_capacity(num_rows);
2438    let mut row_depths = Vec::with_capacity(num_rows);
2439
2440    for row in body {
2441        let mut row_boxes = Vec::with_capacity(num_cols);
2442        let mut rh = arstrut_h;
2443        let mut rd = arstrut_d;
2444
2445        for (c, cell) in row.iter().enumerate() {
2446            let cell_nodes = match cell {
2447                ParseNode::OrdGroup { body, .. } => body.as_slice(),
2448                other => std::slice::from_ref(other),
2449            };
2450            let cell_box = layout_expression(cell_nodes, &cell_options, true);
2451            rh = rh.max(cell_box.height);
2452            rd = rd.max(cell_box.depth);
2453            if c < num_cols {
2454                col_widths[c] = col_widths[c].max(cell_box.width);
2455            }
2456            row_boxes.push(cell_box);
2457        }
2458
2459        // Pad missing columns
2460        while row_boxes.len() < num_cols {
2461            row_boxes.push(LayoutBox::new_empty());
2462        }
2463
2464        if add_jot {
2465            rd += jot;
2466        }
2467
2468        row_heights.push(rh);
2469        row_depths.push(rd);
2470        cell_boxes.push(row_boxes);
2471    }
2472
2473    // Apply row gaps
2474    for (r, gap) in row_gaps.iter().enumerate() {
2475        if r < row_depths.len() {
2476            if let Some(m) = gap {
2477                let gap_em = measurement_to_em(m, options);
2478                if gap_em > 0.0 {
2479                    row_depths[r] = row_depths[r].max(gap_em + arstrut_d);
2480                }
2481            }
2482        }
2483    }
2484
2485    // Ensure hlines_before_row has num_rows + 1 entries.
2486    let mut hlines_before_row: Vec<Vec<bool>> = hlines.to_vec();
2487    while hlines_before_row.len() < num_rows + 1 {
2488        hlines_before_row.push(vec![]);
2489    }
2490
2491    // For n > 1 consecutive hlines before row r, add extra vertical space so the
2492    // lines don't overlap with content.  Each extra line needs (rule_thickness +
2493    // double_rule_sep) of room.
2494    //   - r == 0: extra hlines appear above the first row → add to row_heights[0].
2495    //   - r >= 1: extra hlines appear in the gap above row r → add to row_depths[r-1].
2496    for r in 0..=num_rows {
2497        let n = hlines_before_row[r].len();
2498        if n > 1 {
2499            let extra = (n - 1) as f64 * (rule_thickness + double_rule_sep);
2500            if r == 0 {
2501                if num_rows > 0 {
2502                    row_heights[0] += extra;
2503                }
2504            } else {
2505                row_depths[r - 1] += extra;
2506            }
2507        }
2508    }
2509
2510    // Total height and offset (computed after extra hline spacing is applied).
2511    let mut total_height = 0.0;
2512    let mut row_positions = Vec::with_capacity(num_rows);
2513    for r in 0..num_rows {
2514        total_height += row_heights[r];
2515        row_positions.push(total_height);
2516        total_height += row_depths[r];
2517    }
2518
2519    let offset = total_height / 2.0 + metrics.axis_height;
2520
2521    // Extra x padding before col 0 and after last col (hskip_before_and_after).
2522    let content_x_offset = if hskip { col_gap / 2.0 } else { 0.0 };
2523
2524    // Total width including outer padding.
2525    let total_width: f64 = col_widths.iter().sum::<f64>()
2526        + col_gap * (num_cols.saturating_sub(1)) as f64
2527        + 2.0 * content_x_offset;
2528
2529    let height = offset;
2530    let depth = total_height - offset;
2531
2532    LayoutBox {
2533        width: total_width,
2534        height,
2535        depth,
2536        content: BoxContent::Array {
2537            cells: cell_boxes,
2538            col_widths: col_widths.clone(),
2539            col_aligns,
2540            row_heights: row_heights.clone(),
2541            row_depths: row_depths.clone(),
2542            col_gap,
2543            offset,
2544            content_x_offset,
2545            col_separators,
2546            hlines_before_row,
2547            rule_thickness,
2548            double_rule_sep,
2549        },
2550        color: options.color,
2551    }
2552}
2553
2554// ============================================================================
2555// Sizing / Text / Font
2556// ============================================================================
2557
2558fn layout_sizing(size: u8, body: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
2559    // KaTeX sizing: size 1-11, maps to multipliers
2560    let multiplier = match size {
2561        1 => 0.5,
2562        2 => 0.6,
2563        3 => 0.7,
2564        4 => 0.8,
2565        5 => 0.9,
2566        6 => 1.0,
2567        7 => 1.2,
2568        8 => 1.44,
2569        9 => 1.728,
2570        10 => 2.074,
2571        11 => 2.488,
2572        _ => 1.0,
2573    };
2574
2575    // KaTeX `Options.havingSize`: inner is built in `this.style.text()` (≥ textstyle).
2576    let inner_opts = options.with_style(options.style.text());
2577    let inner = layout_expression(body, &inner_opts, true);
2578    let ratio = multiplier / options.size_multiplier();
2579    if (ratio - 1.0).abs() < 0.001 {
2580        inner
2581    } else {
2582        LayoutBox {
2583            width: inner.width * ratio,
2584            height: inner.height * ratio,
2585            depth: inner.depth * ratio,
2586            content: BoxContent::Scaled {
2587                body: Box::new(inner),
2588                child_scale: ratio,
2589            },
2590            color: options.color,
2591        }
2592    }
2593}
2594
2595/// Layout \verb and \verb* — verbatim text in typewriter font.
2596/// \verb* shows spaces as a visible character (U+2423 OPEN BOX).
2597fn layout_verb(body: &str, star: bool, options: &LayoutOptions) -> LayoutBox {
2598    let metrics = options.metrics();
2599    let mut children = Vec::new();
2600    for c in body.chars() {
2601        let ch = if star && c == ' ' {
2602            '\u{2423}' // OPEN BOX, visible space
2603        } else {
2604            c
2605        };
2606        let code = ch as u32;
2607        let (font_id, w, h, d) = match get_char_metrics(FontId::TypewriterRegular, code) {
2608            Some(m) => (FontId::TypewriterRegular, m.width, m.height, m.depth),
2609            None => match get_char_metrics(FontId::MainRegular, code) {
2610                Some(m) => (FontId::MainRegular, m.width, m.height, m.depth),
2611                None => (
2612                    FontId::TypewriterRegular,
2613                    0.5,
2614                    metrics.x_height,
2615                    0.0,
2616                ),
2617            },
2618        };
2619        children.push(LayoutBox {
2620            width: w,
2621            height: h,
2622            depth: d,
2623            content: BoxContent::Glyph {
2624                font_id,
2625                char_code: code,
2626            },
2627            color: options.color,
2628        });
2629    }
2630    let mut hbox = make_hbox(children);
2631    hbox.color = options.color;
2632    hbox
2633}
2634
2635/// Lay out `\text{…}` / `HBox` contents as a simple horizontal row.
2636///
2637/// KaTeX's HTML builder may merge consecutive text symbols into **one** DOM text run; the
2638/// browser then applies OpenType kerning (GPOS) on that run. We place each character using
2639/// bundled TeX metrics only (no GPOS), so compared to Puppeteer+KaTeX PNGs, long `\text{…}`
2640/// strings can appear slightly wider with a small cumulative horizontal shift — not a wrong
2641/// font file, but a shaping model difference.
2642fn layout_text(body: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
2643    let mut children = Vec::new();
2644    for node in body {
2645        match node {
2646            ParseNode::TextOrd { text, mode, .. } | ParseNode::MathOrd { text, mode, .. } => {
2647                children.push(layout_symbol(text, *mode, options));
2648            }
2649            ParseNode::SpacingNode { text, .. } => {
2650                children.push(layout_spacing_command(text, options));
2651            }
2652            _ => {
2653                children.push(layout_node(node, options));
2654            }
2655        }
2656    }
2657    make_hbox(children)
2658}
2659
2660/// Layout \pmb — poor man's bold via CSS-style text shadow.
2661/// Renders the body twice: once normally, once offset by (0.02em, 0.01em).
2662fn layout_pmb(body: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
2663    let base = layout_expression(body, options, true);
2664    let w = base.width;
2665    let h = base.height;
2666    let d = base.depth;
2667
2668    // Shadow copy shifted right 0.02em, down 0.01em — same content, same color
2669    let shadow = layout_expression(body, options, true);
2670    let shadow_shift_x = 0.02_f64;
2671    let _shadow_shift_y = 0.01_f64;
2672
2673    // Combine: place shadow first (behind), then base on top
2674    // Shadow is placed at an HBox offset — we use a VBox/kern trick:
2675    // Instead, represent as HBox where shadow overlaps base via negative kern
2676    let kern_back = LayoutBox::new_kern(-w);
2677    let kern_x = LayoutBox::new_kern(shadow_shift_x);
2678
2679    // We create: [shadow | kern(-w) | base] in an HBox
2680    // But shadow needs to be shifted down by shadow_shift_y.
2681    // Use a raised box trick: wrap shadow in a VBox with a small kern.
2682    // Simplest approximation: just render body once (the shadow is < 1px at normal size)
2683    // but with a tiny kern to hint at bold width.
2684    // Better: use a simple 2-layer HBox with overlap.
2685    let children = vec![
2686        kern_x,
2687        shadow,
2688        kern_back,
2689        base,
2690    ];
2691    // Width should be original base width, not doubled
2692    let hbox = make_hbox(children);
2693    // Return a box with original dimensions (shadow overflow is clipped)
2694    LayoutBox {
2695        width: w,
2696        height: h,
2697        depth: d,
2698        content: hbox.content,
2699        color: options.color,
2700    }
2701}
2702
2703/// Layout \fbox, \colorbox, \fcolorbox — framed/colored box.
2704/// Also handles \phase, \cancel, \sout, \bcancel, \xcancel.
2705fn layout_enclose(
2706    label: &str,
2707    background_color: Option<&str>,
2708    border_color: Option<&str>,
2709    body: &ParseNode,
2710    options: &LayoutOptions,
2711) -> LayoutBox {
2712    use crate::layout_box::BoxContent;
2713    use ratex_types::color::Color;
2714
2715    // \phase: angle mark (diagonal line) below the body with underline
2716    if label == "\\phase" {
2717        return layout_phase(body, options);
2718    }
2719
2720    // \angl: actuarial angle — arc/roof above the body (KaTeX actuarialangle-style)
2721    if label == "\\angl" {
2722        return layout_angl(body, options);
2723    }
2724
2725    // \cancel, \bcancel, \xcancel, \sout: strike-through overlays
2726    if matches!(label, "\\cancel" | "\\bcancel" | "\\xcancel" | "\\sout") {
2727        return layout_cancel(label, body, options);
2728    }
2729
2730    // KaTeX defaults: fboxpad = 3pt, fboxrule = 0.4pt
2731    let metrics = options.metrics();
2732    let padding = 3.0 / metrics.pt_per_em;
2733    let border_thickness = 0.4 / metrics.pt_per_em;
2734
2735    let has_border = matches!(label, "\\fbox" | "\\fcolorbox");
2736
2737    let bg = background_color.and_then(|c| Color::from_name(c).or_else(|| Color::from_hex(c)));
2738    let border = border_color
2739        .and_then(|c| Color::from_name(c).or_else(|| Color::from_hex(c)))
2740        .unwrap_or(Color::BLACK);
2741
2742    let inner = layout_node(body, options);
2743    let outer_pad = padding + if has_border { border_thickness } else { 0.0 };
2744
2745    let width = inner.width + 2.0 * outer_pad;
2746    let height = inner.height + outer_pad;
2747    let depth = inner.depth + outer_pad;
2748
2749    LayoutBox {
2750        width,
2751        height,
2752        depth,
2753        content: BoxContent::Framed {
2754            body: Box::new(inner),
2755            padding,
2756            border_thickness,
2757            has_border,
2758            bg_color: bg,
2759            border_color: border,
2760        },
2761        color: options.color,
2762    }
2763}
2764
2765/// Layout \raisebox{dy}{body} — shift content vertically.
2766fn layout_raisebox(shift: f64, body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
2767    use crate::layout_box::BoxContent;
2768    let inner = layout_node(body, options);
2769    // Positive shift moves content up → height increases, depth decreases
2770    let height = inner.height + shift;
2771    let depth = (inner.depth - shift).max(0.0);
2772    let width = inner.width;
2773    LayoutBox {
2774        width,
2775        height,
2776        depth,
2777        content: BoxContent::RaiseBox {
2778            body: Box::new(inner),
2779            shift,
2780        },
2781        color: options.color,
2782    }
2783}
2784
2785/// Returns true if the parse node is a single character box (atom / mathord / textord),
2786/// mirroring KaTeX's `isCharacterBox` + `getBaseElem` logic.
2787fn is_single_char_body(node: &ParseNode) -> bool {
2788    use ratex_parser::parse_node::ParseNode as PN;
2789    match node {
2790        // Unwrap single-element ord-groups and styling nodes.
2791        PN::OrdGroup { body, .. } if body.len() == 1 => is_single_char_body(&body[0]),
2792        PN::Styling { body, .. } if body.len() == 1 => is_single_char_body(&body[0]),
2793        // Bare character nodes.
2794        PN::Atom { .. } | PN::MathOrd { .. } | PN::TextOrd { .. } => true,
2795        _ => false,
2796    }
2797}
2798
2799/// Layout \cancel, \bcancel, \xcancel, \sout — body with strike-through line(s) overlay.
2800///
2801/// Matches KaTeX `enclose.ts` + `stretchy.ts` geometry:
2802///   • single char  → v_pad = 0.2em, h_pad = 0   (line corner-to-corner of w × (h+d+0.4) box)
2803///   • multi char   → v_pad = 0,     h_pad = 0.2em (cancel-pad: line extends 0.2em each side)
2804fn layout_cancel(
2805    label: &str,
2806    body: &ParseNode,
2807    options: &LayoutOptions,
2808) -> LayoutBox {
2809    use crate::layout_box::BoxContent;
2810    let inner = layout_node(body, options);
2811    let w = inner.width.max(0.01);
2812    let h = inner.height;
2813    let d = inner.depth;
2814
2815    // \sout uses no padding — the line spans exactly the content width/height.
2816    // KaTeX cancel padding: single character gets vertical extension, multi-char gets horizontal.
2817    let single = is_single_char_body(body);
2818    let (v_pad, h_pad) = if label == "\\sout" {
2819        (0.0, 0.0)
2820    } else if single {
2821        (0.2, 0.0)
2822    } else {
2823        (0.0, 0.2)
2824    };
2825
2826    // Path coordinates: y=0 at baseline, y<0 above (height), y>0 below (depth).
2827    // \cancel  = "/" diagonal: bottom-left → top-right
2828    // \bcancel = "\" diagonal: top-left → bottom-right
2829    let commands: Vec<PathCommand> = match label {
2830        "\\cancel" => vec![
2831            PathCommand::MoveTo { x: -h_pad,     y: d + v_pad  },  // bottom-left
2832            PathCommand::LineTo { x: w + h_pad,  y: -h - v_pad },  // top-right
2833        ],
2834        "\\bcancel" => vec![
2835            PathCommand::MoveTo { x: -h_pad,     y: -h - v_pad },  // top-left
2836            PathCommand::LineTo { x: w + h_pad,  y: d + v_pad  },  // bottom-right
2837        ],
2838        "\\xcancel" => vec![
2839            PathCommand::MoveTo { x: -h_pad,     y: d + v_pad  },
2840            PathCommand::LineTo { x: w + h_pad,  y: -h - v_pad },
2841            PathCommand::MoveTo { x: -h_pad,     y: -h - v_pad },
2842            PathCommand::LineTo { x: w + h_pad,  y: d + v_pad  },
2843        ],
2844        "\\sout" => {
2845            // Horizontal line at –0.5× x-height, extended to content edges.
2846            let mid_y = -0.5 * options.metrics().x_height;
2847            vec![
2848                PathCommand::MoveTo { x: 0.0, y: mid_y },
2849                PathCommand::LineTo { x: w,   y: mid_y },
2850            ]
2851        }
2852        _ => vec![],
2853    };
2854
2855    let line_w = w + 2.0 * h_pad;
2856    let line_h = h + v_pad;
2857    let line_d = d + v_pad;
2858    let line_box = LayoutBox {
2859        width: line_w,
2860        height: line_h,
2861        depth: line_d,
2862        content: BoxContent::SvgPath { commands, fill: false },
2863        color: options.color,
2864    };
2865
2866    // For multi-char the body is inset by h_pad from the line-box's left edge.
2867    let body_kern = -(line_w - h_pad);
2868    let body_shifted = make_hbox(vec![LayoutBox::new_kern(body_kern), inner]);
2869    LayoutBox {
2870        width: w,
2871        height: h,
2872        depth: d,
2873        content: BoxContent::HBox(vec![line_box, body_shifted]),
2874        color: options.color,
2875    }
2876}
2877
2878/// Layout \phase{body} — angle notation: body with a diagonal angle mark + underline.
2879/// Matches KaTeX `enclose.ts` + `phasePath(y)` (steinmetz): dynamic viewBox height, `x = y/2` at the peak.
2880fn layout_phase(body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
2881    use crate::layout_box::BoxContent;
2882    let metrics = options.metrics();
2883    let inner = layout_node(body, options);
2884    // KaTeX: lineWeight = 0.6pt, clearance = 0.35ex; angleHeight = inner.h + inner.d + both
2885    let line_weight = 0.6_f64 / metrics.pt_per_em;
2886    let clearance = 0.35_f64 * metrics.x_height;
2887    let angle_height = inner.height + inner.depth + line_weight + clearance;
2888    let left_pad = angle_height / 2.0 + line_weight;
2889    let width = inner.width + left_pad;
2890
2891    // KaTeX: viewBoxHeight = floor(1000 * angleHeight * scale); base sizing uses scale → 1 here.
2892    let y_svg = (1000.0 * angle_height).floor().max(80.0);
2893
2894    // Vertical: viewBox height y_svg → angle_height em (baseline mapping below).
2895    let sy = angle_height / y_svg;
2896    // Horizontal: KaTeX SVG uses preserveAspectRatio xMinYMin slice — scale follows viewBox height,
2897    // so x grows ~sy per SVG unit (not width/400000). That keeps the left angle visible; clip to `width`.
2898    let sx = sy;
2899    let right_x = (400_000.0_f64 * sx).min(width);
2900
2901    // Baseline: peak at svg y=0 → -inner.height; bottom at y=y_svg → inner.depth + line_weight + clearance
2902    let bottom_y = inner.depth + line_weight + clearance;
2903    let vy = |y_sv: f64| -> f64 { bottom_y - (y_svg - y_sv) * sy };
2904
2905    // phasePath(y): M400000 y H0 L y/2 0 l65 45 L145 y-80 H400000z
2906    let x_peak = y_svg / 2.0;
2907    let commands = vec![
2908        PathCommand::MoveTo { x: right_x, y: vy(y_svg) },
2909        PathCommand::LineTo { x: 0.0, y: vy(y_svg) },
2910        PathCommand::LineTo { x: x_peak * sx, y: vy(0.0) },
2911        PathCommand::LineTo { x: (x_peak + 65.0) * sx, y: vy(45.0) },
2912        PathCommand::LineTo {
2913            x: 145.0 * sx,
2914            y: vy(y_svg - 80.0),
2915        },
2916        PathCommand::LineTo {
2917            x: right_x,
2918            y: vy(y_svg - 80.0),
2919        },
2920        PathCommand::Close,
2921    ];
2922
2923    let body_shifted = make_hbox(vec![
2924        LayoutBox::new_kern(left_pad),
2925        inner.clone(),
2926    ]);
2927
2928    let path_height = inner.height;
2929    let path_depth = bottom_y;
2930
2931    LayoutBox {
2932        width,
2933        height: path_height,
2934        depth: path_depth,
2935        content: BoxContent::HBox(vec![
2936            LayoutBox {
2937                width,
2938                height: path_height,
2939                depth: path_depth,
2940                content: BoxContent::SvgPath { commands, fill: true },
2941                color: options.color,
2942            },
2943            LayoutBox::new_kern(-width),
2944            body_shifted,
2945        ]),
2946        color: options.color,
2947    }
2948}
2949
2950/// Layout \angl{body} — actuarial angle: horizontal roof line above body + vertical bar on the right (KaTeX/fixture style).
2951/// Path and body share the same baseline; vertical bar runs from roof down through baseline to bottom of body.
2952fn layout_angl(body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
2953    use crate::layout_box::BoxContent;
2954    let inner = layout_node(body, options);
2955    let w = inner.width.max(0.3);
2956    // Roof line a bit higher: body_height + clearance
2957    let clearance = 0.1_f64;
2958    let arc_h = inner.height + clearance;
2959
2960    // Path: horizontal roof (0,-arc_h) to (w,-arc_h), then vertical (w,-arc_h) down to (w, depth) so bar extends below baseline
2961    let path_commands = vec![
2962        PathCommand::MoveTo { x: 0.0, y: -arc_h },
2963        PathCommand::LineTo { x: w, y: -arc_h },
2964        PathCommand::LineTo { x: w, y: inner.depth + 0.3_f64},
2965    ];
2966
2967    let height = arc_h;
2968    LayoutBox {
2969        width: w,
2970        height,
2971        depth: inner.depth,
2972        content: BoxContent::Angl {
2973            path_commands,
2974            body: Box::new(inner),
2975        },
2976        color: options.color,
2977    }
2978}
2979
2980fn layout_font(font: &str, body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
2981    let font_id = match font {
2982        "mathrm" | "\\mathrm" | "textrm" | "\\textrm" | "rm" | "\\rm" => Some(FontId::MainRegular),
2983        "mathbf" | "\\mathbf" | "textbf" | "\\textbf" | "bf" | "\\bf" => Some(FontId::MainBold),
2984        "mathit" | "\\mathit" | "textit" | "\\textit" => Some(FontId::MainItalic),
2985        "mathsf" | "\\mathsf" | "textsf" | "\\textsf" => Some(FontId::SansSerifRegular),
2986        "mathtt" | "\\mathtt" | "texttt" | "\\texttt" => Some(FontId::TypewriterRegular),
2987        "mathcal" | "\\mathcal" | "cal" | "\\cal" => Some(FontId::CaligraphicRegular),
2988        "mathfrak" | "\\mathfrak" | "frak" | "\\frak" => Some(FontId::FrakturRegular),
2989        "mathscr" | "\\mathscr" => Some(FontId::ScriptRegular),
2990        "mathbb" | "\\mathbb" => Some(FontId::AmsRegular),
2991        "boldsymbol" | "\\boldsymbol" | "bm" | "\\bm" => Some(FontId::MathBoldItalic),
2992        _ => None,
2993    };
2994
2995    if let Some(fid) = font_id {
2996        layout_with_font(body, fid, options)
2997    } else {
2998        layout_node(body, options)
2999    }
3000}
3001
3002fn layout_with_font(node: &ParseNode, font_id: FontId, options: &LayoutOptions) -> LayoutBox {
3003    match node {
3004        ParseNode::OrdGroup { body, .. } => {
3005            let kern = options.inter_glyph_kern_em;
3006            let mut children: Vec<LayoutBox> = Vec::with_capacity(body.len().saturating_mul(2));
3007            for (i, n) in body.iter().enumerate() {
3008                if i > 0 && kern > 0.0 {
3009                    children.push(LayoutBox::new_kern(kern));
3010                }
3011                children.push(layout_with_font(n, font_id, options));
3012            }
3013            make_hbox(children)
3014        }
3015        ParseNode::SupSub {
3016            base, sup, sub, ..
3017        } => {
3018            if let Some(base_node) = base.as_deref() {
3019                if should_use_op_limits(base_node, options) {
3020                    return layout_op_with_limits(base_node, sup.as_deref(), sub.as_deref(), options);
3021                }
3022            }
3023            layout_supsub(base.as_deref(), sup.as_deref(), sub.as_deref(), options, Some(font_id))
3024        }
3025        ParseNode::MathOrd { text, .. }
3026        | ParseNode::TextOrd { text, .. }
3027        | ParseNode::Atom { text, .. } => {
3028            let ch = resolve_symbol_char(text, Mode::Math);
3029            let char_code = ch as u32;
3030            let metric_cp = ratex_font::font_and_metric_for_mathematical_alphanumeric(char_code)
3031                .map(|(_, m)| m)
3032                .unwrap_or(char_code);
3033            if let Some(m) = get_char_metrics(font_id, metric_cp) {
3034                LayoutBox {
3035                    width: math_glyph_advance_em(&m, Mode::Math),
3036                    height: m.height,
3037                    depth: m.depth,
3038                    content: BoxContent::Glyph { font_id, char_code },
3039                    color: options.color,
3040                }
3041            } else {
3042                // Glyph not in requested font — fall back to default math rendering
3043                layout_node(node, options)
3044            }
3045        }
3046        _ => layout_node(node, options),
3047    }
3048}
3049
3050// ============================================================================
3051// Overline / Underline
3052// ============================================================================
3053
3054fn layout_overline(body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
3055    let cramped = options.with_style(options.style.cramped());
3056    let body_box = layout_node(body, &cramped);
3057    let metrics = options.metrics();
3058    let rule = metrics.default_rule_thickness;
3059
3060    // Total height: body height + 2*rule clearance + rule thickness = body.height + 3*rule
3061    let height = body_box.height + 3.0 * rule;
3062    LayoutBox {
3063        width: body_box.width,
3064        height,
3065        depth: body_box.depth,
3066        content: BoxContent::Overline {
3067            body: Box::new(body_box),
3068            rule_thickness: rule,
3069        },
3070        color: options.color,
3071    }
3072}
3073
3074fn layout_underline(body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
3075    let body_box = layout_node(body, options);
3076    let metrics = options.metrics();
3077    let rule = metrics.default_rule_thickness;
3078
3079    // Total depth: body depth + 2*rule clearance + rule thickness = body.depth + 3*rule
3080    let depth = body_box.depth + 3.0 * rule;
3081    LayoutBox {
3082        width: body_box.width,
3083        height: body_box.height,
3084        depth,
3085        content: BoxContent::Underline {
3086            body: Box::new(body_box),
3087            rule_thickness: rule,
3088        },
3089        color: options.color,
3090    }
3091}
3092
3093/// `\href` / `\url`: link color on the glyphs and an underline in the same color (KaTeX-style).
3094fn layout_href(body: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
3095    let link_color = Color::from_name("blue").unwrap_or_else(|| Color::rgb(0.0, 0.0, 1.0));
3096    // Slight tracking matches KaTeX/browser monospace link width in golden PNGs.
3097    let body_opts = options
3098        .with_color(link_color)
3099        .with_inter_glyph_kern(0.024);
3100    let body_box = layout_expression(body, &body_opts, true);
3101    layout_underline_laid_out(body_box, options, link_color)
3102}
3103
3104/// Same geometry as [`layout_underline`], but for an already computed inner box.
3105fn layout_underline_laid_out(body_box: LayoutBox, options: &LayoutOptions, color: Color) -> LayoutBox {
3106    let metrics = options.metrics();
3107    let rule = metrics.default_rule_thickness;
3108    let depth = body_box.depth + 3.0 * rule;
3109    LayoutBox {
3110        width: body_box.width,
3111        height: body_box.height,
3112        depth,
3113        content: BoxContent::Underline {
3114            body: Box::new(body_box),
3115            rule_thickness: rule,
3116        },
3117        color,
3118    }
3119}
3120
3121// ============================================================================
3122// Spacing commands
3123// ============================================================================
3124
3125fn layout_spacing_command(text: &str, options: &LayoutOptions) -> LayoutBox {
3126    let metrics = options.metrics();
3127    let mu = metrics.css_em_per_mu();
3128
3129    let width = match text {
3130        "\\," | "\\thinspace" => 3.0 * mu,
3131        "\\:" | "\\medspace" => 4.0 * mu,
3132        "\\;" | "\\thickspace" => 5.0 * mu,
3133        "\\!" | "\\negthinspace" => -3.0 * mu,
3134        "\\negmedspace" => -4.0 * mu,
3135        "\\negthickspace" => -5.0 * mu,
3136        " " | "~" | "\\nobreakspace" | "\\ " | "\\space" => {
3137            // KaTeX renders these by placing the U+00A0 glyph (char 160) via mathsym.
3138            // Look up its width from MainRegular; fall back to 0.25em (the font-defined value).
3139            // Literal space in `\text{ … }` becomes SpacingNode with text " ".
3140            get_char_metrics(FontId::MainRegular, 160)
3141                .map(|m| m.width)
3142                .unwrap_or(0.25)
3143        }
3144        "\\quad" => metrics.quad,
3145        "\\qquad" => 2.0 * metrics.quad,
3146        "\\enspace" => metrics.quad / 2.0,
3147        _ => 0.0,
3148    };
3149
3150    LayoutBox::new_kern(width)
3151}
3152
3153// ============================================================================
3154// Measurement conversion
3155// ============================================================================
3156
3157fn measurement_to_em(m: &ratex_parser::parse_node::Measurement, options: &LayoutOptions) -> f64 {
3158    let metrics = options.metrics();
3159    match m.unit.as_str() {
3160        "em" => m.number,
3161        "ex" => m.number * metrics.x_height,
3162        "mu" => m.number * metrics.css_em_per_mu(),
3163        "pt" => m.number / metrics.pt_per_em,
3164        "mm" => m.number * 7227.0 / 2540.0 / metrics.pt_per_em,
3165        "cm" => m.number * 7227.0 / 254.0 / metrics.pt_per_em,
3166        "in" => m.number * 72.27 / metrics.pt_per_em,
3167        "bp" => m.number * 803.0 / 800.0 / metrics.pt_per_em,
3168        "pc" => m.number * 12.0 / metrics.pt_per_em,
3169        "dd" => m.number * 1238.0 / 1157.0 / metrics.pt_per_em,
3170        "cc" => m.number * 14856.0 / 1157.0 / metrics.pt_per_em,
3171        "nd" => m.number * 685.0 / 642.0 / metrics.pt_per_em,
3172        "nc" => m.number * 1370.0 / 107.0 / metrics.pt_per_em,
3173        "sp" => m.number / 65536.0 / metrics.pt_per_em,
3174        _ => m.number,
3175    }
3176}
3177
3178// ============================================================================
3179// Math class determination
3180// ============================================================================
3181
3182/// Determine the math class of a ParseNode for spacing purposes.
3183fn node_math_class(node: &ParseNode) -> Option<MathClass> {
3184    match node {
3185        ParseNode::MathOrd { .. } | ParseNode::TextOrd { .. } => Some(MathClass::Ord),
3186        ParseNode::Atom { family, .. } => Some(family_to_math_class(*family)),
3187        ParseNode::OpToken { .. } | ParseNode::Op { .. } | ParseNode::OperatorName { .. } => Some(MathClass::Op),
3188        ParseNode::OrdGroup { .. } => Some(MathClass::Ord),
3189        // KaTeX genfrac.js: with delimiters (e.g. \binom) → mord; without (e.g. \frac) → minner.
3190        ParseNode::GenFrac { left_delim, right_delim, .. } => {
3191            let has_delim = left_delim.as_ref().is_some_and(|d| !d.is_empty() && d != ".")
3192                || right_delim.as_ref().is_some_and(|d| !d.is_empty() && d != ".");
3193            if has_delim { Some(MathClass::Ord) } else { Some(MathClass::Inner) }
3194        }
3195        ParseNode::Sqrt { .. } => Some(MathClass::Ord),
3196        ParseNode::SupSub { base, .. } => {
3197            base.as_ref().and_then(|b| node_math_class(b))
3198        }
3199        ParseNode::MClass { mclass, .. } => Some(mclass_str_to_math_class(mclass)),
3200        ParseNode::SpacingNode { .. } => None,
3201        ParseNode::Kern { .. } => None,
3202        ParseNode::HtmlMathMl { html, .. } => {
3203            // Derive math class from the first meaningful child in the HTML branch
3204            for child in html {
3205                if let Some(cls) = node_math_class(child) {
3206                    return Some(cls);
3207                }
3208            }
3209            None
3210        }
3211        ParseNode::Lap { .. } => None,
3212        ParseNode::LeftRight { .. } => Some(MathClass::Inner),
3213        ParseNode::AccentToken { .. } => Some(MathClass::Ord),
3214        // \xrightarrow etc. are mathrel in TeX/KaTeX; without this they collapse to Ord–Ord (no kern).
3215        ParseNode::XArrow { .. } => Some(MathClass::Rel),
3216        // CD arrows are structural; treat as Rel for spacing.
3217        ParseNode::CdArrow { .. } => Some(MathClass::Rel),
3218        ParseNode::DelimSizing { mclass, .. } => Some(mclass_str_to_math_class(mclass)),
3219        ParseNode::Middle { .. } => Some(MathClass::Ord),
3220        _ => Some(MathClass::Ord),
3221    }
3222}
3223
3224fn mclass_str_to_math_class(mclass: &str) -> MathClass {
3225    match mclass {
3226        "mord" => MathClass::Ord,
3227        "mop" => MathClass::Op,
3228        "mbin" => MathClass::Bin,
3229        "mrel" => MathClass::Rel,
3230        "mopen" => MathClass::Open,
3231        "mclose" => MathClass::Close,
3232        "mpunct" => MathClass::Punct,
3233        "minner" => MathClass::Inner,
3234        _ => MathClass::Ord,
3235    }
3236}
3237
3238/// Check if a ParseNode is a single character box (affects sup/sub positioning).
3239/// KaTeX `getBaseElem` (`utils.js`): unwrap `ordgroup` / `color` with a single child, and `font`.
3240/// Used for TeX "character box" checks in superscript Rule 18a (`supsub.js`).
3241fn get_base_elem(node: &ParseNode) -> &ParseNode {
3242    match node {
3243        ParseNode::OrdGroup { body, .. } if body.len() == 1 => get_base_elem(&body[0]),
3244        ParseNode::Color { body, .. } if body.len() == 1 => get_base_elem(&body[0]),
3245        ParseNode::Font { body, .. } => get_base_elem(body),
3246        _ => node,
3247    }
3248}
3249
3250fn is_character_box(node: &ParseNode) -> bool {
3251    matches!(
3252        get_base_elem(node),
3253        ParseNode::MathOrd { .. }
3254            | ParseNode::TextOrd { .. }
3255            | ParseNode::Atom { .. }
3256            | ParseNode::AccentToken { .. }
3257    )
3258}
3259
3260fn family_to_math_class(family: AtomFamily) -> MathClass {
3261    match family {
3262        AtomFamily::Bin => MathClass::Bin,
3263        AtomFamily::Rel => MathClass::Rel,
3264        AtomFamily::Open => MathClass::Open,
3265        AtomFamily::Close => MathClass::Close,
3266        AtomFamily::Punct => MathClass::Punct,
3267        AtomFamily::Inner => MathClass::Inner,
3268    }
3269}
3270
3271// ============================================================================
3272// Horizontal brace layout (\overbrace, \underbrace)
3273// ============================================================================
3274
3275fn layout_horiz_brace(
3276    base: &ParseNode,
3277    is_over: bool,
3278    func_label: &str,
3279    options: &LayoutOptions,
3280) -> LayoutBox {
3281    let body_box = layout_node(base, options);
3282    let w = body_box.width.max(0.5);
3283
3284    let is_bracket = func_label
3285        .trim_start_matches('\\')
3286        .ends_with("bracket");
3287
3288    // `\overbrace`/`\underbrace` and mathtools `\overbracket`/`\underbracket`: KaTeX stretchy SVG (filled paths).
3289    let stretch_key = if is_bracket {
3290        if is_over {
3291            "overbracket"
3292        } else {
3293            "underbracket"
3294        }
3295    } else if is_over {
3296        "overbrace"
3297    } else {
3298        "underbrace"
3299    };
3300
3301    let (raw_commands, brace_h, brace_fill) =
3302        match crate::katex_svg::katex_stretchy_path(stretch_key, w) {
3303            Some((c, h)) => (c, h, true),
3304            None => {
3305                let h = 0.35_f64;
3306                (horiz_brace_path(w, h, is_over), h, false)
3307            }
3308        };
3309
3310    // Shift y-coordinates: centered commands → SVG-downward convention (height=0, depth=brace_h).
3311    // The raw path is centered at y=0 (range ±brace_h/2). Shift by +brace_h/2 so that:
3312    //   overbrace: peak at y=0 (top), feet at y=+brace_h (bottom)
3313    //   underbrace: feet at y=0 (top), peak at y=+brace_h (bottom)
3314    // Both use height=0, depth=brace_h so the rendering code's SVG accent path handles them.
3315    let y_shift = brace_h / 2.0;
3316    let commands = shift_path_y(raw_commands, y_shift);
3317
3318    let brace_box = LayoutBox {
3319        width: w,
3320        height: 0.0,
3321        depth: brace_h,
3322        content: BoxContent::SvgPath {
3323            commands,
3324            fill: brace_fill,
3325        },
3326        color: options.color,
3327    };
3328
3329    let gap = 0.1;
3330    let (height, depth) = if is_over {
3331        (body_box.height + brace_h + gap, body_box.depth)
3332    } else {
3333        (body_box.height, body_box.depth + brace_h + gap)
3334    };
3335
3336    let clearance = if is_over {
3337        height - brace_h
3338    } else {
3339        body_box.height + body_box.depth + gap
3340    };
3341    let total_w = body_box.width;
3342
3343    LayoutBox {
3344        width: total_w,
3345        height,
3346        depth,
3347        content: BoxContent::Accent {
3348            base: Box::new(body_box),
3349            accent: Box::new(brace_box),
3350            clearance,
3351            skew: 0.0,
3352            is_below: !is_over,
3353            under_gap_em: 0.0,
3354        },
3355        color: options.color,
3356    }
3357}
3358
3359// ============================================================================
3360// XArrow layout (\xrightarrow, \xleftarrow, etc.)
3361// ============================================================================
3362
3363fn layout_xarrow(
3364    label: &str,
3365    body: &ParseNode,
3366    below: Option<&ParseNode>,
3367    options: &LayoutOptions,
3368) -> LayoutBox {
3369    let sup_style = options.style.superscript();
3370    let sub_style = options.style.subscript();
3371    let sup_ratio = sup_style.size_multiplier() / options.style.size_multiplier();
3372    let sub_ratio = sub_style.size_multiplier() / options.style.size_multiplier();
3373
3374    let sup_opts = options.with_style(sup_style);
3375    let body_box = layout_node(body, &sup_opts);
3376    let body_w = body_box.width * sup_ratio;
3377
3378    let below_box = below.map(|b| {
3379        let sub_opts = options.with_style(sub_style);
3380        layout_node(b, &sub_opts)
3381    });
3382    let below_w = below_box
3383        .as_ref()
3384        .map(|b| b.width * sub_ratio)
3385        .unwrap_or(0.0);
3386
3387    // KaTeX `katexImagesData` minWidth on the stretchy SVG, plus `.x-arrow-pad { padding: 0 0.5em }`
3388    // on each label row (em = that row's font). In parent em: +0.5·sup_ratio + 0.5·sup_ratio, etc.
3389    let min_w = crate::katex_svg::katex_stretchy_min_width_em(label).unwrap_or(1.0);
3390    let upper_w = body_w + sup_ratio;
3391    let lower_w = if below_box.is_some() {
3392        below_w + sub_ratio
3393    } else {
3394        0.0
3395    };
3396    let arrow_w = upper_w.max(lower_w).max(min_w);
3397    let arrow_h = 0.3;
3398
3399    let (commands, actual_arrow_h, fill_arrow) =
3400        match crate::katex_svg::katex_stretchy_path(label, arrow_w) {
3401            Some((c, h)) => (c, h, true),
3402            None => (
3403                stretchy_accent_path(label, arrow_w, arrow_h),
3404                arrow_h,
3405                label == "\\xtwoheadrightarrow" || label == "\\xtwoheadleftarrow",
3406            ),
3407        };
3408    let arrow_box = LayoutBox {
3409        width: arrow_w,
3410        height: actual_arrow_h / 2.0,
3411        depth: actual_arrow_h / 2.0,
3412        content: BoxContent::SvgPath {
3413            commands,
3414            fill: fill_arrow,
3415        },
3416        color: options.color,
3417    };
3418
3419    // KaTeX positions xarrows centered on the math axis, with a 0.111em (2mu) gap
3420    // between the arrow and the text above/below (see amsmath.dtx reference).
3421    let metrics = options.metrics();
3422    let axis = metrics.axis_height;        // 0.25em
3423    let arrow_half = actual_arrow_h / 2.0;
3424    let gap = 0.111;                       // 2mu gap (KaTeX constant)
3425
3426    // Center the arrow on the math axis by shifting it up.
3427    let base_shift = -axis;
3428
3429    // sup_kern: gap between arrow top and text bottom.
3430    // In the OpLimits renderer:
3431    //   sup_y = y - (arrow_half - base_shift) - sup_kern - sup_box.depth * ratio
3432    //         = y - (arrow_half + axis) - sup_kern - sup_box.depth * ratio
3433    // KaTeX: text_baseline = -(axis + arrow_half + gap)
3434    //   (with extra -= depth when depth > 0.25, but that's rare for typical text)
3435    // Matching: sup_kern = gap
3436    let sup_kern = gap;
3437    let sub_kern = gap;
3438
3439    let sup_h = body_box.height * sup_ratio;
3440    let sup_d = body_box.depth * sup_ratio;
3441
3442    // Height: from baseline to top of upper text
3443    let height = axis + arrow_half + gap + sup_h + sup_d;
3444    // Depth: arrow bottom below baseline = arrow_half - axis
3445    let mut depth = (arrow_half - axis).max(0.0);
3446
3447    if let Some(ref bel) = below_box {
3448        let sub_h = bel.height * sub_ratio;
3449        let sub_d = bel.depth * sub_ratio;
3450        // Lower text positioned symmetrically below the arrow
3451        depth = (arrow_half - axis) + gap + sub_h + sub_d;
3452    }
3453
3454    LayoutBox {
3455        width: arrow_w,
3456        height,
3457        depth,
3458        content: BoxContent::OpLimits {
3459            base: Box::new(arrow_box),
3460            sup: Some(Box::new(body_box)),
3461            sub: below_box.map(Box::new),
3462            base_shift,
3463            sup_kern,
3464            sub_kern,
3465            slant: 0.0,
3466            sup_scale: sup_ratio,
3467            sub_scale: sub_ratio,
3468        },
3469        color: options.color,
3470    }
3471}
3472
3473// ============================================================================
3474// \textcircled layout
3475// ============================================================================
3476
3477fn layout_textcircled(body_box: LayoutBox, options: &LayoutOptions) -> LayoutBox {
3478    // Draw a circle around the content, similar to KaTeX's CSS-based approach
3479    let pad = 0.1_f64; // padding around the content
3480    let total_h = body_box.height + body_box.depth;
3481    let radius = (body_box.width.max(total_h) / 2.0 + pad).max(0.35);
3482    let diameter = radius * 2.0;
3483
3484    // Build a circle path using cubic Bezier approximation
3485    let cx = radius;
3486    let cy = -(body_box.height - total_h / 2.0); // center at vertical center of content
3487    let k = 0.5523; // cubic Bezier approximation of circle: 4*(sqrt(2)-1)/3
3488    let r = radius;
3489
3490    let circle_commands = vec![
3491        PathCommand::MoveTo { x: cx + r, y: cy },
3492        PathCommand::CubicTo {
3493            x1: cx + r, y1: cy - k * r,
3494            x2: cx + k * r, y2: cy - r,
3495            x: cx, y: cy - r,
3496        },
3497        PathCommand::CubicTo {
3498            x1: cx - k * r, y1: cy - r,
3499            x2: cx - r, y2: cy - k * r,
3500            x: cx - r, y: cy,
3501        },
3502        PathCommand::CubicTo {
3503            x1: cx - r, y1: cy + k * r,
3504            x2: cx - k * r, y2: cy + r,
3505            x: cx, y: cy + r,
3506        },
3507        PathCommand::CubicTo {
3508            x1: cx + k * r, y1: cy + r,
3509            x2: cx + r, y2: cy + k * r,
3510            x: cx + r, y: cy,
3511        },
3512        PathCommand::Close,
3513    ];
3514
3515    let circle_box = LayoutBox {
3516        width: diameter,
3517        height: r - cy.min(0.0),
3518        depth: (r + cy).max(0.0),
3519        content: BoxContent::SvgPath {
3520            commands: circle_commands,
3521            fill: false,
3522        },
3523        color: options.color,
3524    };
3525
3526    // Center the content inside the circle
3527    let content_shift = (diameter - body_box.width) / 2.0;
3528    // Shift content to the right to center it
3529    let children = vec![
3530        circle_box,
3531        LayoutBox::new_kern(-(diameter) + content_shift),
3532        body_box.clone(),
3533    ];
3534
3535    let height = r - cy.min(0.0);
3536    let depth = (r + cy).max(0.0);
3537
3538    LayoutBox {
3539        width: diameter,
3540        height,
3541        depth,
3542        content: BoxContent::HBox(children),
3543        color: options.color,
3544    }
3545}
3546
3547// ============================================================================
3548// Path generation helpers
3549// ============================================================================
3550
3551// ============================================================================
3552// \imageof / \origof  (U+22B7 / U+22B6)
3553// ============================================================================
3554
3555/// Synthesise \imageof (•—○) or \origof (○—•).
3556///
3557/// Neither glyph exists in any KaTeX font.  We build each symbol as an HBox
3558/// of three pieces:
3559///   disk  : filled circle SVG path
3560///   bar   : Rule (horizontal segment at circle-centre height)
3561///   ring  : stroked circle SVG path
3562///
3563/// The ordering is reversed for \origof.
3564///
3565/// Dimensions are calibrated against the KaTeX reference PNG (DPR=2, 20px font):
3566///   ink bbox ≈ 0.700w × 0.225h em, centre ≈ 0.263em above baseline.
3567///
3568/// Coordinate convention in path commands:
3569///   origin = baseline-left of the box, x right, y positive → below baseline.
3570fn layout_imageof_origof(imageof: bool, options: &LayoutOptions) -> LayoutBox {
3571    // Disk radius: filled circle ink height = 2·r = 0.225em  →  r = 0.1125em
3572    let r: f64 = 0.1125;
3573    // Circle centre above baseline (negative = above in path coords).
3574    // Calibrated to the math axis (≈0.25em) so both symbols sit at the same height
3575    // as the reference KaTeX rendering.
3576    let cy: f64 = -0.2625;
3577    // Cubic-Bezier circle approximation constant (4*(√2−1)/3)
3578    let k: f64 = 0.5523;
3579    // Each circle sub-box is 2r wide; the circle centre sits at x = r within it.
3580    let cx: f64 = r;
3581
3582    // Box height/depth: symbol sits entirely above baseline.
3583    let h: f64 = r + cy.abs(); // 0.1125 + 0.2625 = 0.375
3584    let d: f64 = 0.0;
3585
3586    // The renderer strokes rings with width = 1.5 × DPR pixels.
3587    // At the golden-test resolution (font=40px, DPR=1) that is 1.5 px = 0.0375em.
3588    // To keep the ring's outer ink edge coincident with the disk's outer edge,
3589    // draw the ring path at r_ring = r − stroke_half so the outer ink = r − stroke_half + stroke_half = r.
3590    let stroke_half: f64 = 0.01875; // 0.75px / 40px·em⁻¹
3591    let r_ring: f64 = r - stroke_half; // 0.09375em
3592
3593    // Closed circle path (counter-clockwise) centred at (ox, cy) with radius rad.
3594    let circle_commands = |ox: f64, rad: f64| -> Vec<PathCommand> {
3595        vec![
3596            PathCommand::MoveTo { x: ox + rad, y: cy },
3597            PathCommand::CubicTo {
3598                x1: ox + rad,     y1: cy - k * rad,
3599                x2: ox + k * rad, y2: cy - rad,
3600                x:  ox,           y:  cy - rad,
3601            },
3602            PathCommand::CubicTo {
3603                x1: ox - k * rad, y1: cy - rad,
3604                x2: ox - rad,     y2: cy - k * rad,
3605                x:  ox - rad,     y:  cy,
3606            },
3607            PathCommand::CubicTo {
3608                x1: ox - rad,     y1: cy + k * rad,
3609                x2: ox - k * rad, y2: cy + rad,
3610                x:  ox,           y:  cy + rad,
3611            },
3612            PathCommand::CubicTo {
3613                x1: ox + k * rad, y1: cy + rad,
3614                x2: ox + rad,     y2: cy + k * rad,
3615                x:  ox + rad,     y:  cy,
3616            },
3617            PathCommand::Close,
3618        ]
3619    };
3620
3621    let disk = LayoutBox {
3622        width: 2.0 * r,
3623        height: h,
3624        depth: d,
3625        content: BoxContent::SvgPath {
3626            commands: circle_commands(cx, r),
3627            fill: true,
3628        },
3629        color: options.color,
3630    };
3631
3632    let ring = LayoutBox {
3633        width: 2.0 * r,
3634        height: h,
3635        depth: d,
3636        content: BoxContent::SvgPath {
3637            commands: circle_commands(cx, r_ring),
3638            fill: false,
3639        },
3640        color: options.color,
3641    };
3642
3643    // Connecting bar centred on the same axis as the circles.
3644    // Rule.raise = distance from baseline to the bottom edge of the rule.
3645    // bar centre at |cy| = 0.2625em  →  raise = 0.2625 − bar_th/2
3646    let bar_len: f64 = 0.25;
3647    let bar_th: f64 = 0.04;
3648    let bar_raise: f64 = cy.abs() - bar_th / 2.0; // 0.2625 − 0.02 = 0.2425
3649
3650    let bar = LayoutBox::new_rule(bar_len, h, d, bar_th, bar_raise);
3651
3652    let children = if imageof {
3653        vec![disk, bar, ring]
3654    } else {
3655        vec![ring, bar, disk]
3656    };
3657
3658    // Total width = 2r (disk) + bar_len + 2r (ring) = 0.225 + 0.25 + 0.225 = 0.700em
3659    let total_width = 4.0 * r + bar_len;
3660    LayoutBox {
3661        width: total_width,
3662        height: h,
3663        depth: d,
3664        content: BoxContent::HBox(children),
3665        color: options.color,
3666    }
3667}
3668
3669/// Build path commands for a horizontal ellipse (circle overlay for \oiint, \oiiint).
3670/// Box-local coords: origin at baseline-left, x right, y down (positive = below baseline).
3671/// Ellipse is centered in the box and spans most of the integral width.
3672fn ellipse_overlay_path(width: f64, height: f64, depth: f64) -> Vec<PathCommand> {
3673    let cx = width / 2.0;
3674    let cy = (depth - height) / 2.0; // vertical center
3675    let a = width * 0.402_f64; // horizontal semi-axis (0.36 * 1.2)
3676    let b = 0.3_f64;          // vertical semi-axis (0.1 * 2)
3677    let k = 0.62_f64;          // Bezier factor: larger = fuller ellipse (0.5523 ≈ exact circle)
3678    vec![
3679        PathCommand::MoveTo { x: cx + a, y: cy },
3680        PathCommand::CubicTo {
3681            x1: cx + a,
3682            y1: cy - k * b,
3683            x2: cx + k * a,
3684            y2: cy - b,
3685            x: cx,
3686            y: cy - b,
3687        },
3688        PathCommand::CubicTo {
3689            x1: cx - k * a,
3690            y1: cy - b,
3691            x2: cx - a,
3692            y2: cy - k * b,
3693            x: cx - a,
3694            y: cy,
3695        },
3696        PathCommand::CubicTo {
3697            x1: cx - a,
3698            y1: cy + k * b,
3699            x2: cx - k * a,
3700            y2: cy + b,
3701            x: cx,
3702            y: cy + b,
3703        },
3704        PathCommand::CubicTo {
3705            x1: cx + k * a,
3706            y1: cy + b,
3707            x2: cx + a,
3708            y2: cy + k * b,
3709            x: cx + a,
3710            y: cy,
3711        },
3712        PathCommand::Close,
3713    ]
3714}
3715
3716fn shift_path_y(cmds: Vec<PathCommand>, dy: f64) -> Vec<PathCommand> {
3717    cmds.into_iter().map(|c| match c {
3718        PathCommand::MoveTo { x, y } => PathCommand::MoveTo { x, y: y + dy },
3719        PathCommand::LineTo { x, y } => PathCommand::LineTo { x, y: y + dy },
3720        PathCommand::CubicTo { x1, y1, x2, y2, x, y } => PathCommand::CubicTo {
3721            x1, y1: y1 + dy, x2, y2: y2 + dy, x, y: y + dy,
3722        },
3723        PathCommand::QuadTo { x1, y1, x, y } => PathCommand::QuadTo {
3724            x1, y1: y1 + dy, x, y: y + dy,
3725        },
3726        PathCommand::Close => PathCommand::Close,
3727    }).collect()
3728}
3729
3730fn stretchy_accent_path(label: &str, width: f64, height: f64) -> Vec<PathCommand> {
3731    if let Some(commands) = crate::katex_svg::katex_stretchy_arrow_path(label, width, height) {
3732        return commands;
3733    }
3734    let ah = height * 0.35; // arrowhead size
3735    let mid_y = -height / 2.0;
3736
3737    match label {
3738        "\\overleftarrow" | "\\underleftarrow" | "\\xleftarrow" | "\\xLeftarrow" => {
3739            vec![
3740                PathCommand::MoveTo { x: ah, y: mid_y - ah },
3741                PathCommand::LineTo { x: 0.0, y: mid_y },
3742                PathCommand::LineTo { x: ah, y: mid_y + ah },
3743                PathCommand::MoveTo { x: 0.0, y: mid_y },
3744                PathCommand::LineTo { x: width, y: mid_y },
3745            ]
3746        }
3747        "\\overleftrightarrow" | "\\underleftrightarrow"
3748        | "\\xleftrightarrow" | "\\xLeftrightarrow" => {
3749            vec![
3750                PathCommand::MoveTo { x: ah, y: mid_y - ah },
3751                PathCommand::LineTo { x: 0.0, y: mid_y },
3752                PathCommand::LineTo { x: ah, y: mid_y + ah },
3753                PathCommand::MoveTo { x: 0.0, y: mid_y },
3754                PathCommand::LineTo { x: width, y: mid_y },
3755                PathCommand::MoveTo { x: width - ah, y: mid_y - ah },
3756                PathCommand::LineTo { x: width, y: mid_y },
3757                PathCommand::LineTo { x: width - ah, y: mid_y + ah },
3758            ]
3759        }
3760        "\\xlongequal" => {
3761            let gap = 0.04;
3762            vec![
3763                PathCommand::MoveTo { x: 0.0, y: mid_y - gap },
3764                PathCommand::LineTo { x: width, y: mid_y - gap },
3765                PathCommand::MoveTo { x: 0.0, y: mid_y + gap },
3766                PathCommand::LineTo { x: width, y: mid_y + gap },
3767            ]
3768        }
3769        "\\xhookleftarrow" => {
3770            vec![
3771                PathCommand::MoveTo { x: ah, y: mid_y - ah },
3772                PathCommand::LineTo { x: 0.0, y: mid_y },
3773                PathCommand::LineTo { x: ah, y: mid_y + ah },
3774                PathCommand::MoveTo { x: 0.0, y: mid_y },
3775                PathCommand::LineTo { x: width, y: mid_y },
3776                PathCommand::QuadTo { x1: width + ah, y1: mid_y, x: width + ah, y: mid_y + ah },
3777            ]
3778        }
3779        "\\xhookrightarrow" => {
3780            vec![
3781                PathCommand::MoveTo { x: 0.0 - ah, y: mid_y - ah },
3782                PathCommand::QuadTo { x1: 0.0 - ah, y1: mid_y, x: 0.0, y: mid_y },
3783                PathCommand::LineTo { x: width, y: mid_y },
3784                PathCommand::MoveTo { x: width - ah, y: mid_y - ah },
3785                PathCommand::LineTo { x: width, y: mid_y },
3786                PathCommand::LineTo { x: width - ah, y: mid_y + ah },
3787            ]
3788        }
3789        "\\xrightharpoonup" | "\\xleftharpoonup" => {
3790            let right = label.contains("right");
3791            if right {
3792                vec![
3793                    PathCommand::MoveTo { x: 0.0, y: mid_y },
3794                    PathCommand::LineTo { x: width, y: mid_y },
3795                    PathCommand::MoveTo { x: width - ah, y: mid_y - ah },
3796                    PathCommand::LineTo { x: width, y: mid_y },
3797                ]
3798            } else {
3799                vec![
3800                    PathCommand::MoveTo { x: ah, y: mid_y - ah },
3801                    PathCommand::LineTo { x: 0.0, y: mid_y },
3802                    PathCommand::LineTo { x: width, y: mid_y },
3803                ]
3804            }
3805        }
3806        "\\xrightharpoondown" | "\\xleftharpoondown" => {
3807            let right = label.contains("right");
3808            if right {
3809                vec![
3810                    PathCommand::MoveTo { x: 0.0, y: mid_y },
3811                    PathCommand::LineTo { x: width, y: mid_y },
3812                    PathCommand::MoveTo { x: width - ah, y: mid_y + ah },
3813                    PathCommand::LineTo { x: width, y: mid_y },
3814                ]
3815            } else {
3816                vec![
3817                    PathCommand::MoveTo { x: ah, y: mid_y + ah },
3818                    PathCommand::LineTo { x: 0.0, y: mid_y },
3819                    PathCommand::LineTo { x: width, y: mid_y },
3820                ]
3821            }
3822        }
3823        "\\xrightleftharpoons" | "\\xleftrightharpoons" => {
3824            let gap = 0.06;
3825            vec![
3826                PathCommand::MoveTo { x: 0.0, y: mid_y - gap },
3827                PathCommand::LineTo { x: width, y: mid_y - gap },
3828                PathCommand::MoveTo { x: width - ah, y: mid_y - gap - ah },
3829                PathCommand::LineTo { x: width, y: mid_y - gap },
3830                PathCommand::MoveTo { x: width, y: mid_y + gap },
3831                PathCommand::LineTo { x: 0.0, y: mid_y + gap },
3832                PathCommand::MoveTo { x: ah, y: mid_y + gap + ah },
3833                PathCommand::LineTo { x: 0.0, y: mid_y + gap },
3834            ]
3835        }
3836        "\\xtofrom" | "\\xrightleftarrows" => {
3837            let gap = 0.06;
3838            vec![
3839                PathCommand::MoveTo { x: 0.0, y: mid_y - gap },
3840                PathCommand::LineTo { x: width, y: mid_y - gap },
3841                PathCommand::MoveTo { x: width - ah, y: mid_y - gap - ah },
3842                PathCommand::LineTo { x: width, y: mid_y - gap },
3843                PathCommand::LineTo { x: width - ah, y: mid_y - gap + ah },
3844                PathCommand::MoveTo { x: width, y: mid_y + gap },
3845                PathCommand::LineTo { x: 0.0, y: mid_y + gap },
3846                PathCommand::MoveTo { x: ah, y: mid_y + gap - ah },
3847                PathCommand::LineTo { x: 0.0, y: mid_y + gap },
3848                PathCommand::LineTo { x: ah, y: mid_y + gap + ah },
3849            ]
3850        }
3851        "\\overlinesegment" | "\\underlinesegment" => {
3852            vec![
3853                PathCommand::MoveTo { x: 0.0, y: mid_y },
3854                PathCommand::LineTo { x: width, y: mid_y },
3855            ]
3856        }
3857        _ => {
3858            vec![
3859                PathCommand::MoveTo { x: 0.0, y: mid_y },
3860                PathCommand::LineTo { x: width, y: mid_y },
3861                PathCommand::MoveTo { x: width - ah, y: mid_y - ah },
3862                PathCommand::LineTo { x: width, y: mid_y },
3863                PathCommand::LineTo { x: width - ah, y: mid_y + ah },
3864            ]
3865        }
3866    }
3867}
3868
3869// ============================================================================
3870// CD (amscd commutative diagram) layout
3871// ============================================================================
3872
3873/// Wrap a horizontal arrow cell with left/right kerns (KaTeX `.cd-arrow-pad`).
3874fn cd_wrap_hpad(inner: LayoutBox, pad_l: f64, pad_r: f64, color: Color) -> LayoutBox {
3875    let h = inner.height;
3876    let d = inner.depth;
3877    let w = inner.width + pad_l + pad_r;
3878    let mut children: Vec<LayoutBox> = Vec::with_capacity(3);
3879    if pad_l > 0.0 {
3880        children.push(LayoutBox::new_kern(pad_l));
3881    }
3882    children.push(inner);
3883    if pad_r > 0.0 {
3884        children.push(LayoutBox::new_kern(pad_r));
3885    }
3886    LayoutBox {
3887        width: w,
3888        height: h,
3889        depth: d,
3890        content: BoxContent::HBox(children),
3891        color,
3892    }
3893}
3894
3895/// Wrap a side label for a vertical CD arrow so it is vertically centered on the shaft.
3896///
3897/// The resulting box reports `height = box_h, depth = box_d` (same as the shaft) so it
3898/// does not change the row's allocated height.  The label body is raised/lowered via
3899/// `RaiseBox` so that the label's visual center aligns with the shaft's vertical center.
3900///
3901/// Derivation (screen coords, y+ downward):
3902///   shaft center  = (box_d − box_h) / 2
3903///   label center  = −shift − (label_h − label_d) / 2
3904///   solving gives  shift = (box_h − box_d + label_d − label_h) / 2
3905fn cd_vcenter_side_label(label: LayoutBox, box_h: f64, box_d: f64, color: Color) -> LayoutBox {
3906    let shift = (box_h - box_d + label.depth - label.height) / 2.0;
3907    LayoutBox {
3908        width: label.width,
3909        height: box_h,
3910        depth: box_d,
3911        content: BoxContent::RaiseBox {
3912            body: Box::new(label),
3913            shift,
3914        },
3915        color,
3916    }
3917}
3918
3919/// Side labels on vertical `{CD}` arrows: KaTeX `\\\\cdleft` / `\\\\cdright` both use
3920/// `options.style.sup()` (`cd.js` htmlBuilder), then our pipeline must scale like `OpLimits`
3921/// scripts — `RaiseBox` in `to_display` does not apply script size, so wrap in `Scaled`.
3922fn cd_side_label_scaled(body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
3923    let sup_style = options.style.superscript();
3924    let sup_opts = options.with_style(sup_style);
3925    let sup_ratio = sup_style.size_multiplier() / options.style.size_multiplier();
3926    let inner = layout_node(body, &sup_opts);
3927    if (sup_ratio - 1.0).abs() < 1e-6 {
3928        inner
3929    } else {
3930        LayoutBox {
3931            width: inner.width * sup_ratio,
3932            height: inner.height * sup_ratio,
3933            depth: inner.depth * sup_ratio,
3934            content: BoxContent::Scaled {
3935                body: Box::new(inner),
3936                child_scale: sup_ratio,
3937            },
3938            color: options.color,
3939        }
3940    }
3941}
3942
3943/// Stretch ↑ / ↓ to span the CD arrow row (`total_height` = height + depth in em).
3944///
3945/// Reuses the same filled KaTeX stretchy path as horizontal `\cdrightarrow` (see
3946/// `katex_svg::katex_cd_vert_arrow_from_rightarrow`) so the head/shaft match the horizontal CD
3947/// arrows; `make_stretchy_delim` does not stack ↑/↓ to arbitrary heights.
3948fn cd_stretch_vert_arrow_box(total_height: f64, down: bool, options: &LayoutOptions) -> LayoutBox {
3949    let axis = options.metrics().axis_height;
3950    let depth = (total_height / 2.0 - axis).max(0.0);
3951    let height = total_height - depth;
3952    if let Some((commands, w)) =
3953        crate::katex_svg::katex_cd_vert_arrow_from_rightarrow(down, total_height, axis)
3954    {
3955        return LayoutBox {
3956            width: w,
3957            height,
3958            depth,
3959            content: BoxContent::SvgPath {
3960                commands,
3961                fill: true,
3962            },
3963            color: options.color,
3964        };
3965    }
3966    // Fallback (should not happen): `\cdrightarrow` is always in the stretchy table.
3967    if down {
3968        make_stretchy_delim("\\downarrow", SIZE_TO_MAX_HEIGHT[2], options)
3969    } else {
3970        make_stretchy_delim("\\uparrow", SIZE_TO_MAX_HEIGHT[2], options)
3971    }
3972}
3973
3974/// Render a single CdArrow cell.
3975///
3976/// `target_size`:
3977/// - `w > 0` for horizontal arrows: shaft length is exactly `w` em (KaTeX: per-cell natural width,
3978///   not the full column max — see `.katex .mtable` + `.stretchy { width: 100% }` where the cell
3979///   span is only as wide as content; narrow arrows stay at `max(labels, minCDarrowwidth)` and sit
3980///   centered in a wider column).
3981/// - `h > 0` for vertical arrows: shaft total height (height+depth) = `h`.
3982/// - `0.0` = natural size (pass 1).
3983///
3984/// `target_col_width`: when `> 0`, center the cell in this column width (horizontal: side kerns;
3985/// vertical: kerns around shaft + labels).
3986///
3987/// `target_depth` (vertical only): depth portion of `target_size` when `> 0`, so that
3988/// `box_h = target_size - target_depth` and `box_d = target_depth`.
3989fn layout_cd_arrow(
3990    direction: &str,
3991    label_above: Option<&ParseNode>,
3992    label_below: Option<&ParseNode>,
3993    target_size: f64,
3994    target_col_width: f64,
3995    _target_depth: f64,
3996    options: &LayoutOptions,
3997) -> LayoutBox {
3998    let metrics = options.metrics();
3999    let axis = metrics.axis_height;
4000
4001    // Vertical CD: kern between side label and shaft (KaTeX `cd-label-*` sits tight; 0.25em
4002    // widens object columns vs `tests/golden/fixtures` CD).
4003    const CD_VERT_SIDE_KERN_EM: f64 = 0.11;
4004
4005    match direction {
4006        "right" | "left" | "horiz_eq" => {
4007            // ── Horizontal arrow: reuse katex_stretchy_path for proper KaTeX shape ──
4008            let sup_style = options.style.superscript();
4009            let sub_style = options.style.subscript();
4010            let sup_opts = options.with_style(sup_style);
4011            let sub_opts = options.with_style(sub_style);
4012            let sup_ratio = sup_style.size_multiplier() / options.style.size_multiplier();
4013            let sub_ratio = sub_style.size_multiplier() / options.style.size_multiplier();
4014
4015            let above_box = label_above.map(|n| layout_node(n, &sup_opts));
4016            let below_box = label_below.map(|n| layout_node(n, &sub_opts));
4017
4018            let above_w = above_box.as_ref().map(|b| b.width * sup_ratio).unwrap_or(0.0);
4019            let below_w = below_box.as_ref().map(|b| b.width * sub_ratio).unwrap_or(0.0);
4020
4021            // KaTeX `stretchy.js`: CD uses `\\cdrightarrow` / `\\cdleftarrow` / `\\cdlongequal` (minWidth 3.0em).
4022            let path_label = if direction == "right" {
4023                "\\cdrightarrow"
4024            } else if direction == "left" {
4025                "\\cdleftarrow"
4026            } else {
4027                "\\cdlongequal"
4028            };
4029            let min_shaft_w = crate::katex_svg::katex_stretchy_min_width_em(path_label).unwrap_or(1.0);
4030            // Based on KaTeX `.cd-arrow-pad` (0.27778 / 0.55556 script-em); slightly trimmed so
4031            // `natural_w` matches golden KaTeX PNGs in our box model (e.g. 0150).
4032            const CD_LABEL_PAD_L: f64 = 0.22;
4033            const CD_LABEL_PAD_R: f64 = 0.48;
4034            let cd_pad_sup = (CD_LABEL_PAD_L + CD_LABEL_PAD_R) * sup_ratio;
4035            let cd_pad_sub = (CD_LABEL_PAD_L + CD_LABEL_PAD_R) * sub_ratio;
4036            let upper_need = above_box
4037                .as_ref()
4038                .map(|_| above_w + cd_pad_sup)
4039                .unwrap_or(0.0);
4040            let lower_need = below_box
4041                .as_ref()
4042                .map(|_| below_w + cd_pad_sub)
4043                .unwrap_or(0.0);
4044            let natural_w = upper_need.max(lower_need).max(0.0);
4045            let shaft_w = if target_size > 0.0 {
4046                target_size
4047            } else {
4048                natural_w.max(min_shaft_w)
4049            };
4050
4051            let (commands, actual_arrow_h, fill_arrow) =
4052                match crate::katex_svg::katex_stretchy_path(path_label, shaft_w) {
4053                    Some((c, h)) => (c, h, true),
4054                    None => {
4055                        // Fallback hand-drawn (should not happen for these labels)
4056                        let arrow_h = 0.3_f64;
4057                        let ah = 0.12_f64;
4058                        let cmds = if direction == "horiz_eq" {
4059                            let gap = 0.06;
4060                            vec![
4061                                PathCommand::MoveTo { x: 0.0, y: -gap },
4062                                PathCommand::LineTo { x: shaft_w, y: -gap },
4063                                PathCommand::MoveTo { x: 0.0, y: gap },
4064                                PathCommand::LineTo { x: shaft_w, y: gap },
4065                            ]
4066                        } else if direction == "right" {
4067                            vec![
4068                                PathCommand::MoveTo { x: 0.0, y: 0.0 },
4069                                PathCommand::LineTo { x: shaft_w, y: 0.0 },
4070                                PathCommand::MoveTo { x: shaft_w - ah, y: -ah },
4071                                PathCommand::LineTo { x: shaft_w, y: 0.0 },
4072                                PathCommand::LineTo { x: shaft_w - ah, y: ah },
4073                            ]
4074                        } else {
4075                            vec![
4076                                PathCommand::MoveTo { x: shaft_w, y: 0.0 },
4077                                PathCommand::LineTo { x: 0.0, y: 0.0 },
4078                                PathCommand::MoveTo { x: ah, y: -ah },
4079                                PathCommand::LineTo { x: 0.0, y: 0.0 },
4080                                PathCommand::LineTo { x: ah, y: ah },
4081                            ]
4082                        };
4083                        (cmds, arrow_h, false)
4084                    }
4085                };
4086
4087            // Arrow box centered at y=0 (same as layout_xarrow)
4088            let arrow_half = actual_arrow_h / 2.0;
4089            let arrow_box = LayoutBox {
4090                width: shaft_w,
4091                height: arrow_half,
4092                depth: arrow_half,
4093                content: BoxContent::SvgPath {
4094                    commands,
4095                    fill: fill_arrow,
4096                },
4097                color: options.color,
4098            };
4099
4100            // Total height/depth for OpLimits (mirrors layout_xarrow / KaTeX arrow.ts)
4101            let gap = 0.111;
4102            let sup_h = above_box.as_ref().map(|b| b.height * sup_ratio).unwrap_or(0.0);
4103            let sup_d = above_box.as_ref().map(|b| b.depth * sup_ratio).unwrap_or(0.0);
4104            // KaTeX arrow.ts: label depth only shifts the label up when depth > 0.25
4105            // (at the label's own scale). Otherwise the label baseline stays fixed and
4106            // depth extends into the gap without increasing the cell height.
4107            let sup_d_contrib = if above_box.as_ref().map(|b| b.depth).unwrap_or(0.0) > 0.25 {
4108                sup_d
4109            } else {
4110                0.0
4111            };
4112            let height = axis + arrow_half + gap + sup_h + sup_d_contrib;
4113            let sub_h_raw = below_box.as_ref().map(|b| b.height * sub_ratio).unwrap_or(0.0);
4114            let sub_d_raw = below_box.as_ref().map(|b| b.depth * sub_ratio).unwrap_or(0.0);
4115            let depth = if below_box.is_some() {
4116                (arrow_half - axis).max(0.0) + gap + sub_h_raw + sub_d_raw
4117            } else {
4118                (arrow_half - axis).max(0.0)
4119            };
4120
4121            let inner = LayoutBox {
4122                width: shaft_w,
4123                height,
4124                depth,
4125                content: BoxContent::OpLimits {
4126                    base: Box::new(arrow_box),
4127                    sup: above_box.map(Box::new),
4128                    sub: below_box.map(Box::new),
4129                    base_shift: -axis,
4130                    sup_kern: gap,
4131                    sub_kern: gap,
4132                    slant: 0.0,
4133                    sup_scale: sup_ratio,
4134                    sub_scale: sub_ratio,
4135                },
4136                color: options.color,
4137            };
4138
4139            // KaTeX HTML: column width is max(cell widths); each cell stays intrinsic width and is
4140            // centered in the column (`col-align-c`). Match with side kerns, not by stretching the
4141            // shaft to the column max.
4142            if target_col_width > inner.width + 1e-6 {
4143                let extra = target_col_width - inner.width;
4144                let kl = extra / 2.0;
4145                let kr = extra - kl;
4146                cd_wrap_hpad(inner, kl, kr, options.color)
4147            } else {
4148                inner
4149            }
4150        }
4151
4152        "down" | "up" | "vert_eq" => {
4153            // Pass 1: \Big (~1.8em). Pass 2: stretch ↑/↓ / ‖ to the full arrow-row span (em).
4154            let big_total = SIZE_TO_MAX_HEIGHT[2]; // 1.8em
4155
4156            let shaft_box = match direction {
4157                "vert_eq" if target_size > 0.0 => {
4158                    make_vert_delim_box(target_size.max(big_total), true, options)
4159                }
4160                "vert_eq" => make_stretchy_delim("\\Vert", big_total, options),
4161                "down" if target_size > 0.0 => {
4162                    cd_stretch_vert_arrow_box(target_size.max(1.0), true, options)
4163                }
4164                "up" if target_size > 0.0 => {
4165                    cd_stretch_vert_arrow_box(target_size.max(1.0), false, options)
4166                }
4167                "down" => cd_stretch_vert_arrow_box(big_total, true, options),
4168                "up" => cd_stretch_vert_arrow_box(big_total, false, options),
4169                _ => cd_stretch_vert_arrow_box(big_total, true, options),
4170            };
4171            let box_h = shaft_box.height;
4172            let box_d = shaft_box.depth;
4173            let shaft_w = shaft_box.width;
4174
4175            // Side labels: KaTeX uses `style.sup()` for both left and right; scale via `Scaled`
4176            // so `to_display::RaiseBox` does not leave them at display size (unlike `OpLimits`).
4177            let left_box = label_above.map(|n| {
4178                cd_vcenter_side_label(cd_side_label_scaled(n, options), box_h, box_d, options.color)
4179            });
4180            let right_box = label_below.map(|n| {
4181                cd_vcenter_side_label(cd_side_label_scaled(n, options), box_h, box_d, options.color)
4182            });
4183
4184            let left_w = left_box.as_ref().map(|b| b.width).unwrap_or(0.0);
4185            let right_w = right_box.as_ref().map(|b| b.width).unwrap_or(0.0);
4186            let left_part = left_w + if left_w > 0.0 { CD_VERT_SIDE_KERN_EM } else { 0.0 };
4187            let right_part = (if right_w > 0.0 { CD_VERT_SIDE_KERN_EM } else { 0.0 }) + right_w;
4188            let inner_w = left_part + shaft_w + right_part;
4189
4190            // Center shaft within the column width (pass 2) using side kerns.
4191            let (kern_left, kern_right, total_w) = if target_col_width > inner_w {
4192                let extra = target_col_width - inner_w;
4193                let kl = extra / 2.0;
4194                let kr = extra - kl;
4195                (kl, kr, target_col_width)
4196            } else {
4197                (0.0, 0.0, inner_w)
4198            };
4199
4200            let mut children: Vec<LayoutBox> = Vec::new();
4201            if kern_left > 0.0 { children.push(LayoutBox::new_kern(kern_left)); }
4202            if let Some(lb) = left_box {
4203                children.push(lb);
4204                children.push(LayoutBox::new_kern(CD_VERT_SIDE_KERN_EM));
4205            }
4206            children.push(shaft_box);
4207            if let Some(rb) = right_box {
4208                children.push(LayoutBox::new_kern(CD_VERT_SIDE_KERN_EM));
4209                children.push(rb);
4210            }
4211            if kern_right > 0.0 { children.push(LayoutBox::new_kern(kern_right)); }
4212
4213            LayoutBox {
4214                width: total_w,
4215                height: box_h,
4216                depth: box_d,
4217                content: BoxContent::HBox(children),
4218                color: options.color,
4219            }
4220        }
4221
4222        // "none" or unknown: empty placeholder
4223        _ => LayoutBox::new_empty(),
4224    }
4225}
4226
4227/// Layout a `\begin{CD}...\end{CD}` commutative diagram with two-pass stretching.
4228fn layout_cd(body: &[Vec<ParseNode>], options: &LayoutOptions) -> LayoutBox {
4229    let metrics = options.metrics();
4230    let pt = 1.0 / metrics.pt_per_em;
4231    // KaTeX CD uses `baselineskip = 3ex` (array.ts line 312), NOT the standard 12pt.
4232    let baselineskip = 3.0 * metrics.x_height;
4233    let arstrut_h = 0.7 * baselineskip;
4234    let arstrut_d = 0.3 * baselineskip;
4235
4236    let num_rows = body.len();
4237    if num_rows == 0 {
4238        return LayoutBox::new_empty();
4239    }
4240    let num_cols = body.iter().map(|r| r.len()).max().unwrap_or(0);
4241    if num_cols == 0 {
4242        return LayoutBox::new_empty();
4243    }
4244
4245    // `\jot` (3pt): added to every row depth below; include in vertical-arrow stretch span.
4246    let jot = 3.0 * pt;
4247
4248    // ── Pass 1: layout all cells at natural size ────────────────────────────
4249    let mut cell_boxes: Vec<Vec<LayoutBox>> = Vec::with_capacity(num_rows);
4250    let mut col_widths = vec![0.0_f64; num_cols];
4251    let mut row_heights = vec![arstrut_h; num_rows];
4252    let mut row_depths = vec![arstrut_d; num_rows];
4253
4254    for (r, row) in body.iter().enumerate() {
4255        let mut row_boxes: Vec<LayoutBox> = Vec::with_capacity(num_cols);
4256
4257        for (c, cell) in row.iter().enumerate() {
4258            let cbox = match cell {
4259                ParseNode::CdArrow { direction, label_above, label_below, .. } => {
4260                    layout_cd_arrow(
4261                        direction,
4262                        label_above.as_deref(),
4263                        label_below.as_deref(),
4264                        0.0, // natural size in pass 1
4265                        0.0, // natural column width
4266                        0.0, // natural depth split
4267                        options,
4268                    )
4269                }
4270                // KaTeX CD object cells are `styling` nodes; `sizingGroup` builds the body with
4271                // `buildExpression(..., false)` (see katex `functions/sizing.js`), so no inter-atom
4272                // math glue inside a cell — matching that avoids spurious Ord–Bin space (e.g. golden 0963).
4273                ParseNode::OrdGroup { body: cell_body, .. } => {
4274                    layout_expression(cell_body, options, false)
4275                }
4276                other => layout_node(other, options),
4277            };
4278
4279            row_heights[r] = row_heights[r].max(cbox.height);
4280            row_depths[r] = row_depths[r].max(cbox.depth);
4281            col_widths[c] = col_widths[c].max(cbox.width);
4282            row_boxes.push(cbox);
4283        }
4284
4285        // Pad missing columns
4286        while row_boxes.len() < num_cols {
4287            row_boxes.push(LayoutBox::new_empty());
4288        }
4289        cell_boxes.push(row_boxes);
4290    }
4291
4292    // Column targets after pass 1 (max natural width per column). Horizontal shafts use per-cell
4293    // `target_size`, not this max — same as KaTeX: minCDarrowwidth is min-width on the glyph span,
4294    // not “stretch every row to column max”.
4295    let col_target_w: Vec<f64> = col_widths.clone();
4296
4297    #[cfg(debug_assertions)]
4298    {
4299        eprintln!("[CD] pass1 col_widths={col_widths:?} row_heights={row_heights:?} row_depths={row_depths:?}");
4300        for (r, row) in cell_boxes.iter().enumerate() {
4301            for (c, b) in row.iter().enumerate() {
4302                if b.width > 0.0 {
4303                    eprintln!("[CD]   cell[{r}][{c}] w={:.4} h={:.4} d={:.4}", b.width, b.height, b.depth);
4304                }
4305            }
4306        }
4307    }
4308
4309    // ── Pass 2: re-layout arrow cells with target dimensions ───────────────
4310    for (r, row) in body.iter().enumerate() {
4311        let is_arrow_row = r % 2 == 1;
4312        for (c, cell) in row.iter().enumerate() {
4313            if let ParseNode::CdArrow { direction, label_above, label_below, .. } = cell {
4314                let is_horiz = matches!(direction.as_str(), "right" | "left" | "horiz_eq");
4315                let (new_box, col_w) = if !is_arrow_row && c % 2 == 1 && is_horiz {
4316                    let b = layout_cd_arrow(
4317                        direction,
4318                        label_above.as_deref(),
4319                        label_below.as_deref(),
4320                        cell_boxes[r][c].width,
4321                        col_target_w[c],
4322                        0.0,
4323                        options,
4324                    );
4325                    let w = b.width;
4326                    (b, w)
4327                } else if is_arrow_row && c % 2 == 0 {
4328                    // Vertical arrow: KaTeX uses a fixed `\Big` delimiter, not a
4329                    // stretchy arrow.  Match by using the pass-1 row span (without
4330                    // \jot) so the shaft height stays at the natural row h+d.
4331                    let v_span = row_heights[r] + row_depths[r];
4332                    let b = layout_cd_arrow(
4333                        direction,
4334                        label_above.as_deref(),
4335                        label_below.as_deref(),
4336                        v_span,
4337                        col_widths[c],
4338                        0.0,
4339                        options,
4340                    );
4341                    let w = b.width;
4342                    (b, w)
4343                } else {
4344                    continue;
4345                };
4346                col_widths[c] = col_widths[c].max(col_w);
4347                cell_boxes[r][c] = new_box;
4348            }
4349        }
4350    }
4351
4352    #[cfg(debug_assertions)]
4353    {
4354        eprintln!("[CD] pass2 col_widths={col_widths:?} row_heights={row_heights:?} row_depths={row_depths:?}");
4355    }
4356
4357    // KaTeX `environments/cd.js` sets `addJot: true` for CD; `array.js` adds `\jot` (3pt) to each
4358    // row's depth (same as `layout_array` when `add_jot` is set).
4359    for rd in &mut row_depths {
4360        *rd += jot;
4361    }
4362
4363    // ── Build the final Array LayoutBox ────────────────────────────────────
4364    // KaTeX CD uses `pregap: 0.25, postgap: 0.25` per column (cd.ts line 216-217),
4365    // giving 0.5em between adjacent columns.  `hskipBeforeAndAfter` is unset (false),
4366    // so no outer padding.
4367    let col_gap = 0.5;
4368
4369    // Column alignment: objects are centered, arrows are centered
4370    let col_aligns: Vec<u8> = (0..num_cols).map(|_| b'c').collect();
4371
4372    // No vertical separators for CD
4373    let col_separators = vec![None; num_cols + 1];
4374
4375    let mut total_height = 0.0_f64;
4376    let mut row_positions = Vec::with_capacity(num_rows);
4377    for r in 0..num_rows {
4378        total_height += row_heights[r];
4379        row_positions.push(total_height);
4380        total_height += row_depths[r];
4381    }
4382
4383    let offset = total_height / 2.0 + metrics.axis_height;
4384    let height = offset;
4385    let depth = total_height - offset;
4386
4387    // Total width: sum of col_widths + col_gap between each
4388    let total_width = col_widths.iter().sum::<f64>()
4389        + col_gap * (num_cols.saturating_sub(1)) as f64;
4390
4391    // Build hlines_before_row (all empty for CD)
4392    let hlines_before_row: Vec<Vec<bool>> = (0..=num_rows).map(|_| vec![]).collect();
4393
4394    LayoutBox {
4395        width: total_width,
4396        height,
4397        depth,
4398        content: BoxContent::Array {
4399            cells: cell_boxes,
4400            col_widths,
4401            col_aligns,
4402            row_heights,
4403            row_depths,
4404            col_gap,
4405            offset,
4406            content_x_offset: 0.0,
4407            col_separators,
4408            hlines_before_row,
4409            rule_thickness: 0.04 * pt,
4410            double_rule_sep: metrics.double_rule_sep,
4411        },
4412        color: options.color,
4413    }
4414}
4415
4416fn horiz_brace_path(width: f64, height: f64, is_over: bool) -> Vec<PathCommand> {
4417    let mid = width / 2.0;
4418    let q = height * 0.6;
4419    if is_over {
4420        vec![
4421            PathCommand::MoveTo { x: 0.0, y: 0.0 },
4422            PathCommand::QuadTo { x1: 0.0, y1: -q, x: mid * 0.4, y: -q },
4423            PathCommand::LineTo { x: mid - 0.05, y: -q },
4424            PathCommand::LineTo { x: mid, y: -height },
4425            PathCommand::LineTo { x: mid + 0.05, y: -q },
4426            PathCommand::LineTo { x: width - mid * 0.4, y: -q },
4427            PathCommand::QuadTo { x1: width, y1: -q, x: width, y: 0.0 },
4428        ]
4429    } else {
4430        vec![
4431            PathCommand::MoveTo { x: 0.0, y: 0.0 },
4432            PathCommand::QuadTo { x1: 0.0, y1: q, x: mid * 0.4, y: q },
4433            PathCommand::LineTo { x: mid - 0.05, y: q },
4434            PathCommand::LineTo { x: mid, y: height },
4435            PathCommand::LineTo { x: mid + 0.05, y: q },
4436            PathCommand::LineTo { x: width - mid * 0.4, y: q },
4437            PathCommand::QuadTo { x1: width, y1: q, x: width, y: 0.0 },
4438        ]
4439    }
4440}