Skip to main content

ratex_layout/
engine.rs

1use std::collections::HashMap;
2
3use ratex_font::{get_char_metrics, get_global_metrics, FontId};
4use ratex_parser::parse_node::{
5    ArrayTag, AtomFamily, Mode, ParseNode, ProofBranch, ProofLineStyle, StyleStr,
6};
7use ratex_types::color::Color;
8use ratex_types::math_style::MathStyle;
9use ratex_types::path_command::PathCommand;
10
11use crate::hbox::make_hbox;
12use crate::layout_box::{BoxContent, LayoutBox, PlacedBox, ProofRule};
13use crate::layout_options::LayoutOptions;
14
15use crate::katex_svg::parse_svg_path_data;
16use crate::spacing::{atom_spacing, mu_to_em, MathClass};
17use crate::stacked_delim::make_stacked_delim_if_needed;
18
19/// TeX `\nulldelimiterspace` = 1.2pt = 0.12em (at 10pt design size).
20/// KaTeX wraps every `\frac` / `\atop` in mopen+mclose nulldelimiter spans of this width.
21const NULL_DELIMITER_SPACE: f64 = 0.12;
22
23fn style_str_to_math_style(style: &StyleStr) -> MathStyle {
24    match style {
25        StyleStr::Display => MathStyle::Display,
26        StyleStr::Text => MathStyle::Text,
27        StyleStr::Script => MathStyle::Script,
28        StyleStr::Scriptscript => MathStyle::ScriptScript,
29    }
30}
31
32/// Main entry point: lay out a list of ParseNodes into a LayoutBox.
33pub fn layout(nodes: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
34    layout_expression(nodes, options, true)
35}
36
37/// KaTeX `binLeftCanceller` / `binRightCanceller` (TeXbook p.442–446, Rules 5–6).
38/// Binary operators become ordinary in certain contexts so spacing matches TeX/KaTeX.
39fn apply_bin_cancellation(raw: &[Option<MathClass>]) -> Vec<Option<MathClass>> {
40    let n = raw.len();
41    let mut eff = raw.to_vec();
42    for i in 0..n {
43        if raw[i] != Some(MathClass::Bin) {
44            continue;
45        }
46        let prev = if i == 0 { None } else { raw[i - 1] };
47        let left_cancel = matches!(
48            prev,
49            None | Some(MathClass::Bin)
50                | Some(MathClass::Open)
51                | Some(MathClass::Rel)
52                | Some(MathClass::Op)
53                | Some(MathClass::Punct)
54        );
55        if left_cancel {
56            eff[i] = Some(MathClass::Ord);
57        }
58    }
59    for i in 0..n {
60        if raw[i] != Some(MathClass::Bin) {
61            continue;
62        }
63        let next = if i + 1 < n { raw[i + 1] } else { None };
64        let right_cancel = matches!(
65            next,
66            None | Some(MathClass::Rel) | Some(MathClass::Close) | Some(MathClass::Punct)
67        );
68        if right_cancel {
69            eff[i] = Some(MathClass::Ord);
70        }
71    }
72    eff
73}
74
75/// KaTeX HTML: `\middle` delimiters are built with class `delimsizing`, which
76/// `getTypeOfDomTree` does not map to a math atom type, so **no** implicit
77/// table glue is inserted next to them (buildHTML.js). RaTeX must match that or
78/// `\frac` (Inner) gains spurious 3mu on each side of every `\middle\vert`.
79fn node_is_middle_fence(node: &ParseNode) -> bool {
80    matches!(node, ParseNode::Middle { .. })
81}
82
83/// Lay out an expression (list of nodes) as a horizontal sequence with spacing.
84fn layout_expression(
85    nodes: &[ParseNode],
86    options: &LayoutOptions,
87    is_real_group: bool,
88) -> LayoutBox {
89    if nodes.is_empty() {
90        return LayoutBox::new_empty();
91    }
92
93    // Check for line breaks (\\, \newline) — split into rows stacked in a VBox
94    let has_cr = nodes.iter().any(|n| matches!(n, ParseNode::Cr { .. }));
95    if has_cr {
96        return layout_multiline(nodes, options, is_real_group);
97    }
98
99    let raw_classes: Vec<Option<MathClass>> = nodes.iter().map(node_math_class).collect();
100    let eff_classes = apply_bin_cancellation(&raw_classes);
101
102    let mut children = Vec::new();
103    let mut prev_class: Option<MathClass> = None;
104    // Index of the last node that contributed `prev_class` (for `\middle` glue suppression).
105    let mut prev_class_node_idx: Option<usize> = None;
106
107    for (i, node) in nodes.iter().enumerate() {
108        let lbox = layout_node(node, options);
109        let cur_class = eff_classes.get(i).copied().flatten();
110
111        if is_real_group {
112            if let (Some(prev), Some(cur)) = (prev_class, cur_class) {
113                let prev_middle =
114                    prev_class_node_idx.is_some_and(|j| node_is_middle_fence(&nodes[j]));
115                let cur_middle = node_is_middle_fence(node);
116                let mu = if prev_middle || cur_middle {
117                    0.0
118                } else {
119                    atom_spacing(prev, cur, options.style.is_tight())
120                };
121                let mu = if let Some(cap) = options.align_relation_spacing {
122                    if prev == MathClass::Rel || cur == MathClass::Rel {
123                        mu.min(cap)
124                    } else {
125                        mu
126                    }
127                } else {
128                    mu
129                };
130                if mu > 0.0 {
131                    let em = mu_to_em(mu, options.metrics().quad);
132                    children.push(LayoutBox::new_kern(em));
133                }
134            }
135        }
136
137        if cur_class.is_some() {
138            prev_class = cur_class;
139            prev_class_node_idx = Some(i);
140        }
141
142        children.push(lbox);
143    }
144
145    make_hbox(children)
146}
147
148/// Layout an expression containing line-break nodes (\\, \newline) as a VBox.
149fn layout_multiline(
150    nodes: &[ParseNode],
151    options: &LayoutOptions,
152    is_real_group: bool,
153) -> LayoutBox {
154    use crate::layout_box::{BoxContent, VBoxChild, VBoxChildKind};
155    let metrics = options.metrics();
156    let pt = 1.0 / metrics.pt_per_em;
157    let baselineskip = 12.0 * pt; // standard TeX baselineskip
158    let lineskip = 1.0 * pt; // minimum gap between lines
159
160    // Split nodes at Cr boundaries
161    let mut rows: Vec<&[ParseNode]> = Vec::new();
162    let mut start = 0;
163    for (i, node) in nodes.iter().enumerate() {
164        if matches!(node, ParseNode::Cr { .. }) {
165            rows.push(&nodes[start..i]);
166            start = i + 1;
167        }
168    }
169    rows.push(&nodes[start..]);
170
171    let row_boxes: Vec<LayoutBox> = rows
172        .iter()
173        .map(|row| layout_expression(row, options, is_real_group))
174        .collect();
175
176    let total_width = row_boxes.iter().map(|b| b.width).fold(0.0_f64, f64::max);
177
178    let mut vchildren: Vec<VBoxChild> = Vec::new();
179    let mut h = row_boxes.first().map(|b| b.height).unwrap_or(0.0);
180    let d = row_boxes.last().map(|b| b.depth).unwrap_or(0.0);
181    for (i, row) in row_boxes.iter().enumerate() {
182        if i > 0 {
183            // TeX baselineskip: gap = baselineskip - prev_depth - cur_height
184            let prev_depth = row_boxes[i - 1].depth;
185            let gap = (baselineskip - prev_depth - row.height).max(lineskip);
186            vchildren.push(VBoxChild {
187                kind: VBoxChildKind::Kern(gap),
188                shift: 0.0,
189            });
190            h += gap + row.height + prev_depth;
191        }
192        vchildren.push(VBoxChild {
193            kind: VBoxChildKind::Box(Box::new(row.clone())),
194            shift: 0.0,
195        });
196    }
197
198    LayoutBox {
199        width: total_width,
200        height: h,
201        depth: d,
202        content: BoxContent::VBox(vchildren),
203        color: options.color,
204    }
205}
206
207/// Lay out a single ParseNode.
208fn layout_node(node: &ParseNode, options: &LayoutOptions) -> LayoutBox {
209    match node {
210        ParseNode::MathOrd { text, mode, .. } => layout_symbol(text, *mode, options),
211        ParseNode::TextOrd { text, mode, .. } => layout_symbol(text, *mode, options),
212        ParseNode::Atom { text, mode, .. } => layout_symbol(text, *mode, options),
213        ParseNode::OpToken { text, mode, .. } => layout_symbol(text, *mode, options),
214
215        ParseNode::OrdGroup { body, .. } => layout_expression(body, options, true),
216
217        ParseNode::SupSub { base, sup, sub, .. } => {
218            if let Some(base_node) = base.as_deref() {
219                if should_use_op_limits(base_node, options) {
220                    return layout_op_with_limits(
221                        base_node,
222                        sup.as_deref(),
223                        sub.as_deref(),
224                        options,
225                    );
226                }
227            }
228            layout_supsub(
229                base.as_deref(),
230                sup.as_deref(),
231                sub.as_deref(),
232                options,
233                None,
234            )
235        }
236
237        ParseNode::GenFrac {
238            numer,
239            denom,
240            has_bar_line,
241            bar_size,
242            left_delim,
243            right_delim,
244            continued,
245            ..
246        } => {
247            let bar_thickness = if *has_bar_line {
248                bar_size
249                    .as_ref()
250                    .map(|m| measurement_to_em(m, options))
251                    .unwrap_or(options.metrics().default_rule_thickness)
252            } else {
253                0.0
254            };
255            let frac = layout_fraction(numer, denom, bar_thickness, *continued, options);
256
257            let has_left = left_delim
258                .as_ref()
259                .is_some_and(|d| !d.is_empty() && d != ".");
260            let has_right = right_delim
261                .as_ref()
262                .is_some_and(|d| !d.is_empty() && d != ".");
263
264            if has_left || has_right {
265                let total_h = genfrac_delim_target_height(options);
266                let left_d = left_delim.as_deref().unwrap_or(".");
267                let right_d = right_delim.as_deref().unwrap_or(".");
268                let left_box = make_stretchy_delim(left_d, total_h, options);
269                let right_box = make_stretchy_delim(right_d, total_h, options);
270
271                let width = left_box.width + frac.width + right_box.width;
272                let height = frac.height.max(left_box.height).max(right_box.height);
273                let depth = frac.depth.max(left_box.depth).max(right_box.depth);
274
275                LayoutBox {
276                    width,
277                    height,
278                    depth,
279                    content: BoxContent::LeftRight {
280                        left: Box::new(left_box),
281                        right: Box::new(right_box),
282                        inner: Box::new(frac),
283                    },
284                    color: options.color,
285                }
286            } else {
287                let right_nds = if *continued {
288                    0.0
289                } else {
290                    NULL_DELIMITER_SPACE
291                };
292                make_hbox(vec![
293                    LayoutBox::new_kern(NULL_DELIMITER_SPACE),
294                    frac,
295                    LayoutBox::new_kern(right_nds),
296                ])
297            }
298        }
299
300        ParseNode::Sqrt { body, index, .. } => layout_radical(body, index.as_deref(), options),
301
302        ParseNode::Op {
303            name,
304            symbol,
305            body,
306            limits,
307            suppress_base_shift,
308            ..
309        } => layout_op(
310            name.as_deref(),
311            *symbol,
312            body.as_deref(),
313            *limits,
314            suppress_base_shift.unwrap_or(false),
315            options,
316        ),
317
318        ParseNode::OperatorName { body, .. } => layout_operatorname(body, options),
319
320        ParseNode::SpacingNode { text, .. } => layout_spacing_command(text, options),
321
322        ParseNode::Kern { dimension, .. } => {
323            let em = measurement_to_em(dimension, options);
324            LayoutBox::new_kern(em)
325        }
326
327        ParseNode::Color { color, body, .. } => {
328            let new_color = Color::parse(color).unwrap_or(options.color);
329            let new_opts = options.with_color(new_color);
330            let mut lbox = layout_expression(body, &new_opts, true);
331            lbox.color = new_color;
332            lbox
333        }
334
335        ParseNode::Styling { style, body, .. } => {
336            let new_style = style_str_to_math_style(style);
337            let ratio = new_style.size_multiplier() / options.style.size_multiplier();
338            let new_opts = options.with_style(new_style);
339            let inner = layout_expression(body, &new_opts, true);
340            if (ratio - 1.0).abs() < 0.001 {
341                inner
342            } else {
343                LayoutBox {
344                    width: inner.width * ratio,
345                    height: inner.height * ratio,
346                    depth: inner.depth * ratio,
347                    content: BoxContent::Scaled {
348                        body: Box::new(inner),
349                        child_scale: ratio,
350                    },
351                    color: options.color,
352                }
353            }
354        }
355
356        ParseNode::Accent {
357            label,
358            base,
359            is_stretchy,
360            is_shifty,
361            ..
362        } => {
363            // Some text accents (e.g. \c cedilla) place the mark below
364            let is_below = matches!(label.as_str(), "\\c");
365            layout_accent(
366                label,
367                base,
368                is_stretchy.unwrap_or(false),
369                is_shifty.unwrap_or(false),
370                is_below,
371                options,
372            )
373        }
374
375        ParseNode::AccentUnder {
376            label,
377            base,
378            is_stretchy,
379            ..
380        } => layout_accent(
381            label,
382            base,
383            is_stretchy.unwrap_or(false),
384            false,
385            true,
386            options,
387        ),
388
389        ParseNode::LeftRight {
390            body, left, right, ..
391        } => layout_left_right(body, left, right, options),
392
393        ParseNode::DelimSizing { size, delim, .. } => layout_delim_sizing(*size, delim, options),
394
395        ParseNode::Array {
396            body,
397            cols,
398            arraystretch,
399            add_jot,
400            row_gaps,
401            hlines_before_row,
402            col_separation_type,
403            hskip_before_and_after,
404            is_cd,
405            tags,
406            leqno,
407            ..
408        } => {
409            if is_cd.unwrap_or(false) {
410                layout_cd(body, options)
411            } else {
412                layout_array(
413                    body,
414                    cols.as_deref(),
415                    *arraystretch,
416                    add_jot.unwrap_or(false),
417                    row_gaps,
418                    hlines_before_row,
419                    col_separation_type.as_deref(),
420                    hskip_before_and_after.unwrap_or(false),
421                    tags.as_deref(),
422                    leqno.unwrap_or(false),
423                    options,
424                )
425            }
426        }
427
428        ParseNode::CdArrow {
429            direction,
430            label_above,
431            label_below,
432            ..
433        } => layout_cd_arrow(
434            direction,
435            label_above.as_deref(),
436            label_below.as_deref(),
437            0.0,
438            0.0,
439            0.0,
440            options,
441        ),
442
443        ParseNode::ProofTree { tree, .. } => layout_proof_tree(tree, options),
444
445        ParseNode::Sizing { size, body, .. } => layout_sizing(*size, body, options),
446
447        ParseNode::Text {
448            body, font, mode, ..
449        } => match font.as_deref() {
450            Some(f) => {
451                let group = ParseNode::OrdGroup {
452                    mode: *mode,
453                    body: body.clone(),
454                    semisimple: None,
455                    loc: None,
456                };
457                layout_font(f, &group, options)
458            }
459            None => layout_text(body, options),
460        },
461
462        ParseNode::Font { font, body, .. } => layout_font(font, body, options),
463
464        ParseNode::Href { body, .. } => layout_href(body, options),
465
466        ParseNode::Overline { body, .. } => layout_overline(body, options),
467        ParseNode::Underline { body, .. } => layout_underline(body, options),
468
469        ParseNode::Rule {
470            width: w,
471            height: h,
472            shift,
473            ..
474        } => {
475            let width = measurement_to_em(w, options);
476            let ink_h = measurement_to_em(h, options);
477            let raise = shift
478                .as_ref()
479                .map(|s| measurement_to_em(s, options))
480                .unwrap_or(0.0);
481            let box_height = (raise + ink_h).max(0.0);
482            let box_depth = (-raise).max(0.0);
483            LayoutBox::new_rule(width, box_height, box_depth, ink_h, raise)
484        }
485
486        ParseNode::Phantom { body, .. } => {
487            let inner = layout_expression(body, options, true);
488            LayoutBox {
489                width: inner.width,
490                height: inner.height,
491                depth: inner.depth,
492                content: BoxContent::Empty,
493                color: Color::BLACK,
494            }
495        }
496
497        ParseNode::VPhantom { body, .. } => {
498            let inner = layout_node(body, options);
499            LayoutBox {
500                width: 0.0,
501                height: inner.height,
502                depth: inner.depth,
503                content: BoxContent::Empty,
504                color: Color::BLACK,
505            }
506        }
507
508        ParseNode::Smash {
509            body,
510            smash_height,
511            smash_depth,
512            ..
513        } => {
514            let mut inner = layout_node(body, options);
515            if *smash_height {
516                inner.height = 0.0;
517            }
518            if *smash_depth {
519                inner.depth = 0.0;
520            }
521            inner
522        }
523
524        ParseNode::Middle { delim, .. } => {
525            match options.leftright_delim_height {
526                Some(h) => make_stretchy_delim(delim, h, options),
527                None => {
528                    // First pass inside \left...\right: reserve width but don't affect inner height.
529                    let placeholder = make_stretchy_delim(delim, 1.0, options);
530                    LayoutBox {
531                        width: placeholder.width,
532                        height: 0.0,
533                        depth: 0.0,
534                        content: BoxContent::Empty,
535                        color: options.color,
536                    }
537                }
538            }
539        }
540
541        ParseNode::HtmlMathMl { html, .. } => layout_expression(html, options, true),
542
543        ParseNode::Html {
544            attributes, body, ..
545        } => layout_html(attributes, body, options),
546
547        ParseNode::MClass { body, .. } => layout_expression(body, options, true),
548
549        ParseNode::MathChoice {
550            display,
551            text,
552            script,
553            scriptscript,
554            ..
555        } => {
556            let branch = match options.style {
557                MathStyle::Display | MathStyle::DisplayCramped => display,
558                MathStyle::Text | MathStyle::TextCramped => text,
559                MathStyle::Script | MathStyle::ScriptCramped => script,
560                MathStyle::ScriptScript | MathStyle::ScriptScriptCramped => scriptscript,
561            };
562            layout_expression(branch, options, true)
563        }
564
565        ParseNode::Lap {
566            alignment, body, ..
567        } => {
568            let inner = layout_node(body, options);
569            let shift = match alignment.as_str() {
570                "llap" => -inner.width,
571                "clap" => -inner.width / 2.0,
572                _ => 0.0, // rlap: no shift
573            };
574            let mut children = Vec::new();
575            if shift != 0.0 {
576                children.push(LayoutBox::new_kern(shift));
577            }
578            let h = inner.height;
579            let d = inner.depth;
580            children.push(inner);
581            LayoutBox {
582                width: 0.0,
583                height: h,
584                depth: d,
585                content: BoxContent::HBox(children),
586                color: options.color,
587            }
588        }
589
590        ParseNode::HorizBrace {
591            base,
592            is_over,
593            label,
594            ..
595        } => layout_horiz_brace(base, *is_over, label, options),
596
597        ParseNode::XArrow {
598            label, body, below, ..
599        } => layout_xarrow(label, body, below.as_deref(), options),
600
601        ParseNode::Pmb { body, .. } => layout_pmb(body, options),
602
603        ParseNode::HBox { body, .. } => layout_text(body, options),
604
605        ParseNode::Enclose {
606            label,
607            background_color,
608            border_color,
609            body,
610            ..
611        } => layout_enclose(
612            label,
613            background_color.as_deref(),
614            border_color.as_deref(),
615            body,
616            options,
617        ),
618
619        ParseNode::RaiseBox { dy, body, .. } => {
620            let shift = measurement_to_em(dy, options);
621            layout_raisebox(shift, body, options)
622        }
623
624        ParseNode::VCenter { body, .. } => {
625            // Vertically center on the math axis
626            let inner = layout_node(body, options);
627            let axis = options.metrics().axis_height;
628            let total = inner.height + inner.depth;
629            let height = total / 2.0 + axis;
630            let depth = total - height;
631            LayoutBox {
632                width: inner.width,
633                height,
634                depth,
635                content: inner.content,
636                color: inner.color,
637            }
638        }
639
640        ParseNode::Verb { body, star, .. } => layout_verb(body, *star, options),
641
642        ParseNode::Tag { tag, .. } => {
643            let text_opts = options.with_style(options.style.text());
644            layout_expression(tag, &text_opts, true)
645        }
646
647        // Fallback for unhandled node types: produce empty box
648        _ => LayoutBox::new_empty(),
649    }
650}
651
652// ============================================================================
653// Symbol layout
654// ============================================================================
655
656/// Advance width for glyphs missing from bundled KaTeX fonts (e.g. CJK in `\text{…}`).
657///
658/// The placeholder width must match what system font fallback draws at ~1em: using 0.5em
659/// collapses.advance and Core Text / platform rasterizers still paint a full-width ideograph,
660/// so neighbors overlap and the row looks "too large" / clipped.
661fn missing_glyph_width_em(ch: char) -> f64 {
662    match ch as u32 {
663        // Hiragana / Katakana
664        0x3040..=0x30FF | 0x31F0..=0x31FF => 1.0,
665        // CJK Unified + extension / compatibility ideographs
666        0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF => 1.0,
667        // Hangul syllables
668        0xAC00..=0xD7AF => 1.0,
669        // Fullwidth ASCII, punctuation, currency
670        0xFF01..=0xFF60 | 0xFFE0..=0xFFEE => 1.0,
671        // Emoji / pictographs in supplementary planes (e.g. U+1F60A 😊) and related blocks:
672        // system fallback draws ~one em, same rationale as CJK above (issue #49).
673        0x1F000..=0x1FAFF => 1.0,
674        // Dingbats (many BMP emoji / ornaments lack bundled TeX metrics)
675        0x2700..=0x27BF => 1.0,
676        // Miscellaneous Symbols (★ ☆ ☎ ☑ ☒ etc.)
677        0x2600..=0x26FF => 1.0,
678        // Miscellaneous Symbols and Arrows (⭐ ⬛ ⬜ etc.)
679        0x2B00..=0x2BFF => 1.0,
680        _ => 0.5,
681    }
682}
683
684fn missing_glyph_height_em(ch: char, m: &ratex_font::MathConstants) -> f64 {
685    let ru = ch as u32;
686    if (0x1F000..=0x1FAFF).contains(&ru) {
687        // Supplementary-plane emoji: `missing_glyph_width_em` uses ~1em width for raster
688        // parity (#49), but a 0.92·quad tall box inflates `\sqrt` `min_delim_height` past KaTeX’s
689        // threshold so we pick Size1 surd (1em advance) instead of the small surd (0.833em),
690        // visibly widening the gap before the radicand (golden 0955).
691        (m.quad * 0.74).max(m.x_height)
692    } else {
693        (m.quad * 0.92).max(m.x_height)
694    }
695}
696
697fn missing_glyph_metrics_fallback(ch: char, options: &LayoutOptions) -> (f64, f64, f64) {
698    let m = get_global_metrics(options.style.size_index());
699    let w = missing_glyph_width_em(ch);
700    if w >= 0.99 {
701        let h = missing_glyph_height_em(ch, m);
702        (w, h, 0.0)
703    } else {
704        (w, m.x_height, 0.0)
705    }
706}
707
708/// KaTeX `SymbolNode.toNode`: math symbols use `margin-right: italic` (advance = width + italic).
709#[inline]
710fn math_glyph_advance_em(m: &ratex_font::CharMetrics, mode: Mode) -> f64 {
711    if mode == Mode::Math {
712        m.width + m.italic
713    } else {
714        m.width
715    }
716}
717
718fn layout_symbol(text: &str, mode: Mode, options: &LayoutOptions) -> LayoutBox {
719    let ch = resolve_symbol_char(text, mode);
720
721    if text == "\\shortmid" {
722        return make_shortmid_box(options);
723    }
724    if text == "\\textunderscore" {
725        return make_textunderscore_box(options);
726    }
727
728    // Synthetic symbols not present in any KaTeX font; built from SVG paths.
729    match ch as u32 {
730        0x2258 | 0xE258 => return make_corresponds_symbol(options),
731        0x225E | 0xE25E => return make_measured_by_symbol(options),
732        0x22B7 => return layout_imageof_origof(true, options), // \imageof  •—○
733        0x22B6 => return layout_imageof_origof(false, options), // \origof   ○—•
734        _ => {}
735    }
736
737    let char_code = ch as u32;
738
739    if let Some((font_id, metric_cp)) =
740        ratex_font::font_and_metric_for_mathematical_alphanumeric(char_code)
741    {
742        let m = get_char_metrics(font_id, metric_cp);
743        let (width, height, depth) = match m {
744            Some(m) => (math_glyph_advance_em(&m, mode), m.height, m.depth),
745            None => missing_glyph_metrics_fallback(ch, options),
746        };
747        return LayoutBox {
748            width,
749            height,
750            depth,
751            content: BoxContent::Glyph { font_id, char_code },
752            color: options.color,
753        };
754    }
755
756    let mut font_id = select_font(text, ch, mode, options);
757    let mut metrics = get_char_metrics(font_id, char_code);
758
759    if metrics.is_none() && mode == Mode::Math && font_id != FontId::MathItalic {
760        if let Some(m) = get_char_metrics(FontId::MathItalic, char_code) {
761            font_id = FontId::MathItalic;
762            metrics = Some(m);
763        }
764    }
765
766    // KaTeX `Main-Regular` has no metrics/cmap for some codepoints (e.g. U+2211) that only exist
767    // in `Size1`/`Size2`. `\@char` yields `textord`, so we still rasterize via the normal lookup
768    // chain (unicode fallback when Main has no glyph). Using `missing_glyph_metrics_fallback`
769    // (0.5em wide) then clips the real fallback outline in PNG/SVG — borrow Size-font TeX metrics
770    // for the box only, without switching `font_id`.
771    let (width, height, depth) = if let Some(m) = metrics {
772        (math_glyph_advance_em(&m, mode), m.height, m.depth)
773    } else if mode == Mode::Math {
774        let size_font = if options.style.is_display() {
775            FontId::Size2Regular
776        } else {
777            FontId::Size1Regular
778        };
779        match get_char_metrics(size_font, char_code)
780            .or_else(|| get_char_metrics(FontId::Size1Regular, char_code))
781        {
782            Some(m) => (math_glyph_advance_em(&m, mode), m.height, m.depth),
783            None => missing_glyph_metrics_fallback(ch, options),
784        }
785    } else {
786        missing_glyph_metrics_fallback(ch, options)
787    };
788
789    // If the glyph has no KaTeX metrics and is a wide fallback character (CJK, emoji, etc.),
790    // switch font_id to CjkRegular so renderers can load it from a system Unicode font.
791    if metrics.is_none() && missing_glyph_width_em(ch) >= 0.99 {
792        font_id = FontId::CjkRegular;
793    }
794
795    LayoutBox {
796        width,
797        height,
798        depth,
799        content: BoxContent::Glyph { font_id, char_code },
800        color: options.color,
801    }
802}
803
804/// Resolve a symbol name to its actual character.
805fn resolve_symbol_char(text: &str, mode: Mode) -> char {
806    let font_mode = match mode {
807        Mode::Math => ratex_font::Mode::Math,
808        Mode::Text => ratex_font::Mode::Text,
809    };
810
811    if let Some(raw) = text.chars().next() {
812        let ru = raw as u32;
813        if (0x1D400..=0x1D7FF).contains(&ru) {
814            return raw;
815        }
816    }
817
818    if let Some(info) = ratex_font::get_symbol(text, font_mode) {
819        if let Some(cp) = info.codepoint {
820            return cp;
821        }
822    }
823
824    text.chars().next().unwrap_or('?')
825}
826
827/// Select the font for a math symbol.
828/// Uses the symbol table's font field for AMS symbols, and character properties
829/// to choose between MathItalic (for letters and Greek) and MainRegular.
830fn select_font(text: &str, resolved_char: char, mode: Mode, _options: &LayoutOptions) -> FontId {
831    let font_mode = match mode {
832        Mode::Math => ratex_font::Mode::Math,
833        Mode::Text => ratex_font::Mode::Text,
834    };
835
836    if let Some(info) = ratex_font::get_symbol(text, font_mode) {
837        if info.font == ratex_font::SymbolFont::Ams {
838            return FontId::AmsRegular;
839        }
840    }
841
842    match mode {
843        Mode::Math => {
844            if resolved_char.is_ascii_lowercase()
845                || resolved_char.is_ascii_uppercase()
846                || is_math_italic_greek(resolved_char)
847            {
848                FontId::MathItalic
849            } else {
850                FontId::MainRegular
851            }
852        }
853        Mode::Text => FontId::MainRegular,
854    }
855}
856
857/// Lowercase Greek letters and variant forms use Math-Italic in math mode.
858/// Uppercase Greek (U+0391–U+03A9) stays upright in Main-Regular per TeX convention.
859fn is_math_italic_greek(ch: char) -> bool {
860    matches!(
861        ch,
862        '\u{03B1}'..='\u{03C9}' | '\u{03D1}' | '\u{03D5}' | '\u{03D6}' | '\u{03F1}' | '\u{03F5}'
863    )
864}
865
866fn is_arrow_accent(label: &str) -> bool {
867    matches!(
868        label,
869        "\\overrightarrow"
870            | "\\overleftarrow"
871            | "\\Overrightarrow"
872            | "\\overleftrightarrow"
873            | "\\underrightarrow"
874            | "\\underleftarrow"
875            | "\\underleftrightarrow"
876            | "\\overleftharpoon"
877            | "\\overrightharpoon"
878            | "\\overlinesegment"
879            | "\\underlinesegment"
880    )
881}
882
883// ============================================================================
884// Fraction layout (TeX Rule 15d)
885// ============================================================================
886
887fn layout_fraction(
888    numer: &ParseNode,
889    denom: &ParseNode,
890    bar_thickness: f64,
891    continued: bool,
892    options: &LayoutOptions,
893) -> LayoutBox {
894    let numer_s = options.style.numerator();
895    let denom_s = options.style.denominator();
896    let numer_style = options.with_style(numer_s);
897    let denom_style = options.with_style(denom_s);
898
899    let mut numer_box = layout_node(numer, &numer_style);
900    // KaTeX genfrac.js: `\cfrac` pads the numerator with a \strut (TeXbook p.353): 8.5pt × 3.5pt.
901    if continued {
902        let pt = options.metrics().pt_per_em;
903        let h_min = 8.5 / pt;
904        let d_min = 3.5 / pt;
905        if numer_box.height < h_min {
906            numer_box.height = h_min;
907        }
908        if numer_box.depth < d_min {
909            numer_box.depth = d_min;
910        }
911    }
912    let denom_box = layout_node(denom, &denom_style);
913
914    // Size ratios for converting child em to parent em
915    let numer_ratio = numer_s.size_multiplier() / options.style.size_multiplier();
916    let denom_ratio = denom_s.size_multiplier() / options.style.size_multiplier();
917
918    let numer_height = numer_box.height * numer_ratio;
919    let numer_depth = numer_box.depth * numer_ratio;
920    let denom_height = denom_box.height * denom_ratio;
921    let denom_depth = denom_box.depth * denom_ratio;
922    let numer_width = numer_box.width * numer_ratio;
923    let denom_width = denom_box.width * denom_ratio;
924
925    let metrics = options.metrics();
926    let axis = metrics.axis_height;
927    let rule = bar_thickness;
928
929    // TeX Rule 15d: choose shift amounts based on display/text mode
930    let (mut num_shift, mut den_shift) = if options.style.is_display() {
931        (metrics.num1, metrics.denom1)
932    } else if bar_thickness > 0.0 {
933        (metrics.num2, metrics.denom2)
934    } else {
935        (metrics.num3, metrics.denom2)
936    };
937
938    if bar_thickness > 0.0 {
939        let min_clearance = if options.style.is_display() {
940            3.0 * rule
941        } else {
942            rule
943        };
944
945        let num_clearance = (num_shift - numer_depth) - (axis + rule / 2.0);
946        if num_clearance < min_clearance {
947            num_shift += min_clearance - num_clearance;
948        }
949
950        let den_clearance = (axis - rule / 2.0) + (den_shift - denom_height);
951        if den_clearance < min_clearance {
952            den_shift += min_clearance - den_clearance;
953        }
954    } else {
955        let min_gap = if options.style.is_display() {
956            7.0 * metrics.default_rule_thickness
957        } else {
958            3.0 * metrics.default_rule_thickness
959        };
960
961        let gap = (num_shift - numer_depth) - (denom_height - den_shift);
962        if gap < min_gap {
963            let adjust = (min_gap - gap) / 2.0;
964            num_shift += adjust;
965            den_shift += adjust;
966        }
967    }
968
969    let total_width = numer_width.max(denom_width);
970    let height = numer_height + num_shift;
971    let depth = denom_depth + den_shift;
972
973    LayoutBox {
974        width: total_width,
975        height,
976        depth,
977        content: BoxContent::Fraction {
978            numer: Box::new(numer_box),
979            denom: Box::new(denom_box),
980            numer_shift: num_shift,
981            denom_shift: den_shift,
982            bar_thickness: rule,
983            numer_scale: numer_ratio,
984            denom_scale: denom_ratio,
985        },
986        color: options.color,
987    }
988}
989
990// ============================================================================
991// Superscript/Subscript layout
992// ============================================================================
993
994fn layout_supsub(
995    base: Option<&ParseNode>,
996    sup: Option<&ParseNode>,
997    sub: Option<&ParseNode>,
998    options: &LayoutOptions,
999    inherited_font: Option<FontId>,
1000) -> LayoutBox {
1001    let layout_child = |n: &ParseNode, opts: &LayoutOptions| match inherited_font {
1002        Some(fid) => layout_with_font(n, fid, opts),
1003        None => layout_node(n, opts),
1004    };
1005
1006    let horiz_brace_over = matches!(base, Some(ParseNode::HorizBrace { is_over: true, .. }));
1007    let horiz_brace_under = matches!(base, Some(ParseNode::HorizBrace { is_over: false, .. }));
1008    let center_scripts = horiz_brace_over || horiz_brace_under;
1009
1010    let base_box = base
1011        .map(|b| layout_child(b, options))
1012        .unwrap_or_else(LayoutBox::new_empty);
1013
1014    let is_char_box = base.is_some_and(is_character_box);
1015    let metrics = options.metrics();
1016    // KaTeX `supsub.js`: each script span gets `marginRight: (0.5pt/ptPerEm)/sizeMultiplier`
1017    // (TeX `\scriptspace`). Without this, sub/sup boxes are too narrow vs KaTeX (e.g. `pmatrix`
1018    // column widths and inter-column alignment in golden tests).
1019    let script_space = 0.5 / metrics.pt_per_em / options.size_multiplier();
1020
1021    let sup_style = options.style.superscript();
1022    let sub_style = options.style.subscript();
1023
1024    let sup_ratio = sup_style.size_multiplier() / options.style.size_multiplier();
1025    let sub_ratio = sub_style.size_multiplier() / options.style.size_multiplier();
1026
1027    let sup_box = sup.map(|s| {
1028        let sup_opts = options.with_style(sup_style);
1029        layout_child(s, &sup_opts)
1030    });
1031
1032    let sub_box = sub.map(|s| {
1033        let sub_opts = options.with_style(sub_style);
1034        layout_child(s, &sub_opts)
1035    });
1036
1037    let sup_height_scaled = sup_box
1038        .as_ref()
1039        .map(|b| b.height * sup_ratio)
1040        .unwrap_or(0.0);
1041    let sup_depth_scaled = sup_box.as_ref().map(|b| b.depth * sup_ratio).unwrap_or(0.0);
1042    let sub_height_scaled = sub_box
1043        .as_ref()
1044        .map(|b| b.height * sub_ratio)
1045        .unwrap_or(0.0);
1046    let sub_depth_scaled = sub_box.as_ref().map(|b| b.depth * sub_ratio).unwrap_or(0.0);
1047
1048    // KaTeX uses the CHILD style's metrics for supDrop/subDrop, not the parent's
1049    let sup_style_metrics = get_global_metrics(sup_style.size_index());
1050    let sub_style_metrics = get_global_metrics(sub_style.size_index());
1051
1052    // Rule 18a: initial shift from base dimensions
1053    // For character boxes, supShift/subShift start at 0 (KaTeX behavior)
1054    let mut sup_shift = if !is_char_box && sup_box.is_some() {
1055        base_box.height - sup_style_metrics.sup_drop * sup_ratio
1056    } else {
1057        0.0
1058    };
1059
1060    let mut sub_shift = if !is_char_box && sub_box.is_some() {
1061        base_box.depth + sub_style_metrics.sub_drop * sub_ratio
1062    } else {
1063        0.0
1064    };
1065
1066    let min_sup_shift = if options.style.is_cramped() {
1067        metrics.sup3
1068    } else if options.style.is_display() {
1069        metrics.sup1
1070    } else {
1071        metrics.sup2
1072    };
1073
1074    if sup_box.is_some() && sub_box.is_some() {
1075        // Rule 18c+e: both sup and sub
1076        sup_shift = sup_shift
1077            .max(min_sup_shift)
1078            .max(sup_depth_scaled + 0.25 * metrics.x_height);
1079        sub_shift = sub_shift.max(metrics.sub2); // sub2 when both present
1080
1081        let rule_width = metrics.default_rule_thickness;
1082        let max_width = 4.0 * rule_width;
1083        let gap = (sup_shift - sup_depth_scaled) - (sub_height_scaled - sub_shift);
1084        if gap < max_width {
1085            sub_shift = max_width - (sup_shift - sup_depth_scaled) + sub_height_scaled;
1086            let psi = 0.8 * metrics.x_height - (sup_shift - sup_depth_scaled);
1087            if psi > 0.0 {
1088                sup_shift += psi;
1089                sub_shift -= psi;
1090            }
1091        }
1092    } else if sub_box.is_some() {
1093        // Rule 18b: sub only
1094        sub_shift = sub_shift
1095            .max(metrics.sub1)
1096            .max(sub_height_scaled - 0.8 * metrics.x_height);
1097    } else if sup_box.is_some() {
1098        // Rule 18c,d: sup only
1099        sup_shift = sup_shift
1100            .max(min_sup_shift)
1101            .max(sup_depth_scaled + 0.25 * metrics.x_height);
1102    }
1103
1104    // KaTeX `horizBrace.js` htmlBuilder: the script is placed using a VList with a fixed 0.2em
1105    // kern between the brace result and the script, plus the script's own (scaled) dimensions.
1106    // This overrides the default TeX Rule 18 sub_shift / sup_shift with the exact KaTeX layout.
1107    if horiz_brace_over && sup_box.is_some() {
1108        sup_shift = base_box.height + 0.2 + sup_depth_scaled;
1109    }
1110    if horiz_brace_under && sub_box.is_some() {
1111        sub_shift = base_box.depth + 0.2 + sub_height_scaled;
1112    }
1113
1114    // Superscript horizontal offset: `layout_symbol` already uses advance width + italic
1115    // (KaTeX `margin-right: italic`), so we must not add `glyph_italic` again here.
1116    let italic_correction = 0.0;
1117
1118    // KaTeX `supsub.js`: for SymbolNode bases, subscripts get `margin-left: -base.italic` so they
1119    // are not shifted by the base's italic correction (e.g. ∫_{A_1}).
1120    let sub_h_kern = if sub_box.is_some() && !center_scripts {
1121        -glyph_italic(&base_box)
1122    } else {
1123        0.0
1124    };
1125
1126    // Compute total dimensions (using scaled child dimensions)
1127    let mut height = base_box.height;
1128    let mut depth = base_box.depth;
1129    let mut total_width = base_box.width;
1130
1131    if let Some(ref sup_b) = sup_box {
1132        height = height.max(sup_shift + sup_height_scaled);
1133        if center_scripts {
1134            total_width = total_width.max(sup_b.width * sup_ratio + script_space);
1135        } else {
1136            total_width = total_width
1137                .max(base_box.width + italic_correction + sup_b.width * sup_ratio + script_space);
1138        }
1139    }
1140    if let Some(ref sub_b) = sub_box {
1141        depth = depth.max(sub_shift + sub_depth_scaled);
1142        if center_scripts {
1143            total_width = total_width.max(sub_b.width * sub_ratio + script_space);
1144        } else {
1145            total_width = total_width
1146                .max(base_box.width + sub_h_kern + sub_b.width * sub_ratio + script_space);
1147        }
1148    }
1149
1150    LayoutBox {
1151        width: total_width,
1152        height,
1153        depth,
1154        content: BoxContent::SupSub {
1155            base: Box::new(base_box),
1156            sup: sup_box.map(Box::new),
1157            sub: sub_box.map(Box::new),
1158            sup_shift,
1159            sub_shift,
1160            sup_scale: sup_ratio,
1161            sub_scale: sub_ratio,
1162            center_scripts,
1163            italic_correction,
1164            sub_h_kern,
1165        },
1166        color: options.color,
1167    }
1168}
1169
1170// ============================================================================
1171// Radical (square root) layout
1172// ============================================================================
1173
1174fn layout_radical(
1175    body: &ParseNode,
1176    index: Option<&ParseNode>,
1177    options: &LayoutOptions,
1178) -> LayoutBox {
1179    let cramped = options.style.cramped();
1180    let cramped_opts = options.with_style(cramped);
1181    let mut body_box = layout_node(body, &cramped_opts);
1182
1183    // Cramped style has same size_multiplier as uncramped
1184    let body_ratio = cramped.size_multiplier() / options.style.size_multiplier();
1185    body_box.height *= body_ratio;
1186    body_box.depth *= body_ratio;
1187    body_box.width *= body_ratio;
1188
1189    // Ensure non-zero inner height (KaTeX: if inner.height === 0, use xHeight)
1190    if body_box.height == 0.0 {
1191        body_box.height = options.metrics().x_height;
1192    }
1193
1194    let metrics = options.metrics();
1195    let theta = metrics.default_rule_thickness; // 0.04 for textstyle
1196
1197    // KaTeX sqrt.js: `let phi = theta; if (options.style.id < Style.TEXT.id) phi = xHeight`.
1198    // Style ids 0–1 are DISPLAY / DISPLAY_CRAMPED; TEXT is id 2. So only display styles use xHeight.
1199    let phi = if options.style.is_display() {
1200        metrics.x_height
1201    } else {
1202        theta
1203    };
1204
1205    let mut line_clearance = theta + phi / 4.0;
1206
1207    // Minimum delimiter height needed
1208    let min_delim_height = body_box.height + body_box.depth + line_clearance + theta;
1209
1210    // Select surd glyph size (simplified: use known breakpoints)
1211    // KaTeX surd sizes: small=1.0, size1=1.2, size2=1.8, size3=2.4, size4=3.0
1212    let tex_height = select_surd_height(min_delim_height);
1213    let rule_width = theta;
1214    let surd_font = crate::surd::surd_font_for_inner_height(tex_height);
1215    let advance_width = ratex_font::get_char_metrics(surd_font, 0x221A)
1216        .map(|m| m.width)
1217        .unwrap_or(0.833);
1218
1219    // Check if delimiter is taller than needed → center the extra space
1220    let delim_depth = tex_height - rule_width;
1221    if delim_depth > body_box.height + body_box.depth + line_clearance {
1222        line_clearance = (line_clearance + delim_depth - body_box.height - body_box.depth) / 2.0;
1223    }
1224
1225    let img_shift = tex_height - body_box.height - line_clearance - rule_width;
1226
1227    // Compute final box dimensions via vlist logic
1228    // height = inner.height + lineClearance + 2*ruleWidth when inner.depth=0
1229    let height = tex_height + rule_width - img_shift;
1230    let depth = if img_shift > body_box.depth {
1231        img_shift
1232    } else {
1233        body_box.depth
1234    };
1235
1236    // Root index (e.g. \sqrt[3]{x}): KaTeX uses SCRIPTSCRIPT (TeX: superscript of superscript).
1237    const INDEX_KERN: f64 = 0.05;
1238    let (index_box, index_offset, index_scale) = if let Some(index_node) = index {
1239        let root_style = options.style.superscript().superscript();
1240        let root_opts = options.with_style(root_style);
1241        let idx = layout_node(index_node, &root_opts);
1242        let index_ratio = root_style.size_multiplier() / options.style.size_multiplier();
1243        let offset = idx.width * index_ratio + INDEX_KERN;
1244        (Some(Box::new(idx)), offset, index_ratio)
1245    } else {
1246        (None, 0.0, 1.0)
1247    };
1248
1249    let width = index_offset + advance_width + body_box.width;
1250
1251    LayoutBox {
1252        width,
1253        height,
1254        depth,
1255        content: BoxContent::Radical {
1256            body: Box::new(body_box),
1257            index: index_box,
1258            index_offset,
1259            index_scale,
1260            rule_thickness: rule_width,
1261            inner_height: tex_height,
1262        },
1263        color: options.color,
1264    }
1265}
1266
1267/// Select the surd glyph height based on the required minimum delimiter height.
1268/// KaTeX uses: small(1.0), Size1(1.2), Size2(1.8), Size3(2.4), Size4(3.0).
1269fn select_surd_height(min_height: f64) -> f64 {
1270    const SURD_HEIGHTS: [f64; 5] = [1.0, 1.2, 1.8, 2.4, 3.0];
1271    for &h in &SURD_HEIGHTS {
1272        if h >= min_height {
1273            return h;
1274        }
1275    }
1276    // For very tall content, use the largest + stack
1277    SURD_HEIGHTS[4].max(min_height)
1278}
1279
1280// ============================================================================
1281// Operator layout (TeX Rule 13)
1282// ============================================================================
1283
1284const NO_SUCCESSOR: &[&str] = &["\\smallint"];
1285
1286/// Check if a SupSub's base should use limits (above/below) positioning.
1287fn should_use_op_limits(base: &ParseNode, options: &LayoutOptions) -> bool {
1288    match base {
1289        ParseNode::Op {
1290            limits,
1291            always_handle_sup_sub,
1292            ..
1293        } => *limits && (options.style.is_display() || always_handle_sup_sub.unwrap_or(false)),
1294        ParseNode::OperatorName {
1295            always_handle_sup_sub,
1296            limits,
1297            ..
1298        } => *always_handle_sup_sub && (options.style.is_display() || *limits),
1299        _ => false,
1300    }
1301}
1302
1303/// Lay out an Op node (without limits — standalone or nolimits mode).
1304///
1305/// In KaTeX, baseShift is applied via CSS `position:relative;top:` which
1306/// does NOT alter the box dimensions. So we return the original glyph
1307/// dimensions unchanged — the visual shift is handled at render time.
1308fn layout_op(
1309    name: Option<&str>,
1310    symbol: bool,
1311    body: Option<&[ParseNode]>,
1312    _limits: bool,
1313    suppress_base_shift: bool,
1314    options: &LayoutOptions,
1315) -> LayoutBox {
1316    let (mut base_box, _slant) = build_op_base(name, symbol, body, options);
1317
1318    // Center symbol operators on the math axis (TeX Rule 13a)
1319    if symbol && !suppress_base_shift {
1320        let axis = options.metrics().axis_height;
1321        let shift = (base_box.height - base_box.depth) / 2.0 - axis;
1322        if shift.abs() > 0.001 {
1323            base_box.height -= shift;
1324            base_box.depth += shift;
1325        }
1326    }
1327
1328    // For user-defined \mathop{content} (e.g. \vcentcolon), center the content
1329    // on the math axis via a RaiseBox so the glyph physically moves up/down.
1330    // The HBox emit pass keeps all children at the same baseline, so adjusting
1331    // height/depth alone doesn't move the glyph.
1332    if !suppress_base_shift && !symbol && body.is_some() {
1333        let axis = options.metrics().axis_height;
1334        let delta = (base_box.height - base_box.depth) / 2.0 - axis;
1335        if delta.abs() > 0.001 {
1336            let w = base_box.width;
1337            // delta < 0 → center is below axis → raise (positive RaiseBox shift)
1338            let raise = -delta;
1339            base_box = LayoutBox {
1340                width: w,
1341                height: (base_box.height + raise).max(0.0),
1342                depth: (base_box.depth - raise).max(0.0),
1343                content: BoxContent::RaiseBox {
1344                    body: Box::new(base_box),
1345                    shift: raise,
1346                },
1347                color: options.color,
1348            };
1349        }
1350    }
1351
1352    base_box
1353}
1354
1355/// Build the base glyph/text for an operator.
1356/// Returns (base_box, slant) where slant is the italic correction.
1357fn build_op_base(
1358    name: Option<&str>,
1359    symbol: bool,
1360    body: Option<&[ParseNode]>,
1361    options: &LayoutOptions,
1362) -> (LayoutBox, f64) {
1363    if symbol {
1364        let large = options.style.is_display() && !NO_SUCCESSOR.contains(&name.unwrap_or(""));
1365        let font_id = if large {
1366            FontId::Size2Regular
1367        } else {
1368            FontId::Size1Regular
1369        };
1370
1371        let op_name = name.unwrap_or("");
1372        let ch = resolve_op_char(op_name);
1373        let char_code = ch as u32;
1374
1375        let metrics = get_char_metrics(font_id, char_code);
1376        let (width, height, depth, italic) = match metrics {
1377            Some(m) => (m.width, m.height, m.depth, m.italic),
1378            None => (1.0, 0.75, 0.25, 0.0),
1379        };
1380        // Include italic correction in width so limits centered above/below don't overlap
1381        // the operator's right-side extension (e.g. integral ∫ has non-zero italic).
1382        let width_with_italic = width + italic;
1383
1384        let base = LayoutBox {
1385            width: width_with_italic,
1386            height,
1387            depth,
1388            content: BoxContent::Glyph { font_id, char_code },
1389            color: options.color,
1390        };
1391
1392        // \oiint and \oiiint: overlay an ellipse on the integral (∬/∭) like \oint’s circle.
1393        // resolve_op_char already maps them to ∬/∭; add the circle overlay here.
1394        if op_name == "\\oiint" || op_name == "\\oiiint" {
1395            let w = base.width;
1396            let ellipse_commands = ellipse_overlay_path(w, base.height, base.depth);
1397            let overlay_box = LayoutBox {
1398                width: w,
1399                height: base.height,
1400                depth: base.depth,
1401                content: BoxContent::SvgPath {
1402                    commands: ellipse_commands,
1403                    fill: false,
1404                },
1405                color: options.color,
1406            };
1407            let with_overlay = make_hbox(vec![base, LayoutBox::new_kern(-w), overlay_box]);
1408            return (with_overlay, italic);
1409        }
1410
1411        (base, italic)
1412    } else if let Some(body_nodes) = body {
1413        let base = layout_expression(body_nodes, options, true);
1414        (base, 0.0)
1415    } else {
1416        let base = layout_op_text(name.unwrap_or(""), options);
1417        (base, 0.0)
1418    }
1419}
1420
1421/// Render a text operator name like \sin, \cos, \lim.
1422fn layout_op_text(name: &str, options: &LayoutOptions) -> LayoutBox {
1423    let text = name.strip_prefix('\\').unwrap_or(name);
1424    let mut children = Vec::new();
1425    for ch in text.chars() {
1426        let char_code = ch as u32;
1427        let metrics = get_char_metrics(FontId::MainRegular, char_code);
1428        let (width, height, depth) = match metrics {
1429            Some(m) => (m.width, m.height, m.depth),
1430            None => (0.5, 0.43, 0.0),
1431        };
1432        children.push(LayoutBox {
1433            width,
1434            height,
1435            depth,
1436            content: BoxContent::Glyph {
1437                font_id: FontId::MainRegular,
1438                char_code,
1439            },
1440            color: options.color,
1441        });
1442    }
1443    make_hbox(children)
1444}
1445
1446/// Compute the vertical shift to center an op symbol on the math axis (Rule 13).
1447fn compute_op_base_shift(base: &LayoutBox, options: &LayoutOptions) -> f64 {
1448    let metrics = options.metrics();
1449    (base.height - base.depth) / 2.0 - metrics.axis_height
1450}
1451
1452/// Resolve an op command name to its Unicode character.
1453fn resolve_op_char(name: &str) -> char {
1454    // \oiint and \oiiint: use ∬/∭ as base glyph; circle overlay is drawn in build_op_base
1455    // (same idea as \oint’s circle, but U+222F/U+2230 often missing in math fonts).
1456    match name {
1457        "\\oiint" => return '\u{222C}',  // ∬ (double integral)
1458        "\\oiiint" => return '\u{222D}', // ∭ (triple integral)
1459        _ => {}
1460    }
1461    let font_mode = ratex_font::Mode::Math;
1462    if let Some(info) = ratex_font::get_symbol(name, font_mode) {
1463        if let Some(cp) = info.codepoint {
1464            return cp;
1465        }
1466    }
1467    name.chars().next().unwrap_or('?')
1468}
1469
1470/// Lay out an Op with limits above/below (called from SupSub delegation).
1471fn layout_op_with_limits(
1472    base_node: &ParseNode,
1473    sup_node: Option<&ParseNode>,
1474    sub_node: Option<&ParseNode>,
1475    options: &LayoutOptions,
1476) -> LayoutBox {
1477    let (name, symbol, body, suppress_base_shift) = match base_node {
1478        ParseNode::Op {
1479            name,
1480            symbol,
1481            body,
1482            suppress_base_shift,
1483            ..
1484        } => (
1485            name.as_deref(),
1486            *symbol,
1487            body.as_deref(),
1488            suppress_base_shift.unwrap_or(false),
1489        ),
1490        ParseNode::OperatorName { body, .. } => (None, false, Some(body.as_slice()), false),
1491        _ => return layout_supsub(Some(base_node), sup_node, sub_node, options, None),
1492    };
1493
1494    // KaTeX-exact limit kerning (no +0.08em) for `\overset`/`\underset` only (`suppress_base_shift`).
1495    let legacy_limit_kern_padding = !suppress_base_shift;
1496
1497    let (base_box, slant) = build_op_base(name, symbol, body, options);
1498    // baseShift only applies to symbol operators (KaTeX: base instanceof SymbolNode)
1499    let base_shift = if symbol && !suppress_base_shift {
1500        compute_op_base_shift(&base_box, options)
1501    } else {
1502        0.0
1503    };
1504
1505    layout_op_limits_inner(
1506        &base_box,
1507        sup_node,
1508        sub_node,
1509        slant,
1510        base_shift,
1511        legacy_limit_kern_padding,
1512        options,
1513    )
1514}
1515
1516/// Assemble an operator with limits above/below (KaTeX's `assembleSupSub`).
1517///
1518/// `legacy_limit_kern_padding`: +0.08em on limit kerns for all ops except `\overset`/`\underset`
1519/// (`ParseNode::Op { suppress_base_shift: true }`), matching KaTeX on `\dddot`/`\ddddot` PNGs.
1520fn layout_op_limits_inner(
1521    base: &LayoutBox,
1522    sup_node: Option<&ParseNode>,
1523    sub_node: Option<&ParseNode>,
1524    slant: f64,
1525    base_shift: f64,
1526    legacy_limit_kern_padding: bool,
1527    options: &LayoutOptions,
1528) -> LayoutBox {
1529    let metrics = options.metrics();
1530    let sup_style = options.style.superscript();
1531    let sub_style = options.style.subscript();
1532
1533    let sup_ratio = sup_style.size_multiplier() / options.style.size_multiplier();
1534    let sub_ratio = sub_style.size_multiplier() / options.style.size_multiplier();
1535
1536    let extra_kern = if legacy_limit_kern_padding {
1537        0.08_f64
1538    } else {
1539        0.0_f64
1540    };
1541
1542    let sup_data = sup_node.map(|s| {
1543        let sup_opts = options.with_style(sup_style);
1544        let elem = layout_node(s, &sup_opts);
1545        // `\overset`/`\underset`: KaTeX `assembleSupSub` uses `elem.depth` as-is. Other limits
1546        // (e.g. `\lim\limits_x`) keep the legacy `depth * sup_ratio` term so ink scores stay
1547        // aligned with our KaTeX PNG fixtures.
1548        let d = if legacy_limit_kern_padding {
1549            elem.depth * sup_ratio
1550        } else {
1551            elem.depth
1552        };
1553        let kern =
1554            (metrics.big_op_spacing1 + extra_kern).max(metrics.big_op_spacing3 - d + extra_kern);
1555        (elem, kern)
1556    });
1557
1558    let sub_data = sub_node.map(|s| {
1559        let sub_opts = options.with_style(sub_style);
1560        let elem = layout_node(s, &sub_opts);
1561        let h = if legacy_limit_kern_padding {
1562            elem.height * sub_ratio
1563        } else {
1564            elem.height
1565        };
1566        let kern =
1567            (metrics.big_op_spacing2 + extra_kern).max(metrics.big_op_spacing4 - h + extra_kern);
1568        (elem, kern)
1569    });
1570
1571    let sp5 = metrics.big_op_spacing5;
1572
1573    let (total_height, total_depth, total_width) = match (&sup_data, &sub_data) {
1574        (Some((sup_elem, sup_kern)), Some((sub_elem, sub_kern))) => {
1575            // Both sup and sub: VList from bottom
1576            // [sp5, sub, sub_kern, base, sup_kern, sup, sp5]
1577            let sup_h = sup_elem.height * sup_ratio;
1578            let sup_d = sup_elem.depth * sup_ratio;
1579            let sub_h = sub_elem.height * sub_ratio;
1580            let sub_d = sub_elem.depth * sub_ratio;
1581
1582            let bottom = sp5 + sub_h + sub_d + sub_kern + base.depth + base_shift;
1583
1584            let height = bottom + base.height - base_shift + sup_kern + sup_h + sup_d + sp5
1585                - (base.height + base.depth);
1586
1587            let total_h = base.height - base_shift + sup_kern + sup_h + sup_d + sp5;
1588            let total_d = bottom;
1589
1590            let w = base
1591                .width
1592                .max(sup_elem.width * sup_ratio)
1593                .max(sub_elem.width * sub_ratio);
1594            let _ = height; // suppress unused; we use total_h/total_d
1595            (total_h, total_d, w)
1596        }
1597        (None, Some((sub_elem, sub_kern))) => {
1598            // Sub only: VList from top
1599            // [sp5, sub, sub_kern, base]
1600            let sub_h = sub_elem.height * sub_ratio;
1601            let sub_d = sub_elem.depth * sub_ratio;
1602
1603            let total_h = base.height - base_shift;
1604            let total_d = base.depth + base_shift + sub_kern + sub_h + sub_d + sp5;
1605
1606            let w = base.width.max(sub_elem.width * sub_ratio);
1607            (total_h, total_d, w)
1608        }
1609        (Some((sup_elem, sup_kern)), None) => {
1610            // Sup only: VList from bottom
1611            // [base, sup_kern, sup, sp5]
1612            let sup_h = sup_elem.height * sup_ratio;
1613            let sup_d = sup_elem.depth * sup_ratio;
1614
1615            let total_h = base.height - base_shift + sup_kern + sup_h + sup_d + sp5;
1616            let total_d = base.depth + base_shift;
1617
1618            let w = base.width.max(sup_elem.width * sup_ratio);
1619            (total_h, total_d, w)
1620        }
1621        (None, None) => {
1622            return base.clone();
1623        }
1624    };
1625
1626    let sup_kern_val = sup_data.as_ref().map(|(_, k)| *k).unwrap_or(0.0);
1627    let sub_kern_val = sub_data.as_ref().map(|(_, k)| *k).unwrap_or(0.0);
1628
1629    LayoutBox {
1630        width: total_width,
1631        height: total_height,
1632        depth: total_depth,
1633        content: BoxContent::OpLimits {
1634            base: Box::new(base.clone()),
1635            sup: sup_data.map(|(elem, _)| Box::new(elem)),
1636            sub: sub_data.map(|(elem, _)| Box::new(elem)),
1637            base_shift,
1638            sup_kern: sup_kern_val,
1639            sub_kern: sub_kern_val,
1640            slant,
1641            sup_scale: sup_ratio,
1642            sub_scale: sub_ratio,
1643        },
1644        color: options.color,
1645    }
1646}
1647
1648/// Lay out \operatorname body as roman text.
1649fn layout_operatorname(body: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
1650    let mut children = Vec::new();
1651    for node in body {
1652        match node {
1653            ParseNode::MathOrd { text, .. } | ParseNode::TextOrd { text, .. } => {
1654                let ch = text.chars().next().unwrap_or('?');
1655                let char_code = ch as u32;
1656                let metrics = get_char_metrics(FontId::MainRegular, char_code);
1657                let (width, height, depth) = match metrics {
1658                    Some(m) => (m.width, m.height, m.depth),
1659                    None => (0.5, 0.43, 0.0),
1660                };
1661                children.push(LayoutBox {
1662                    width,
1663                    height,
1664                    depth,
1665                    content: BoxContent::Glyph {
1666                        font_id: FontId::MainRegular,
1667                        char_code,
1668                    },
1669                    color: options.color,
1670                });
1671            }
1672            _ => {
1673                children.push(layout_node(node, options));
1674            }
1675        }
1676    }
1677    make_hbox(children)
1678}
1679
1680// ============================================================================
1681// Accent layout
1682// ============================================================================
1683
1684/// `\vec` KaTeX SVG: nudge slightly right to match KaTeX reference.
1685const VEC_SKEW_EXTRA_RIGHT_EM: f64 = 0.018;
1686
1687/// Extract the italic correction of the base glyph.
1688/// Used by superscripts: KaTeX adds margin-right = italic_correction to italic math characters,
1689/// so the superscript starts at advance_width + italic_correction (not just advance_width).
1690fn glyph_italic(lb: &LayoutBox) -> f64 {
1691    match &lb.content {
1692        BoxContent::Glyph { font_id, char_code } => get_char_metrics(*font_id, *char_code)
1693            .map(|m| m.italic)
1694            .unwrap_or(0.0),
1695        BoxContent::HBox(children) => children.last().map(glyph_italic).unwrap_or(0.0),
1696        _ => 0.0,
1697    }
1698}
1699
1700/// Extract the skew (italic correction) of the innermost/last glyph in a box.
1701/// Used by shifty accents (\hat, \tilde…) to horizontally centre the mark
1702/// over italic math letters (e.g. M in MathItalic has skew ≈ 0.083em).
1703/// KaTeX `groupLength` for wide SVG accents: `ordgroup.body.length`, else 1.
1704fn accent_ordgroup_len(base: &ParseNode) -> usize {
1705    match base {
1706        ParseNode::OrdGroup { body, .. } => body.len().max(1),
1707        _ => 1,
1708    }
1709}
1710
1711fn glyph_skew(lb: &LayoutBox) -> f64 {
1712    match &lb.content {
1713        BoxContent::Glyph { font_id, char_code } => get_char_metrics(*font_id, *char_code)
1714            .map(|m| m.skew)
1715            .unwrap_or(0.0),
1716        BoxContent::HBox(children) => children.last().map(glyph_skew).unwrap_or(0.0),
1717        _ => 0.0,
1718    }
1719}
1720
1721fn layout_accent(
1722    label: &str,
1723    base: &ParseNode,
1724    is_stretchy: bool,
1725    is_shifty: bool,
1726    is_below: bool,
1727    options: &LayoutOptions,
1728) -> LayoutBox {
1729    let body_box = layout_node(base, options);
1730    let base_w = body_box.width.max(0.5);
1731
1732    // Special handling for \textcircled: draw a circle around the content
1733    if label == "\\textcircled" {
1734        return layout_textcircled(body_box, options);
1735    }
1736
1737    // Try KaTeX exact SVG paths first (widehat, widetilde, overgroup, etc.).
1738    // The path width must stay inside the accent box; renderers size their canvas from
1739    // DisplayList.width, so drawing wider than the box clips in an enclosing HBox.
1740    let accent_target_w = base_w;
1741    if let Some((commands, w, h, fill)) =
1742        crate::katex_svg::katex_accent_path(label, accent_target_w, accent_ordgroup_len(base))
1743    {
1744        // KaTeX paths use SVG coords (y down): height=0, depth=h
1745        let accent_box = LayoutBox {
1746            width: w,
1747            height: 0.0,
1748            depth: h,
1749            content: BoxContent::SvgPath { commands, fill },
1750            color: options.color,
1751        };
1752        // KaTeX `accent.ts` uses `clearance = min(body.height, xHeight)` for ordinary accents.
1753        // That matches fixed-size `\vec` (svgData.vec); using it for *width-scaled* SVG accents
1754        // (\widehat, \widetilde, \overgroup, …) pulls the path down onto the base (golden 0604/0885/0886).
1755        // Slightly tighter than 0.08em — aligns wide SVG hats with KaTeX PNG crops (e.g. 0935).
1756        let gap = 0.065;
1757        let under_gap_em = if is_below && label == "\\utilde" {
1758            0.12
1759        } else {
1760            0.0
1761        };
1762        let clearance = if is_below {
1763            body_box.height + body_box.depth + gap
1764        } else if label == "\\vec" {
1765            // KaTeX: clearance = min(body.height, xHeight) is used as *overlap* (kern down).
1766            // Equivalent RaTeX position: vec bottom = body.height - overlap = max(0, body.height - xHeight).
1767            (body_box.height - options.metrics().x_height).max(0.0)
1768        } else {
1769            body_box.height + gap
1770        };
1771        let (height, depth) = if is_below {
1772            (body_box.height, body_box.depth + h + gap + under_gap_em)
1773        } else if label == "\\vec" {
1774            // Box height = clearance + H_EM, matching KaTeX VList height.
1775            (clearance + h, body_box.depth)
1776        } else {
1777            (body_box.height + gap + h, body_box.depth)
1778        };
1779        let vec_skew = if label == "\\vec" {
1780            (if is_shifty {
1781                glyph_skew(&body_box)
1782            } else {
1783                0.0
1784            }) + VEC_SKEW_EXTRA_RIGHT_EM
1785        } else {
1786            0.0
1787        };
1788        return LayoutBox {
1789            width: body_box.width,
1790            height,
1791            depth,
1792            content: BoxContent::Accent {
1793                base: Box::new(body_box),
1794                accent: Box::new(accent_box),
1795                clearance,
1796                skew: vec_skew,
1797                is_below,
1798                under_gap_em,
1799            },
1800            color: options.color,
1801        };
1802    }
1803
1804    // Arrow-type stretchy accents (overrightarrow, etc.)
1805    let use_arrow_path = is_stretchy && is_arrow_accent(label);
1806
1807    let accent_box = if use_arrow_path {
1808        let (commands, arrow_h, fill_arrow) =
1809            match crate::katex_svg::katex_stretchy_path(label, base_w) {
1810                Some((c, h)) => (c, h, true),
1811                None => {
1812                    let h = 0.3_f64;
1813                    let c = stretchy_accent_path(label, base_w, h);
1814                    let fill = label == "\\xtwoheadrightarrow" || label == "\\xtwoheadleftarrow";
1815                    (c, h, fill)
1816                }
1817            };
1818        LayoutBox {
1819            width: base_w,
1820            height: arrow_h / 2.0,
1821            depth: arrow_h / 2.0,
1822            content: BoxContent::SvgPath {
1823                commands,
1824                fill: fill_arrow,
1825            },
1826            color: options.color,
1827        }
1828    } else {
1829        // Try text mode first for text accents (\c, \', \`, etc.), fall back to math
1830        let accent_char = {
1831            let ch = resolve_symbol_char(label, Mode::Text);
1832            if ch == label.chars().next().unwrap_or('?') {
1833                // Text mode didn't resolve (returned first char of label, likely '\\')
1834                // so try math mode
1835                resolve_symbol_char(label, Mode::Math)
1836            } else {
1837                ch
1838            }
1839        };
1840        let accent_code = accent_char as u32;
1841        let accent_metrics = get_char_metrics(FontId::MainRegular, accent_code);
1842        let (accent_w, accent_h, accent_d) = match accent_metrics {
1843            Some(m) => (m.width, m.height, m.depth),
1844            None => (body_box.width, 0.25, 0.0),
1845        };
1846        LayoutBox {
1847            width: accent_w,
1848            height: accent_h,
1849            depth: accent_d,
1850            content: BoxContent::Glyph {
1851                font_id: FontId::MainRegular,
1852                char_code: accent_code,
1853            },
1854            color: options.color,
1855        }
1856    };
1857
1858    let skew = if use_arrow_path {
1859        0.0
1860    } else if is_shifty {
1861        // For shifty accents (\hat, \tilde, etc.) shift by the BASE character's skew,
1862        // which encodes the italic correction in math-italic fonts (e.g. M → 0.083em).
1863        glyph_skew(&body_box)
1864    } else {
1865        0.0
1866    };
1867
1868    // gap = clearance between body top and bottom of accent SVG.
1869    // For arrow accents, the SVG path is centered (height=h/2, depth=h/2).
1870    // The gap prevents the visible arrowhead / harpoon tip from overlapping the base top.
1871    //
1872    // KaTeX stretchy arrows with vb_height 522 have h/2 ≈ 0.261em; default gap=0.12 left
1873    // too little room for tall caps (`\overleftrightarrow{AB}`, `\overleftarrow{AB}`,
1874    // `\overleftharpoon{AB}`, …).  `\Overrightarrow` uses a taller glyph (vb 560) and keeps
1875    // the slightly smaller kern used in prior tuning.
1876    let gap = if use_arrow_path {
1877        if label == "\\Overrightarrow" {
1878            0.21
1879        } else {
1880            0.26
1881        }
1882    } else {
1883        0.0
1884    };
1885
1886    let clearance = if is_below {
1887        body_box.height + body_box.depth + accent_box.depth + gap
1888    } else if use_arrow_path {
1889        body_box.height + gap
1890    } else {
1891        // Clearance = how high above baseline the accent is positioned.
1892        // - For simple letters (M, b, o): body_box.height is the letter top → use directly.
1893        // - For a body that is itself an above-accent (\r{a}, `\tilde{\tilde{x}}`, …):
1894        //   use the same kern basis as a plain base (`max(0, body.height - xHeight) +
1895        //   correction`, with `\bar`/`\=` exceptions) instead of `inner_clearance + ε`, which
1896        //   double-counted stacked accent depths and inflated nested spacing vs KaTeX.
1897        let base_clearance = match &body_box.content {
1898            BoxContent::Accent {
1899                clearance: inner_cl,
1900                is_below,
1901                accent: inner_accent,
1902                ..
1903            } if !is_below => {
1904                // For SVG accents (height≈0, e.g. \vec): body_box.height = clearance + H_EM,
1905                // which matches KaTeX's body.height. Use min(body.height, xHeight) exactly as
1906                // KaTeX does: clearance = min(body.height, xHeight).
1907                if inner_accent.height <= 0.001 {
1908                    // For SVG accents like \vec: KaTeX places the outer glyph accent with
1909                    // its baseline at body.height - min(body.height, xHeight) above formula
1910                    // baseline, i.e. max(0, body.height - xHeight).
1911                    // to_display.rs shifts the glyph DOWN by (accent_h - 0.35.min(accent_h))
1912                    // so we pre-add that correction to land at the right position.
1913                    let katex_pos = (body_box.height - options.metrics().x_height).max(0.0);
1914                    let correction = (accent_box.height - 0.35_f64.min(accent_box.height)).max(0.0);
1915                    katex_pos + correction
1916                } else {
1917                    // `inner_cl` already includes the inner accent glyph's depth. Using
1918                    // `inner_cl + ε` stacked another full kern on top (e.g. `\tilde{\tilde{x}}`
1919                    // blew up to ~1.32em vs KaTeX ~0.90em). KaTeX recomputes clearance from the
1920                    // built inner span via `min(body.height, xHeight)`; matching the non-nested
1921                    // glyph path (`max(0, body.height - xHeight) + correction`) tracks that.
1922                    if label == "\\bar" || label == "\\=" {
1923                        body_box.height
1924                    } else {
1925                        // `\hat{x}` / `\dot{x}` / … enforce a 0.78056em strut so `body_box.height`
1926                        // exceeds the ink top, while KaTeX's nested accent still uses the inner
1927                        // span height (~0.6944em) for clearance. `\tilde{x}` keeps body ≈ visual
1928                        // top, so we keep `body_box.height` when it is not strut-inflated.
1929                        let inner_visual_top = inner_cl + 0.35_f64.min(inner_accent.height);
1930                        let h_for_kern = if body_box.height > inner_visual_top + 0.002 {
1931                            inner_visual_top
1932                        } else {
1933                            body_box.height
1934                        };
1935                        let katex_pos = (h_for_kern - options.metrics().x_height).max(0.0);
1936                        let correction =
1937                            (accent_box.height - 0.35_f64.min(accent_box.height)).max(0.0);
1938                        katex_pos + correction
1939                    }
1940                }
1941            }
1942            _ => {
1943                // KaTeX positions glyph accents by kerning DOWN by
1944                // min(body.height, xHeight), so the accent baseline sits at
1945                //   max(0, body.height - xHeight)
1946                // above the formula baseline.  This keeps the accent within the
1947                // body's height bounds for normal-height bases and produces a
1948                // formula height == body_height (accent adds no extra height),
1949                // matching KaTeX's VList.
1950                //
1951                // \bar / \= (macron) are an exception: for x-height bases (a, e, o, …)
1952                // body.height ≈ xHeight so katex_pos ≈ 0 and the bar sits on the letter
1953                // (golden \text{\={a}}).  Tie macron clearance to full body height like
1954                // the pre-62f7ba53 engine, then apply the same small kern as before.
1955                if label == "\\bar" || label == "\\=" {
1956                    body_box.height
1957                } else {
1958                    let katex_pos = (body_box.height - options.metrics().x_height).max(0.0);
1959                    let correction = (accent_box.height - 0.35_f64.min(accent_box.height)).max(0.0);
1960                    katex_pos + correction
1961                }
1962            }
1963        };
1964        // KaTeX VList places the accent so its depth-bottom edge sits at the kern
1965        // position.  The accent baseline is therefore depth higher than that edge.
1966        // Without this term, glyphs with non-zero depth (notably \tilde, depth=0.35)
1967        // are positioned too low, overlapping the base character.
1968        let base_clearance = base_clearance + accent_box.depth;
1969        if label == "\\bar" || label == "\\=" {
1970            (base_clearance - 0.12).max(0.0)
1971        } else {
1972            base_clearance
1973        }
1974    };
1975
1976    let (height, depth) = if is_below {
1977        (
1978            body_box.height,
1979            body_box.depth + accent_box.height + accent_box.depth + gap,
1980        )
1981    } else if use_arrow_path {
1982        (body_box.height + gap + accent_box.height, body_box.depth)
1983    } else {
1984        // to_display.rs shifts every glyph accent DOWN by max(0, accent.height - 0.35),
1985        // so the actual visual top of the accent mark = clearance + min(0.35, accent.height).
1986        // Use this for the layout height so nested accents (e.g. \hat{\r{a}}) see the
1987        // correct base height instead of the over-estimated clearance + accent.height.
1988        // For \hat, \bar, \dot, \ddot: also enforce KaTeX's 0.78056em strut so that
1989        // short bases (x_height ≈ 0.43) produce consistent line spacing.
1990        const ACCENT_ABOVE_STRUT_HEIGHT_EM: f64 = 0.78056;
1991        let accent_visual_top = clearance + 0.35_f64.min(accent_box.height);
1992        let h = if matches!(label, "\\hat" | "\\bar" | "\\=" | "\\dot" | "\\ddot") {
1993            accent_visual_top.max(ACCENT_ABOVE_STRUT_HEIGHT_EM)
1994        } else {
1995            body_box.height.max(accent_visual_top)
1996        };
1997        (h, body_box.depth)
1998    };
1999
2000    LayoutBox {
2001        width: body_box.width,
2002        height,
2003        depth,
2004        content: BoxContent::Accent {
2005            base: Box::new(body_box),
2006            accent: Box::new(accent_box),
2007            clearance,
2008            skew,
2009            is_below,
2010            under_gap_em: 0.0,
2011        },
2012        color: options.color,
2013    }
2014}
2015
2016// ============================================================================
2017// Left/Right stretchy delimiters
2018// ============================================================================
2019
2020/// Returns true if the node (or any descendant in the current `\left...\right`
2021/// scope) is a Middle node.  A nested LeftRight starts its own scope, so its
2022/// `\middle`s are handled by that nested layout pass.
2023fn node_contains_middle(node: &ParseNode, style: MathStyle) -> bool {
2024    match node {
2025        ParseNode::Middle { .. } => true,
2026        ParseNode::OrdGroup { body, .. } | ParseNode::MClass { body, .. } => {
2027            body.iter().any(|n| node_contains_middle(n, style))
2028        }
2029        ParseNode::SupSub { base, sup, sub, .. } => {
2030            base.as_deref()
2031                .is_some_and(|n| node_contains_middle(n, style))
2032                || sup
2033                    .as_deref()
2034                    .is_some_and(|n| node_contains_middle(n, style.superscript()))
2035                || sub
2036                    .as_deref()
2037                    .is_some_and(|n| node_contains_middle(n, style.subscript()))
2038        }
2039        ParseNode::GenFrac { numer, denom, .. } => {
2040            node_contains_middle(numer, style.numerator())
2041                || node_contains_middle(denom, style.denominator())
2042        }
2043        ParseNode::Sqrt { body, index, .. } => {
2044            node_contains_middle(body, style.cramped())
2045                || index
2046                    .as_deref()
2047                    .is_some_and(|n| node_contains_middle(n, style.superscript().superscript()))
2048        }
2049        ParseNode::Accent { base, .. } | ParseNode::AccentUnder { base, .. } => {
2050            node_contains_middle(base, style)
2051        }
2052        ParseNode::Op { body, .. } => body
2053            .as_ref()
2054            .is_some_and(|b| b.iter().any(|n| node_contains_middle(n, style))),
2055        ParseNode::LeftRight { .. } => false,
2056        ParseNode::OperatorName { body, .. } => body.iter().any(|n| node_contains_middle(n, style)),
2057        ParseNode::Font { body, .. } => node_contains_middle(body, style),
2058        ParseNode::Text { body, .. } | ParseNode::Sizing { body, .. } => {
2059            body.iter().any(|n| node_contains_middle(n, style.text()))
2060        }
2061        ParseNode::Color { body, .. } => body.iter().any(|n| node_contains_middle(n, style)),
2062        ParseNode::Styling {
2063            style: node_style,
2064            body,
2065            ..
2066        } => {
2067            let new_style = style_str_to_math_style(node_style);
2068            body.iter().any(|n| node_contains_middle(n, new_style))
2069        }
2070        ParseNode::Overline { body, .. } | ParseNode::Underline { body, .. } => {
2071            node_contains_middle(body, style)
2072        }
2073        ParseNode::Phantom { body, .. } => body.iter().any(|n| node_contains_middle(n, style)),
2074        ParseNode::VPhantom { body, .. } | ParseNode::Smash { body, .. } => {
2075            node_contains_middle(body, style)
2076        }
2077        ParseNode::Array { body, .. } => body
2078            .iter()
2079            .any(|row| row.iter().any(|n| node_contains_middle(n, style))),
2080        ParseNode::Enclose { body, .. }
2081        | ParseNode::Lap { body, .. }
2082        | ParseNode::RaiseBox { body, .. }
2083        | ParseNode::VCenter { body, .. } => node_contains_middle(body, style),
2084        ParseNode::Pmb { body, .. } => body.iter().any(|n| node_contains_middle(n, style)),
2085        ParseNode::XArrow { body, below, .. } => {
2086            node_contains_middle(body, style.superscript())
2087                || below
2088                    .as_deref()
2089                    .is_some_and(|n| node_contains_middle(n, style.subscript()))
2090        }
2091        ParseNode::CdArrow {
2092            label_above,
2093            label_below,
2094            ..
2095        } => {
2096            label_above
2097                .as_deref()
2098                .is_some_and(|n| node_contains_middle(n, style.superscript()))
2099                || label_below
2100                    .as_deref()
2101                    .is_some_and(|n| node_contains_middle(n, style.subscript()))
2102        }
2103        ParseNode::MathChoice {
2104            display,
2105            text,
2106            script,
2107            scriptscript,
2108            ..
2109        } => {
2110            let branch = match style {
2111                MathStyle::Display | MathStyle::DisplayCramped => display,
2112                MathStyle::Text | MathStyle::TextCramped => text,
2113                MathStyle::Script | MathStyle::ScriptCramped => script,
2114                MathStyle::ScriptScript | MathStyle::ScriptScriptCramped => scriptscript,
2115            };
2116            branch.iter().any(|n| node_contains_middle(n, style))
2117        }
2118        ParseNode::HorizBrace { base, .. } => node_contains_middle(base, style),
2119        ParseNode::Href { body, .. } | ParseNode::Html { body, .. } => {
2120            body.iter().any(|n| node_contains_middle(n, style))
2121        }
2122        ParseNode::HtmlMathMl { html, .. } => html.iter().any(|n| node_contains_middle(n, style)),
2123        _ => false,
2124    }
2125}
2126
2127/// Returns true if any node in the slice contains a Middle node governed by the
2128/// current `\left...\right` pass.
2129fn body_contains_middle(nodes: &[ParseNode], style: MathStyle) -> bool {
2130    nodes.iter().any(|n| node_contains_middle(n, style))
2131}
2132
2133/// KaTeX genfrac HTML Rule 15e: `\binom`, `\brace`, `\brack`, `\atop` use `delim1`/`delim2`
2134/// from font metrics, not the `\left`/`\right` height formula (`makeLeftRightDelim` vs genfrac).
2135fn genfrac_delim_target_height(options: &LayoutOptions) -> f64 {
2136    let m = options.metrics();
2137    if options.style.is_display() {
2138        m.delim1
2139    } else if matches!(
2140        options.style,
2141        MathStyle::ScriptScript | MathStyle::ScriptScriptCramped
2142    ) {
2143        options.with_style(MathStyle::Script).metrics().delim2
2144    } else {
2145        m.delim2
2146    }
2147}
2148
2149/// Required total height for `\left`/`\right` stretchy delimiters (TeX `\sigma_4` rule).
2150fn left_right_delim_total_height(inner: &LayoutBox, options: &LayoutOptions) -> f64 {
2151    let metrics = options.metrics();
2152    let inner_height = inner.height;
2153    let inner_depth = inner.depth;
2154    let axis = metrics.axis_height;
2155    let max_dist = (inner_height - axis).max(inner_depth + axis);
2156    let delim_factor = 901.0;
2157    let delim_extend = 5.0 / metrics.pt_per_em;
2158    let from_formula = (max_dist / 500.0 * delim_factor).max(2.0 * max_dist - delim_extend);
2159    // Ensure delimiter is at least as tall as inner content
2160    from_formula.max(inner_height + inner_depth)
2161}
2162
2163fn layout_left_right(
2164    body: &[ParseNode],
2165    left_delim: &str,
2166    right_delim: &str,
2167    options: &LayoutOptions,
2168) -> LayoutBox {
2169    let (inner, total_height) = if body_contains_middle(body, options.style) {
2170        // First pass: layout with no delim height so \middle doesn't inflate inner size.
2171        let opts_first = LayoutOptions {
2172            leftright_delim_height: None,
2173            ..options.clone()
2174        };
2175        let inner_first = layout_expression(body, &opts_first, true);
2176        let total_height = left_right_delim_total_height(&inner_first, options);
2177        // Second pass: layout with total_height so \middle stretches to match \left and \right.
2178        let opts_second = LayoutOptions {
2179            leftright_delim_height: Some(total_height),
2180            ..options.clone()
2181        };
2182        let inner_second = layout_expression(body, &opts_second, true);
2183        (inner_second, total_height)
2184    } else {
2185        let inner = layout_expression(body, options, true);
2186        let total_height = left_right_delim_total_height(&inner, options);
2187        (inner, total_height)
2188    };
2189
2190    let inner_height = inner.height;
2191    let inner_depth = inner.depth;
2192
2193    let left_box = make_stretchy_delim(left_delim, total_height, options);
2194    let right_box = make_stretchy_delim(right_delim, total_height, options);
2195
2196    let width = left_box.width + inner.width + right_box.width;
2197    let height = left_box.height.max(right_box.height).max(inner_height);
2198    let depth = left_box.depth.max(right_box.depth).max(inner_depth);
2199
2200    LayoutBox {
2201        width,
2202        height,
2203        depth,
2204        content: BoxContent::LeftRight {
2205            left: Box::new(left_box),
2206            right: Box::new(right_box),
2207            inner: Box::new(inner),
2208        },
2209        color: options.color,
2210    }
2211}
2212
2213const DELIM_FONT_SEQUENCE: [FontId; 5] = [
2214    FontId::MainRegular,
2215    FontId::Size1Regular,
2216    FontId::Size2Regular,
2217    FontId::Size3Regular,
2218    FontId::Size4Regular,
2219];
2220
2221/// Normalize angle-bracket delimiter aliases to \langle / \rangle.
2222fn normalize_delim(delim: &str) -> &str {
2223    match delim {
2224        "<" | "\\lt" | "\u{27E8}" => "\\langle",
2225        ">" | "\\gt" | "\u{27E9}" => "\\rangle",
2226        _ => delim,
2227    }
2228}
2229
2230/// Return true if delimiter should be rendered as a single vertical bar SVG path.
2231fn is_vert_delim(delim: &str) -> bool {
2232    matches!(delim, "|" | "\\vert" | "\\lvert" | "\\rvert")
2233}
2234
2235/// Return true if delimiter should be rendered as a double vertical bar SVG path.
2236fn is_double_vert_delim(delim: &str) -> bool {
2237    matches!(delim, "\\|" | "\\Vert" | "\\lVert" | "\\rVert")
2238}
2239
2240/// KaTeX `delimiter.makeStackedDelim`: total span of one repeat piece (U+2223 / U+2225) in Size1-Regular.
2241fn vert_repeat_piece_height(is_double: bool) -> f64 {
2242    let code = if is_double { 8741_u32 } else { 8739 };
2243    get_char_metrics(FontId::Size1Regular, code)
2244        .map(|m| m.height + m.depth)
2245        .unwrap_or(0.5)
2246}
2247
2248/// Match KaTeX `realHeightTotal` for stack-always `|` / `\Vert` delimiters.
2249fn katex_vert_real_height(requested_total: f64, is_double: bool, is_sized_delim: bool) -> f64 {
2250    let piece = vert_repeat_piece_height(is_double);
2251    let min_h = 2.0 * piece;
2252    let repeat_count = ((requested_total - min_h) / piece).ceil().max(0.0);
2253    let mut h = min_h + repeat_count * piece;
2254    // KaTeX's sized vertical bars are CSS SVG spans with a slightly taller ink
2255    // crop in browser screenshots than our native path rasterization. Keep this
2256    // adjustment scoped to \big/\Big/\bigg/\Bigg so ordinary stretchy \left bars
2257    // still follow content height directly.
2258    if is_sized_delim && !is_double {
2259        h *= if requested_total < 3.0 { 1.2 } else { 1.135 };
2260    }
2261    h
2262}
2263
2264/// KaTeX `svgGeometry.tallDelim` paths for `"vert"` / `"doublevert"` (viewBox units per em width).
2265fn tall_vert_svg_path_data(mid_th: i64, is_double: bool) -> String {
2266    let neg = -mid_th;
2267    if !is_double {
2268        format!(
2269            "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"
2270        )
2271    } else {
2272        format!(
2273            "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"
2274        )
2275    }
2276}
2277
2278fn scale_svg_path_to_em(cmds: &[PathCommand]) -> Vec<PathCommand> {
2279    let s = 0.001_f64;
2280    cmds.iter()
2281        .map(|c| match *c {
2282            PathCommand::MoveTo { x, y } => PathCommand::MoveTo { x: x * s, y: y * s },
2283            PathCommand::LineTo { x, y } => PathCommand::LineTo { x: x * s, y: y * s },
2284            PathCommand::CubicTo {
2285                x1,
2286                y1,
2287                x2,
2288                y2,
2289                x,
2290                y,
2291            } => PathCommand::CubicTo {
2292                x1: x1 * s,
2293                y1: y1 * s,
2294                x2: x2 * s,
2295                y2: y2 * s,
2296                x: x * s,
2297                y: y * s,
2298            },
2299            PathCommand::QuadTo { x1, y1, x, y } => PathCommand::QuadTo {
2300                x1: x1 * s,
2301                y1: y1 * s,
2302                x: x * s,
2303                y: y * s,
2304            },
2305            PathCommand::Close => PathCommand::Close,
2306        })
2307        .collect()
2308}
2309
2310/// Map KaTeX top-origin SVG y (after ×0.001) to RaTeX baseline coords (top −height, bottom +depth).
2311fn map_vert_path_y_to_baseline(
2312    cmds: Vec<PathCommand>,
2313    height: f64,
2314    depth: f64,
2315    view_box_height: i64,
2316) -> Vec<PathCommand> {
2317    let span_em = view_box_height as f64 / 1000.0;
2318    let total = height + depth;
2319    let scale_y = if span_em > 0.0 { total / span_em } else { 1.0 };
2320    cmds.into_iter()
2321        .map(|c| match c {
2322            PathCommand::MoveTo { x, y } => PathCommand::MoveTo {
2323                x,
2324                y: -height + y * scale_y,
2325            },
2326            PathCommand::LineTo { x, y } => PathCommand::LineTo {
2327                x,
2328                y: -height + y * scale_y,
2329            },
2330            PathCommand::CubicTo {
2331                x1,
2332                y1,
2333                x2,
2334                y2,
2335                x,
2336                y,
2337            } => PathCommand::CubicTo {
2338                x1,
2339                y1: -height + y1 * scale_y,
2340                x2,
2341                y2: -height + y2 * scale_y,
2342                x,
2343                y: -height + y * scale_y,
2344            },
2345            PathCommand::QuadTo { x1, y1, x, y } => PathCommand::QuadTo {
2346                x1,
2347                y1: -height + y1 * scale_y,
2348                x,
2349                y: -height + y * scale_y,
2350            },
2351            PathCommand::Close => PathCommand::Close,
2352        })
2353        .collect()
2354}
2355
2356fn make_shortmid_box(options: &LayoutOptions) -> LayoutBox {
2357    let total = 0.675_f64;
2358    let axis = options.metrics().axis_height;
2359    let depth = (total / 2.0 - axis).max(0.0);
2360    let height = total - depth;
2361    let raw = parse_svg_path_data(&tall_vert_svg_path_data(0, false));
2362    let scaled = scale_svg_path_to_em(&raw);
2363    let commands = map_vert_path_y_to_baseline(scaled, height, depth, 1170);
2364    LayoutBox {
2365        width: 0.22222,
2366        height,
2367        depth,
2368        content: BoxContent::SvgPath {
2369            commands,
2370            fill: true,
2371        },
2372        color: options.color,
2373    }
2374}
2375
2376fn make_textunderscore_box(options: &LayoutOptions) -> LayoutBox {
2377    LayoutBox {
2378        width: 0.65,
2379        height: 0.0,
2380        depth: 0.31,
2381        content: BoxContent::Rule {
2382            thickness: 0.08,
2383            raise: -0.31,
2384        },
2385        color: options.color,
2386    }
2387}
2388
2389fn make_corresponds_symbol(options: &LayoutOptions) -> LayoutBox {
2390    let width = 1.16;
2391    let height = 0.55;
2392    let depth = 0.24;
2393    let rule_thickness = 0.055;
2394    let arc = LayoutBox {
2395        width,
2396        height: 0.36,
2397        depth: 0.0,
2398        content: BoxContent::SvgPath {
2399            commands: vec![
2400                PathCommand::MoveTo { x: 0.02, y: 0.0 },
2401                PathCommand::CubicTo {
2402                    x1: 0.18,
2403                    y1: -0.34,
2404                    x2: 0.64,
2405                    y2: -0.34,
2406                    x: 0.80,
2407                    y: 0.0,
2408                },
2409            ],
2410            fill: false,
2411        },
2412        color: options.color,
2413    };
2414
2415    LayoutBox {
2416        width,
2417        height,
2418        depth,
2419        content: BoxContent::ProofTree {
2420            children: vec![PlacedBox {
2421                box_: arc,
2422                x: 0.0,
2423                baseline_y: 0.36,
2424            }],
2425            rules: vec![
2426                ProofRule {
2427                    x: 0.28,
2428                    y: 0.48,
2429                    width: 0.86,
2430                    thickness: rule_thickness,
2431                    dashed: false,
2432                },
2433                ProofRule {
2434                    x: 0.28,
2435                    y: 0.68,
2436                    width: 0.86,
2437                    thickness: rule_thickness,
2438                    dashed: false,
2439                },
2440            ],
2441        },
2442        color: options.color,
2443    }
2444}
2445
2446fn make_measured_by_symbol(options: &LayoutOptions) -> LayoutBox {
2447    let width = 0.98;
2448    let height = 0.56;
2449    let depth = 0.30;
2450    let rule_thickness = 0.055;
2451    let m_metrics = get_char_metrics(FontId::MainRegular, 'm' as u32);
2452    let (m_width, m_height, m_depth) = m_metrics
2453        .map(|m| (m.width, m.height, m.depth))
2454        .unwrap_or((0.78, 0.43, 0.0));
2455    let m_scale = 0.61;
2456    let m_glyph = LayoutBox {
2457        width: m_width,
2458        height: m_height,
2459        depth: m_depth,
2460        content: BoxContent::Glyph {
2461            font_id: FontId::MainRegular,
2462            char_code: 'm' as u32,
2463        },
2464        color: options.color,
2465    };
2466    let m_box = LayoutBox {
2467        width: m_width * m_scale,
2468        height: m_height * m_scale,
2469        depth: m_depth * m_scale,
2470        content: BoxContent::Scaled {
2471            body: Box::new(m_glyph),
2472            child_scale: m_scale,
2473        },
2474        color: options.color,
2475    };
2476
2477    LayoutBox {
2478        width,
2479        height,
2480        depth,
2481        content: BoxContent::ProofTree {
2482            children: vec![PlacedBox {
2483                box_: m_box,
2484                x: (width - m_width * m_scale) / 2.0,
2485                baseline_y: 0.02 + m_height * m_scale,
2486            }],
2487            rules: vec![
2488                ProofRule {
2489                    x: 0.06,
2490                    y: 0.55,
2491                    width: 0.86,
2492                    thickness: rule_thickness,
2493                    dashed: false,
2494                },
2495                ProofRule {
2496                    x: 0.06,
2497                    y: 0.78,
2498                    width: 0.86,
2499                    thickness: rule_thickness,
2500                    dashed: false,
2501                },
2502            ],
2503        },
2504        color: options.color,
2505    }
2506}
2507
2508/// Build a vertical-bar delimiter LayoutBox using the same SVG as KaTeX `tallDelim` (`vert` / `doublevert`).
2509/// `total_height` is the requested full span in em (`sizeToMaxHeight` for `\big`/`\Big`/…).
2510fn make_vert_delim_box(
2511    total_height: f64,
2512    is_double: bool,
2513    is_sized_delim: bool,
2514    options: &LayoutOptions,
2515) -> LayoutBox {
2516    let real_h = katex_vert_real_height(total_height, is_double, is_sized_delim);
2517    let axis = options.metrics().axis_height;
2518    let depth = (real_h / 2.0 - axis).max(0.0);
2519    let height = real_h - depth;
2520    let width = if is_double { 0.556 } else { 0.333 };
2521
2522    let piece = vert_repeat_piece_height(is_double);
2523    let mid_em = (real_h - 2.0 * piece).max(0.0);
2524    let mid_th = (mid_em * 1000.0).round() as i64;
2525    let view_box_height = (real_h * 1000.0).round() as i64;
2526
2527    let d = tall_vert_svg_path_data(mid_th, is_double);
2528    let raw = parse_svg_path_data(&d);
2529    let scaled = scale_svg_path_to_em(&raw);
2530    let commands = map_vert_path_y_to_baseline(scaled, height, depth, view_box_height);
2531
2532    LayoutBox {
2533        width,
2534        height,
2535        depth,
2536        content: BoxContent::SvgPath {
2537            commands,
2538            fill: true,
2539        },
2540        color: options.color,
2541    }
2542}
2543
2544/// Select a delimiter glyph large enough for the given total height.
2545fn make_stretchy_delim(delim: &str, total_height: f64, options: &LayoutOptions) -> LayoutBox {
2546    if delim == "." || delim.is_empty() {
2547        return LayoutBox::new_kern(0.0);
2548    }
2549
2550    // stackAlwaysDelimiters: use SVG path only when the required height exceeds
2551    // the natural font-glyph height (1.0em for single vert, same for double).
2552    // When the content is small enough, fall through to the normal font glyph.
2553    const VERT_NATURAL_HEIGHT: f64 = 1.0; // MainRegular |: 0.75+0.25
2554    if is_vert_delim(delim) && total_height > VERT_NATURAL_HEIGHT {
2555        return make_vert_delim_box(total_height, false, false, options);
2556    }
2557    if is_double_vert_delim(delim) && total_height > VERT_NATURAL_HEIGHT {
2558        return make_vert_delim_box(total_height, true, false, options);
2559    }
2560
2561    // Normalize < > to \langle \rangle for proper angle bracket glyphs
2562    let delim = normalize_delim(delim);
2563
2564    let ch = resolve_symbol_char(delim, Mode::Math);
2565    let char_code = ch as u32;
2566
2567    let mut best_font = FontId::MainRegular;
2568    let mut best_w = 0.4;
2569    let mut best_h = 0.7;
2570    let mut best_d = 0.2;
2571
2572    for &font_id in &DELIM_FONT_SEQUENCE {
2573        if let Some(m) = get_char_metrics(font_id, char_code) {
2574            best_font = font_id;
2575            best_w = m.width;
2576            best_h = m.height;
2577            best_d = m.depth;
2578            if best_h + best_d >= total_height {
2579                break;
2580            }
2581        }
2582    }
2583
2584    let best_total = best_h + best_d;
2585    if let Some(stacked) = make_stacked_delim_if_needed(delim, total_height, best_total, options) {
2586        return stacked;
2587    }
2588
2589    LayoutBox {
2590        width: best_w,
2591        height: best_h,
2592        depth: best_d,
2593        content: BoxContent::Glyph {
2594            font_id: best_font,
2595            char_code,
2596        },
2597        color: options.color,
2598    }
2599}
2600
2601/// Fixed total heights for \big/\Big/\bigg/\Bigg (sizeToMaxHeight from KaTeX).
2602const SIZE_TO_MAX_HEIGHT: [f64; 5] = [0.0, 1.2, 1.8, 2.4, 3.0];
2603
2604/// Layout \big, \Big, \bigg, \Bigg delimiters.
2605fn layout_delim_sizing(size: u8, delim: &str, options: &LayoutOptions) -> LayoutBox {
2606    if delim == "." || delim.is_empty() {
2607        return LayoutBox::new_kern(0.0);
2608    }
2609
2610    // stackAlwaysDelimiters: render as SVG path at the fixed size height
2611    if is_vert_delim(delim) {
2612        let total = SIZE_TO_MAX_HEIGHT[size.min(4) as usize];
2613        return make_vert_delim_box(total, false, true, options);
2614    }
2615    if is_double_vert_delim(delim) {
2616        let total = SIZE_TO_MAX_HEIGHT[size.min(4) as usize];
2617        return make_vert_delim_box(total, true, true, options);
2618    }
2619
2620    // Normalize angle brackets to proper math angle bracket glyphs
2621    let delim = normalize_delim(delim);
2622
2623    let ch = resolve_symbol_char(delim, Mode::Math);
2624    let char_code = ch as u32;
2625
2626    let font_id = match size {
2627        1 => FontId::Size1Regular,
2628        2 => FontId::Size2Regular,
2629        3 => FontId::Size3Regular,
2630        4 => FontId::Size4Regular,
2631        _ => FontId::Size1Regular,
2632    };
2633
2634    let metrics = get_char_metrics(font_id, char_code);
2635    let (width, height, depth, actual_font) = match metrics {
2636        Some(m) => (m.width, m.height, m.depth, font_id),
2637        None => {
2638            let m = get_char_metrics(FontId::MainRegular, char_code);
2639            match m {
2640                Some(m) => (m.width, m.height, m.depth, FontId::MainRegular),
2641                None => (0.4, 0.7, 0.2, FontId::MainRegular),
2642            }
2643        }
2644    };
2645
2646    LayoutBox {
2647        width,
2648        height,
2649        depth,
2650        content: BoxContent::Glyph {
2651            font_id: actual_font,
2652            char_code,
2653        },
2654        color: options.color,
2655    }
2656}
2657
2658// ============================================================================
2659// Array / Matrix layout
2660// ============================================================================
2661
2662#[allow(clippy::too_many_arguments)]
2663fn layout_array(
2664    body: &[Vec<ParseNode>],
2665    cols: Option<&[ratex_parser::parse_node::AlignSpec]>,
2666    arraystretch: f64,
2667    add_jot: bool,
2668    row_gaps: &[Option<ratex_parser::parse_node::Measurement>],
2669    hlines: &[Vec<bool>],
2670    col_sep_type: Option<&str>,
2671    hskip: bool,
2672    tags: Option<&[ArrayTag]>,
2673    _leqno: bool,
2674    options: &LayoutOptions,
2675) -> LayoutBox {
2676    let metrics = options.metrics();
2677    let pt = 1.0 / metrics.pt_per_em;
2678    let baselineskip = 12.0 * pt;
2679    let jot = 3.0 * pt;
2680    let arrayskip = arraystretch * baselineskip;
2681    let arstrut_h = 0.7 * arrayskip;
2682    let arstrut_d = 0.3 * arrayskip;
2683    // align/aligned/alignedat: use thin space (3mu) so "x" and "=" are closer,
2684    // and cap relation spacing in cells to 3mu so spacing before/after "=" is equal.
2685    const ALIGN_RELATION_MU: f64 = 3.0;
2686    let col_gap = match col_sep_type {
2687        Some("align") => mu_to_em(ALIGN_RELATION_MU, metrics.quad),
2688        Some("alignat") => 0.0,
2689        Some("small") => {
2690            // smallmatrix: 2 × thickspace × (script_multiplier / current_multiplier)
2691            // KaTeX: arraycolsep = 0.2778em × (scriptMultiplier / sizeMultiplier)
2692            2.0 * mu_to_em(5.0, metrics.quad) * MathStyle::Script.size_multiplier()
2693                / options.size_multiplier()
2694        }
2695        _ => 2.0 * 5.0 * pt, // 2 × arraycolsep
2696    };
2697    let cell_options = match col_sep_type {
2698        Some("align") | Some("alignat") => LayoutOptions {
2699            align_relation_spacing: Some(ALIGN_RELATION_MU),
2700            ..options.clone()
2701        },
2702        _ => options.clone(),
2703    };
2704
2705    let num_rows = body.len();
2706    if num_rows == 0 {
2707        return LayoutBox::new_empty();
2708    }
2709
2710    let num_cols = body.iter().map(|r| r.len()).max().unwrap_or(0);
2711
2712    // Extract per-column alignment and column separators from cols spec.
2713    use ratex_parser::parse_node::AlignType;
2714    let col_aligns: Vec<u8> = {
2715        let align_specs: Vec<&ratex_parser::parse_node::AlignSpec> = cols
2716            .map(|cs| {
2717                cs.iter()
2718                    .filter(|s| matches!(s.align_type, AlignType::Align))
2719                    .collect()
2720            })
2721            .unwrap_or_default();
2722        (0..num_cols)
2723            .map(|c| {
2724                align_specs
2725                    .get(c)
2726                    .and_then(|s| s.align.as_deref())
2727                    .and_then(|a| a.bytes().next())
2728                    .unwrap_or(b'c')
2729            })
2730            .collect()
2731    };
2732
2733    // Detect vertical separator positions in the column spec.
2734    // col_separators[i]: None = no rule, Some(false) = solid '|', Some(true) = dashed ':'.
2735    let col_separators: Vec<Option<bool>> = {
2736        let mut seps = vec![None; num_cols + 1];
2737        let mut align_count = 0usize;
2738        if let Some(cs) = cols {
2739            for spec in cs {
2740                match spec.align_type {
2741                    AlignType::Align => align_count += 1,
2742                    AlignType::Separator
2743                        if spec.align.as_deref() == Some("|") && align_count <= num_cols =>
2744                    {
2745                        seps[align_count] = Some(false);
2746                    }
2747                    AlignType::Separator
2748                        if spec.align.as_deref() == Some(":") && align_count <= num_cols =>
2749                    {
2750                        seps[align_count] = Some(true);
2751                    }
2752                    _ => {}
2753                }
2754            }
2755        }
2756        seps
2757    };
2758
2759    let rule_thickness = 0.4 * pt;
2760    let double_rule_sep = metrics.double_rule_sep;
2761
2762    // Layout all cells
2763    let mut cell_boxes: Vec<Vec<LayoutBox>> = Vec::with_capacity(num_rows);
2764    let mut col_widths = vec![0.0_f64; num_cols];
2765    let mut row_heights = Vec::with_capacity(num_rows);
2766    let mut row_depths = Vec::with_capacity(num_rows);
2767
2768    for row in body {
2769        let mut row_boxes = Vec::with_capacity(num_cols);
2770        let mut rh = arstrut_h;
2771        let mut rd = arstrut_d;
2772
2773        for (c, cell) in row.iter().enumerate() {
2774            let cell_nodes = match cell {
2775                ParseNode::OrdGroup { body, .. } => body.as_slice(),
2776                other => std::slice::from_ref(other),
2777            };
2778            let cell_box = layout_expression(cell_nodes, &cell_options, true);
2779            rh = rh.max(cell_box.height);
2780            rd = rd.max(cell_box.depth);
2781            if c < num_cols {
2782                col_widths[c] = col_widths[c].max(cell_box.width);
2783            }
2784            row_boxes.push(cell_box);
2785        }
2786
2787        // Pad missing columns
2788        while row_boxes.len() < num_cols {
2789            row_boxes.push(LayoutBox::new_empty());
2790        }
2791
2792        if add_jot {
2793            rd += jot;
2794        }
2795
2796        row_heights.push(rh);
2797        row_depths.push(rd);
2798        cell_boxes.push(row_boxes);
2799    }
2800
2801    // Apply row gaps
2802    for (r, gap) in row_gaps.iter().enumerate() {
2803        if r < row_depths.len() {
2804            if let Some(m) = gap {
2805                let gap_em = measurement_to_em(m, options);
2806                if gap_em > 0.0 {
2807                    row_depths[r] = row_depths[r].max(gap_em + arstrut_d);
2808                }
2809            }
2810        }
2811    }
2812
2813    // Ensure hlines_before_row has num_rows + 1 entries.
2814    let mut hlines_before_row: Vec<Vec<bool>> = hlines.to_vec();
2815    while hlines_before_row.len() < num_rows + 1 {
2816        hlines_before_row.push(vec![]);
2817    }
2818
2819    // For n > 1 consecutive hlines before row r, add extra vertical space so the
2820    // lines don't overlap with content.  Each extra line needs (rule_thickness +
2821    // double_rule_sep) of room.
2822    //   - r == 0: extra hlines appear above the first row → add to row_heights[0].
2823    //   - r >= 1: extra hlines appear in the gap above row r → add to row_depths[r-1].
2824    for r in 0..=num_rows {
2825        let n = hlines_before_row[r].len();
2826        if n > 1 {
2827            let extra = (n - 1) as f64 * (rule_thickness + double_rule_sep);
2828            if r == 0 {
2829                if num_rows > 0 {
2830                    row_heights[0] += extra;
2831                }
2832            } else {
2833                row_depths[r - 1] += extra;
2834            }
2835        }
2836    }
2837
2838    // Total height and offset (computed after extra hline spacing is applied).
2839    let mut total_height = 0.0;
2840    let mut row_positions = Vec::with_capacity(num_rows);
2841    for r in 0..num_rows {
2842        total_height += row_heights[r];
2843        row_positions.push(total_height);
2844        total_height += row_depths[r];
2845    }
2846
2847    let offset = total_height / 2.0 + metrics.axis_height;
2848
2849    // Extra x padding before col 0 and after last col (hskip_before_and_after).
2850    let content_x_offset = if hskip { col_gap / 2.0 } else { 0.0 };
2851
2852    // Width of the cell grid including horizontal padding (no tag column).
2853    let array_inner_width: f64 = col_widths.iter().sum::<f64>()
2854        + col_gap * (num_cols.saturating_sub(1)) as f64
2855        + 2.0 * content_x_offset;
2856
2857    let mut row_tag_boxes: Vec<Option<LayoutBox>> = (0..num_rows).map(|_| None).collect();
2858    let mut tag_col_width = 0.0_f64;
2859    let text_opts = options.with_style(options.style.text());
2860    if let Some(tag_slice) = tags {
2861        if tag_slice.len() == num_rows {
2862            for (r, t) in tag_slice.iter().enumerate() {
2863                if let ArrayTag::Explicit(nodes) = t {
2864                    if !nodes.is_empty() {
2865                        let tb = layout_expression(nodes, &text_opts, true);
2866                        tag_col_width = tag_col_width.max(tb.width);
2867                        row_tag_boxes[r] = Some(tb);
2868                    }
2869                }
2870            }
2871        }
2872    }
2873    let tag_gap_em = if tag_col_width > 0.0 {
2874        text_opts.metrics().quad
2875    } else {
2876        0.0
2877    };
2878    // leqno (tags on the left) is parsed but not yet laid out; keep tags on the right.
2879    let tags_left = false;
2880
2881    let total_width = array_inner_width + tag_gap_em + tag_col_width;
2882
2883    let height = offset;
2884    let depth = total_height - offset;
2885
2886    LayoutBox {
2887        width: total_width,
2888        height,
2889        depth,
2890        content: BoxContent::Array {
2891            cells: cell_boxes,
2892            col_widths: col_widths.clone(),
2893            col_aligns,
2894            row_heights: row_heights.clone(),
2895            row_depths: row_depths.clone(),
2896            col_gap,
2897            offset,
2898            content_x_offset,
2899            col_separators,
2900            hlines_before_row,
2901            rule_thickness,
2902            double_rule_sep,
2903            array_inner_width,
2904            tag_gap_em,
2905            tag_col_width,
2906            row_tags: row_tag_boxes,
2907            tags_left,
2908        },
2909        color: options.color,
2910    }
2911}
2912
2913// ============================================================================
2914// Sizing / Text / Font
2915// ============================================================================
2916
2917fn layout_sizing(size: u8, body: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
2918    // KaTeX sizing: size 1-11, maps to multipliers
2919    let multiplier = match size {
2920        1 => 0.5,
2921        2 => 0.6,
2922        3 => 0.7,
2923        4 => 0.8,
2924        5 => 0.9,
2925        6 => 1.0,
2926        7 => 1.2,
2927        8 => 1.44,
2928        9 => 1.728,
2929        10 => 2.074,
2930        11 => 2.488,
2931        _ => 1.0,
2932    };
2933
2934    // KaTeX `Options.havingSize`: inner is built in `this.style.text()` (≥ textstyle).
2935    let inner_opts = options.with_style(options.style.text());
2936    let inner = layout_expression(body, &inner_opts, true);
2937    let ratio = multiplier / options.size_multiplier();
2938    if (ratio - 1.0).abs() < 0.001 {
2939        inner
2940    } else {
2941        LayoutBox {
2942            width: inner.width * ratio,
2943            height: inner.height * ratio,
2944            depth: inner.depth * ratio,
2945            content: BoxContent::Scaled {
2946                body: Box::new(inner),
2947                child_scale: ratio,
2948            },
2949            color: options.color,
2950        }
2951    }
2952}
2953
2954#[derive(Default)]
2955struct HtmlStyle {
2956    color: Option<Color>,
2957    font_size_scale: Option<f64>,
2958    bold: bool,
2959    italic: bool,
2960    background_color: Option<Color>,
2961    underline: bool,
2962}
2963
2964fn layout_html(
2965    attributes: &HashMap<String, String>,
2966    body: &[ParseNode],
2967    options: &LayoutOptions,
2968) -> LayoutBox {
2969    let style = attributes
2970        .get("style")
2971        .map(|style| parse_html_style(style))
2972        .unwrap_or_default();
2973
2974    let body_options = match style.color {
2975        Some(color) => options.with_color(color),
2976        None => options.clone(),
2977    };
2978    let font_id = match (style.bold, style.italic) {
2979        (true, true) => Some(FontId::MainBoldItalic),
2980        (true, false) => Some(FontId::MainBold),
2981        (false, true) => Some(FontId::MainItalic),
2982        (false, false) => None,
2983    };
2984
2985    let body_node = ParseNode::OrdGroup {
2986        mode: body.first().map(ParseNode::mode).unwrap_or(Mode::Math),
2987        body: body.to_vec(),
2988        semisimple: None,
2989        loc: None,
2990    };
2991    let mut lbox = match font_id {
2992        Some(font_id) => layout_with_font(&body_node, font_id, &body_options),
2993        None => layout_expression(body, &body_options, true),
2994    };
2995
2996    if let Some(scale) = style.font_size_scale {
2997        if (scale - 1.0).abs() >= 0.001 {
2998            lbox = LayoutBox {
2999                width: lbox.width * scale,
3000                height: lbox.height * scale,
3001                depth: lbox.depth * scale,
3002                content: BoxContent::Scaled {
3003                    body: Box::new(lbox),
3004                    child_scale: scale,
3005                },
3006                color: body_options.color,
3007            };
3008        }
3009    }
3010
3011    if let Some(background_color) = style.background_color {
3012        lbox = LayoutBox {
3013            width: lbox.width,
3014            height: lbox.height,
3015            depth: lbox.depth,
3016            content: BoxContent::Framed {
3017                body: Box::new(lbox),
3018                padding: 0.0,
3019                border_thickness: 0.0,
3020                has_border: false,
3021                bg_color: Some(background_color),
3022                border_color: Color::BLACK,
3023            },
3024            color: body_options.color,
3025        };
3026    }
3027
3028    if style.underline {
3029        lbox = layout_underline_laid_out(lbox, options, body_options.color);
3030    }
3031
3032    if options.leftright_delim_height.is_none() && body_contains_middle(body, body_options.style) {
3033        let total = SIZE_TO_MAX_HEIGHT[1];
3034        let axis = body_options.metrics().axis_height;
3035        let height = total / 2.0 + axis;
3036        let depth = total - height;
3037        lbox.height = lbox.height.max(height);
3038        lbox.depth = lbox.depth.max(depth);
3039    }
3040
3041    lbox
3042}
3043
3044fn parse_html_style(style: &str) -> HtmlStyle {
3045    let mut parsed = HtmlStyle::default();
3046    for declaration in style.split(';') {
3047        let Some((property, value)) = declaration.split_once(':') else {
3048            continue;
3049        };
3050        let property = property.trim().to_ascii_lowercase();
3051        let value = value.trim();
3052        match property.as_str() {
3053            "color" => parsed.color = Color::parse(value),
3054            "font-size" => parsed.font_size_scale = parse_css_font_size(value),
3055            "font-weight" => parsed.bold = is_css_bold(value),
3056            "font-style" => parsed.italic = is_css_italic(value),
3057            "background" | "background-color" => parsed.background_color = Color::parse(value),
3058            "text-decoration" | "text-decoration-line" => {
3059                parsed.underline = value
3060                    .split_whitespace()
3061                    .any(|part| part.eq_ignore_ascii_case("underline"));
3062            }
3063            _ => {}
3064        }
3065    }
3066    parsed
3067}
3068
3069fn parse_css_font_size(value: &str) -> Option<f64> {
3070    let value = value.trim().to_ascii_lowercase().replace(' ', "");
3071    let parse_number = |s: &str| s.parse::<f64>().ok().filter(|n| n.is_finite() && *n > 0.0);
3072    if let Some(px) = value.strip_suffix("px") {
3073        parse_number(px).map(|n| n / 16.0)
3074    } else if let Some(em) = value
3075        .strip_suffix("em")
3076        .or_else(|| value.strip_suffix("rem"))
3077    {
3078        parse_number(em)
3079    } else if let Some(percent) = value.strip_suffix('%') {
3080        parse_number(percent).map(|n| n / 100.0)
3081    } else {
3082        None
3083    }
3084}
3085
3086fn is_css_bold(value: &str) -> bool {
3087    let value = value.trim();
3088    value.eq_ignore_ascii_case("bold")
3089        || value.eq_ignore_ascii_case("bolder")
3090        || value.parse::<u16>().is_ok_and(|weight| weight >= 600)
3091}
3092
3093fn is_css_italic(value: &str) -> bool {
3094    let value = value.trim();
3095    value.eq_ignore_ascii_case("italic") || value.eq_ignore_ascii_case("oblique")
3096}
3097
3098/// Layout \verb and \verb* — verbatim text in typewriter font.
3099/// \verb* shows spaces as a visible character (U+2423 OPEN BOX).
3100fn layout_verb(body: &str, star: bool, options: &LayoutOptions) -> LayoutBox {
3101    let metrics = options.metrics();
3102    let mut children = Vec::new();
3103    for c in body.chars() {
3104        let ch = if star && c == ' ' {
3105            '\u{2423}' // OPEN BOX, visible space
3106        } else {
3107            c
3108        };
3109        let code = ch as u32;
3110        let (font_id, w, h, d) = match get_char_metrics(FontId::TypewriterRegular, code) {
3111            Some(m) => (FontId::TypewriterRegular, m.width, m.height, m.depth),
3112            None => match get_char_metrics(FontId::MainRegular, code) {
3113                Some(m) => (FontId::MainRegular, m.width, m.height, m.depth),
3114                None => (FontId::TypewriterRegular, 0.5, metrics.x_height, 0.0),
3115            },
3116        };
3117        children.push(LayoutBox {
3118            width: w,
3119            height: h,
3120            depth: d,
3121            content: BoxContent::Glyph {
3122                font_id,
3123                char_code: code,
3124            },
3125            color: options.color,
3126        });
3127    }
3128    let mut hbox = make_hbox(children);
3129    hbox.color = options.color;
3130    hbox
3131}
3132
3133/// Lay out `\text{…}` / `HBox` contents as a simple horizontal row.
3134///
3135/// KaTeX's HTML builder may merge consecutive text symbols into **one** DOM text run; the
3136/// browser then applies OpenType kerning (GPOS) on that run. We place each character using
3137/// bundled TeX metrics only (no GPOS), so compared to Puppeteer+KaTeX PNGs, long `\text{…}`
3138/// strings can appear slightly wider with a small cumulative horizontal shift — not a wrong
3139/// font file, but a shaping model difference.
3140fn layout_text(body: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
3141    let mut children = Vec::new();
3142    for node in body {
3143        match node {
3144            ParseNode::TextOrd { text, mode, .. } | ParseNode::MathOrd { text, mode, .. } => {
3145                children.push(layout_symbol(text, *mode, options));
3146            }
3147            ParseNode::SpacingNode { text, .. } => {
3148                children.push(layout_spacing_command(text, options));
3149            }
3150            _ => {
3151                children.push(layout_node(node, options));
3152            }
3153        }
3154    }
3155    make_hbox(children)
3156}
3157
3158/// Layout \pmb — poor man's bold via CSS-style text shadow.
3159/// Renders the body twice: once normally, once offset by (0.02em, 0.01em).
3160fn layout_pmb(body: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
3161    let base = layout_expression(body, options, true);
3162    let w = base.width;
3163    let h = base.height;
3164    let d = base.depth;
3165
3166    // Shadow copy shifted right 0.02em, down 0.01em — same content, same color
3167    let shadow = layout_expression(body, options, true);
3168    let shadow_shift_x = 0.02_f64;
3169    let _shadow_shift_y = 0.01_f64;
3170
3171    // Combine: place shadow first (behind), then base on top
3172    // Shadow is placed at an HBox offset — we use a VBox/kern trick:
3173    // Instead, represent as HBox where shadow overlaps base via negative kern
3174    let kern_back = LayoutBox::new_kern(-w);
3175    let kern_x = LayoutBox::new_kern(shadow_shift_x);
3176
3177    // We create: [shadow | kern(-w) | base] in an HBox
3178    // But shadow needs to be shifted down by shadow_shift_y.
3179    // Use a raised box trick: wrap shadow in a VBox with a small kern.
3180    // Simplest approximation: just render body once (the shadow is < 1px at normal size)
3181    // but with a tiny kern to hint at bold width.
3182    // Better: use a simple 2-layer HBox with overlap.
3183    let children = vec![kern_x, shadow, kern_back, base];
3184    // Width should be original base width, not doubled
3185    let hbox = make_hbox(children);
3186    // Return a box with original dimensions (shadow overflow is clipped)
3187    LayoutBox {
3188        width: w,
3189        height: h,
3190        depth: d,
3191        content: hbox.content,
3192        color: options.color,
3193    }
3194}
3195
3196/// Layout \fbox, \colorbox, \fcolorbox — framed/colored box.
3197/// Also handles \phase, \cancel, \sout, \bcancel, \xcancel.
3198fn layout_enclose(
3199    label: &str,
3200    background_color: Option<&str>,
3201    border_color: Option<&str>,
3202    body: &ParseNode,
3203    options: &LayoutOptions,
3204) -> LayoutBox {
3205    use crate::layout_box::BoxContent;
3206    use ratex_types::color::Color;
3207
3208    // \phase: angle mark (diagonal line) below the body with underline
3209    if label == "\\phase" {
3210        return layout_phase(body, options);
3211    }
3212
3213    // \angl: actuarial angle — arc/roof above the body (KaTeX actuarialangle-style)
3214    if label == "\\angl" {
3215        return layout_angl(body, options);
3216    }
3217
3218    // \cancel, \bcancel, \xcancel, \sout: strike-through overlays
3219    if matches!(label, "\\cancel" | "\\bcancel" | "\\xcancel" | "\\sout") {
3220        return layout_cancel(label, body, options);
3221    }
3222
3223    // KaTeX defaults: fboxpad = 3pt, fboxrule = 0.4pt
3224    let metrics = options.metrics();
3225    let padding = 3.0 / metrics.pt_per_em;
3226    let border_thickness = 0.4 / metrics.pt_per_em;
3227
3228    let has_border = matches!(label, "\\fbox" | "\\fcolorbox");
3229
3230    let bg = background_color.and_then(|c| Color::from_name(c).or_else(|| Color::from_hex(c)));
3231    let border = border_color
3232        .and_then(|c| Color::from_name(c).or_else(|| Color::from_hex(c)))
3233        .unwrap_or(Color::BLACK);
3234
3235    let inner = layout_node(body, options);
3236    let outer_pad = padding + if has_border { border_thickness } else { 0.0 };
3237
3238    let width = inner.width + 2.0 * outer_pad;
3239    let height = inner.height + outer_pad;
3240    let depth = inner.depth + outer_pad;
3241
3242    LayoutBox {
3243        width,
3244        height,
3245        depth,
3246        content: BoxContent::Framed {
3247            body: Box::new(inner),
3248            padding,
3249            border_thickness,
3250            has_border,
3251            bg_color: bg,
3252            border_color: border,
3253        },
3254        color: options.color,
3255    }
3256}
3257
3258/// Layout \raisebox{dy}{body} — shift content vertically.
3259fn layout_raisebox(shift: f64, body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
3260    use crate::layout_box::BoxContent;
3261    let inner = layout_node(body, options);
3262    // Positive shift moves content up → height increases, depth decreases
3263    let height = inner.height + shift;
3264    let depth = (inner.depth - shift).max(0.0);
3265    let width = inner.width;
3266    LayoutBox {
3267        width,
3268        height,
3269        depth,
3270        content: BoxContent::RaiseBox {
3271            body: Box::new(inner),
3272            shift,
3273        },
3274        color: options.color,
3275    }
3276}
3277
3278/// Returns true if the parse node is a single character box (atom / mathord / textord),
3279/// mirroring KaTeX's `isCharacterBox` + `getBaseElem` logic.
3280fn is_single_char_body(node: &ParseNode) -> bool {
3281    use ratex_parser::parse_node::ParseNode as PN;
3282    match node {
3283        // Unwrap single-element ord-groups and styling nodes.
3284        PN::OrdGroup { body, .. } if body.len() == 1 => is_single_char_body(&body[0]),
3285        PN::Styling { body, .. } if body.len() == 1 => is_single_char_body(&body[0]),
3286        // Bare character nodes.
3287        PN::Atom { .. } | PN::MathOrd { .. } | PN::TextOrd { .. } => true,
3288        _ => false,
3289    }
3290}
3291
3292/// Layout \cancel, \bcancel, \xcancel, \sout — body with strike-through line(s) overlay.
3293///
3294/// Matches KaTeX `enclose.ts` + `stretchy.ts` geometry:
3295///   • single char  → v_pad = 0.2em, h_pad = 0   (line corner-to-corner of w × (h+d+0.4) box)
3296///   • multi char   → v_pad = 0,     h_pad = 0.2em (cancel-pad: line extends 0.2em each side)
3297fn layout_cancel(label: &str, body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
3298    use crate::layout_box::BoxContent;
3299    let inner = layout_node(body, options);
3300    let w = inner.width.max(0.01);
3301    let h = inner.height;
3302    let d = inner.depth;
3303
3304    // \sout uses no padding — the line spans exactly the content width/height.
3305    // KaTeX cancel padding: single character gets vertical extension, multi-char gets horizontal.
3306    let single = is_single_char_body(body);
3307    let (v_pad, h_pad) = if label == "\\sout" {
3308        (0.0, 0.0)
3309    } else if single {
3310        (0.2, 0.0)
3311    } else {
3312        (0.0, 0.2)
3313    };
3314
3315    // Path coordinates: y=0 at baseline, y<0 above (height), y>0 below (depth).
3316    // \cancel  = "/" diagonal: bottom-left → top-right
3317    // \bcancel = "\" diagonal: top-left → bottom-right
3318    let commands: Vec<PathCommand> = match label {
3319        "\\cancel" => vec![
3320            PathCommand::MoveTo {
3321                x: -h_pad,
3322                y: d + v_pad,
3323            }, // bottom-left
3324            PathCommand::LineTo {
3325                x: w + h_pad,
3326                y: -h - v_pad,
3327            }, // top-right
3328        ],
3329        "\\bcancel" => vec![
3330            PathCommand::MoveTo {
3331                x: -h_pad,
3332                y: -h - v_pad,
3333            }, // top-left
3334            PathCommand::LineTo {
3335                x: w + h_pad,
3336                y: d + v_pad,
3337            }, // bottom-right
3338        ],
3339        "\\xcancel" => vec![
3340            PathCommand::MoveTo {
3341                x: -h_pad,
3342                y: d + v_pad,
3343            },
3344            PathCommand::LineTo {
3345                x: w + h_pad,
3346                y: -h - v_pad,
3347            },
3348            PathCommand::MoveTo {
3349                x: -h_pad,
3350                y: -h - v_pad,
3351            },
3352            PathCommand::LineTo {
3353                x: w + h_pad,
3354                y: d + v_pad,
3355            },
3356        ],
3357        "\\sout" => {
3358            // Horizontal line at –0.5× x-height, extended to content edges.
3359            let mid_y = -0.5 * options.metrics().x_height;
3360            vec![
3361                PathCommand::MoveTo { x: 0.0, y: mid_y },
3362                PathCommand::LineTo { x: w, y: mid_y },
3363            ]
3364        }
3365        _ => vec![],
3366    };
3367
3368    let line_w = w + 2.0 * h_pad;
3369    let line_h = h + v_pad;
3370    let line_d = d + v_pad;
3371    let line_box = LayoutBox {
3372        width: line_w,
3373        height: line_h,
3374        depth: line_d,
3375        content: BoxContent::SvgPath {
3376            commands,
3377            fill: false,
3378        },
3379        color: options.color,
3380    };
3381
3382    // For multi-char the body is inset by h_pad from the line-box's left edge.
3383    let body_kern = -(line_w - h_pad);
3384    let body_shifted = make_hbox(vec![LayoutBox::new_kern(body_kern), inner]);
3385    LayoutBox {
3386        width: w,
3387        height: h,
3388        depth: d,
3389        content: BoxContent::HBox(vec![line_box, body_shifted]),
3390        color: options.color,
3391    }
3392}
3393
3394/// Layout \phase{body} — angle notation: body with a diagonal angle mark + underline.
3395/// Matches KaTeX `enclose.ts` + `phasePath(y)` (steinmetz): dynamic viewBox height, `x = y/2` at the peak.
3396fn layout_phase(body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
3397    use crate::layout_box::BoxContent;
3398    let metrics = options.metrics();
3399    let inner = layout_node(body, options);
3400    // KaTeX: lineWeight = 0.6pt, clearance = 0.35ex; angleHeight = inner.h + inner.d + both
3401    let line_weight = 0.6_f64 / metrics.pt_per_em;
3402    let clearance = 0.35_f64 * metrics.x_height;
3403    let angle_height = inner.height + inner.depth + line_weight + clearance;
3404    let left_pad = angle_height / 2.0 + line_weight;
3405    let width = inner.width + left_pad;
3406
3407    // KaTeX: viewBoxHeight = floor(1000 * angleHeight * scale); base sizing uses scale → 1 here.
3408    let y_svg = (1000.0 * angle_height).floor().max(80.0);
3409
3410    // Vertical: viewBox height y_svg → angle_height em (baseline mapping below).
3411    let sy = angle_height / y_svg;
3412    // Horizontal: KaTeX SVG uses preserveAspectRatio xMinYMin slice — scale follows viewBox height,
3413    // so x grows ~sy per SVG unit (not width/400000). That keeps the left angle visible; clip to `width`.
3414    let sx = sy;
3415    let right_x = (400_000.0_f64 * sx).min(width);
3416
3417    // Baseline: peak at svg y=0 → -inner.height; bottom at y=y_svg → inner.depth + line_weight + clearance
3418    let bottom_y = inner.depth + line_weight + clearance;
3419    let vy = |y_sv: f64| -> f64 { bottom_y - (y_svg - y_sv) * sy };
3420
3421    // phasePath(y): M400000 y H0 L y/2 0 l65 45 L145 y-80 H400000z
3422    let x_peak = y_svg / 2.0;
3423    let commands = vec![
3424        PathCommand::MoveTo {
3425            x: right_x,
3426            y: vy(y_svg),
3427        },
3428        PathCommand::LineTo {
3429            x: 0.0,
3430            y: vy(y_svg),
3431        },
3432        PathCommand::LineTo {
3433            x: x_peak * sx,
3434            y: vy(0.0),
3435        },
3436        PathCommand::LineTo {
3437            x: (x_peak + 65.0) * sx,
3438            y: vy(45.0),
3439        },
3440        PathCommand::LineTo {
3441            x: 145.0 * sx,
3442            y: vy(y_svg - 80.0),
3443        },
3444        PathCommand::LineTo {
3445            x: right_x,
3446            y: vy(y_svg - 80.0),
3447        },
3448        PathCommand::Close,
3449    ];
3450
3451    let body_shifted = make_hbox(vec![LayoutBox::new_kern(left_pad), inner.clone()]);
3452
3453    let path_height = inner.height;
3454    let path_depth = bottom_y;
3455
3456    LayoutBox {
3457        width,
3458        height: path_height,
3459        depth: path_depth,
3460        content: BoxContent::HBox(vec![
3461            LayoutBox {
3462                width,
3463                height: path_height,
3464                depth: path_depth,
3465                content: BoxContent::SvgPath {
3466                    commands,
3467                    fill: true,
3468                },
3469                color: options.color,
3470            },
3471            LayoutBox::new_kern(-width),
3472            body_shifted,
3473        ]),
3474        color: options.color,
3475    }
3476}
3477
3478/// Layout \angl{body} — actuarial angle: horizontal roof line above body + vertical bar on the right (KaTeX/fixture style).
3479/// Path and body share the same baseline; vertical bar runs from roof down through baseline to bottom of body.
3480fn layout_angl(body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
3481    use crate::layout_box::BoxContent;
3482    let inner = layout_node(body, options);
3483    let w = inner.width.max(0.3);
3484    // Roof line a bit higher: body_height + clearance
3485    let clearance = 0.1_f64;
3486    let arc_h = inner.height + clearance;
3487
3488    // Path: horizontal roof (0,-arc_h) to (w,-arc_h), then vertical (w,-arc_h) down to (w, depth) so bar extends below baseline
3489    let path_commands = vec![
3490        PathCommand::MoveTo { x: 0.0, y: -arc_h },
3491        PathCommand::LineTo { x: w, y: -arc_h },
3492        PathCommand::LineTo {
3493            x: w,
3494            y: inner.depth + 0.3_f64,
3495        },
3496    ];
3497
3498    let height = arc_h;
3499    LayoutBox {
3500        width: w,
3501        height,
3502        depth: inner.depth,
3503        content: BoxContent::Angl {
3504            path_commands,
3505            body: Box::new(inner),
3506        },
3507        color: options.color,
3508    }
3509}
3510
3511fn layout_font(font: &str, body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
3512    let font_id = match font {
3513        "mathrm" | "\\mathrm" | "textrm" | "\\textrm" | "rm" | "\\rm" => Some(FontId::MainRegular),
3514        "mathbf" | "\\mathbf" | "textbf" | "\\textbf" | "bf" | "\\bf" => Some(FontId::MainBold),
3515        "mathit" | "\\mathit" | "it" | "\\it" => Some(FontId::MathItalic),
3516        "textit" | "\\textit" | "\\emph" => Some(FontId::MainItalic),
3517        "mathsf" | "\\mathsf" | "textsf" | "\\textsf" => Some(FontId::SansSerifRegular),
3518        "mathtt" | "\\mathtt" | "texttt" | "\\texttt" => Some(FontId::TypewriterRegular),
3519        "mathcal" | "\\mathcal" | "cal" | "\\cal" => Some(FontId::CaligraphicRegular),
3520        "mathfrak" | "\\mathfrak" | "frak" | "\\frak" => Some(FontId::FrakturRegular),
3521        "mathscr" | "\\mathscr" => Some(FontId::ScriptRegular),
3522        "mathbb" | "\\mathbb" => Some(FontId::AmsRegular),
3523        "boldsymbol" | "\\boldsymbol" | "bm" | "\\bm" => Some(FontId::MathBoldItalic),
3524        _ => None,
3525    };
3526
3527    if let Some(fid) = font_id {
3528        layout_with_font(body, fid, options)
3529    } else {
3530        layout_node(body, options)
3531    }
3532}
3533
3534fn layout_with_font(node: &ParseNode, font_id: FontId, options: &LayoutOptions) -> LayoutBox {
3535    match node {
3536        ParseNode::OrdGroup { body, .. } => {
3537            let kern = options.inter_glyph_kern_em;
3538            let mut children: Vec<LayoutBox> = Vec::with_capacity(body.len().saturating_mul(2));
3539            for (i, n) in body.iter().enumerate() {
3540                if i > 0 && kern > 0.0 {
3541                    children.push(LayoutBox::new_kern(kern));
3542                }
3543                children.push(layout_with_font(n, font_id, options));
3544            }
3545            make_hbox(children)
3546        }
3547        ParseNode::SupSub { base, sup, sub, .. } => {
3548            if let Some(base_node) = base.as_deref() {
3549                if should_use_op_limits(base_node, options) {
3550                    return layout_op_with_limits(
3551                        base_node,
3552                        sup.as_deref(),
3553                        sub.as_deref(),
3554                        options,
3555                    );
3556                }
3557            }
3558            layout_supsub(
3559                base.as_deref(),
3560                sup.as_deref(),
3561                sub.as_deref(),
3562                options,
3563                Some(font_id),
3564            )
3565        }
3566        ParseNode::MathOrd { text, mode, .. }
3567        | ParseNode::TextOrd { text, mode, .. }
3568        | ParseNode::Atom { text, mode, .. } => {
3569            let ch = resolve_symbol_char(text, *mode);
3570            let char_code = ch as u32;
3571            let metric_cp = ratex_font::font_and_metric_for_mathematical_alphanumeric(char_code)
3572                .map(|(_, m)| m)
3573                .unwrap_or(char_code);
3574            if let Some(m) = get_char_metrics(font_id, metric_cp) {
3575                LayoutBox {
3576                    // Text mode: no italic correction (it's a typographic hint for math sub/sup).
3577                    width: math_glyph_advance_em(&m, *mode),
3578                    height: m.height,
3579                    depth: m.depth,
3580                    content: BoxContent::Glyph { font_id, char_code },
3581                    color: options.color,
3582                }
3583            } else {
3584                // Glyph not in requested font — fall back to default math rendering
3585                layout_node(node, options)
3586            }
3587        }
3588        _ => layout_node(node, options),
3589    }
3590}
3591
3592// ============================================================================
3593// Overline / Underline
3594// ============================================================================
3595
3596fn layout_overline(body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
3597    let cramped = options.with_style(options.style.cramped());
3598    let body_box = layout_node(body, &cramped);
3599    let metrics = options.metrics();
3600    let rule = metrics.default_rule_thickness;
3601
3602    // Total height: body height + 2*rule clearance + rule thickness = body.height + 3*rule
3603    let height = body_box.height + 3.0 * rule;
3604    LayoutBox {
3605        width: body_box.width,
3606        height,
3607        depth: body_box.depth,
3608        content: BoxContent::Overline {
3609            body: Box::new(body_box),
3610            rule_thickness: rule,
3611        },
3612        color: options.color,
3613    }
3614}
3615
3616fn layout_underline(body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
3617    let body_box = layout_node(body, options);
3618    let metrics = options.metrics();
3619    let rule = metrics.default_rule_thickness;
3620    let offset = 4.0 * rule;
3621
3622    let depth = body_box.depth + offset + 0.5 * rule;
3623    LayoutBox {
3624        width: body_box.width,
3625        height: body_box.height,
3626        depth,
3627        content: BoxContent::Underline {
3628            body: Box::new(body_box),
3629            rule_thickness: rule,
3630            offset,
3631        },
3632        color: options.color,
3633    }
3634}
3635
3636/// `\href` / `\url`: link color on the glyphs and an underline in the same color (KaTeX-style).
3637fn layout_href(body: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
3638    let link_color = Color::from_name("blue").unwrap_or_else(|| Color::rgb(0.0, 0.0, 1.0));
3639    // Slight tracking matches KaTeX/browser monospace link width in golden PNGs.
3640    let body_opts = options.with_color(link_color).with_inter_glyph_kern(0.05);
3641    let body_box = layout_expression(body, &body_opts, true);
3642    if box_contains_font(&body_box, FontId::TypewriterRegular) {
3643        layout_link_underline_laid_out(body_box, options, link_color)
3644    } else {
3645        layout_underline_laid_out(body_box, options, link_color)
3646    }
3647}
3648
3649/// Same geometry as [`layout_underline`], but for an already computed inner box.
3650fn layout_underline_laid_out(
3651    body_box: LayoutBox,
3652    options: &LayoutOptions,
3653    color: Color,
3654) -> LayoutBox {
3655    let metrics = options.metrics();
3656    let rule = metrics.default_rule_thickness;
3657    layout_underline_laid_out_with_rule(body_box, rule, 2.5 * rule, color)
3658}
3659
3660fn layout_underline_laid_out_with_rule(
3661    body_box: LayoutBox,
3662    rule: f64,
3663    offset: f64,
3664    color: Color,
3665) -> LayoutBox {
3666    let depth = body_box.depth + offset + 0.5 * rule;
3667    LayoutBox {
3668        width: body_box.width,
3669        height: body_box.height,
3670        depth,
3671        content: BoxContent::Underline {
3672            body: Box::new(body_box),
3673            rule_thickness: rule,
3674            offset,
3675        },
3676        color,
3677    }
3678}
3679
3680fn box_contains_font(lbox: &LayoutBox, font_id: FontId) -> bool {
3681    match &lbox.content {
3682        BoxContent::Glyph { font_id: fid, .. } => *fid == font_id,
3683        BoxContent::HBox(children) => children
3684            .iter()
3685            .any(|child| box_contains_font(child, font_id)),
3686        BoxContent::Scaled { body, .. } | BoxContent::RaiseBox { body, .. } => {
3687            box_contains_font(body, font_id)
3688        }
3689        _ => false,
3690    }
3691}
3692
3693fn link_underline_skip_cuts(lbox: &LayoutBox, x: f64, scale: f64, cuts: &mut Vec<(f64, f64)>) {
3694    match &lbox.content {
3695        BoxContent::HBox(children) => {
3696            let mut cur_x = x;
3697            for child in children {
3698                link_underline_skip_cuts(child, cur_x, scale, cuts);
3699                cur_x += child.width * scale;
3700            }
3701        }
3702        BoxContent::Glyph { char_code, .. } => {
3703            if let Some(ch) = char::from_u32(*char_code) {
3704                if matches!(ch, 'g' | 'j' | 'p' | 'q' | 'y') {
3705                    let pad = (0.04 * scale).min(lbox.width * scale / 3.0);
3706                    cuts.push((x + pad, x + lbox.width * scale - pad));
3707                }
3708            }
3709        }
3710        BoxContent::Scaled { body, child_scale } => {
3711            link_underline_skip_cuts(body, x, scale * child_scale, cuts);
3712        }
3713        BoxContent::RaiseBox { body, .. } => {
3714            link_underline_skip_cuts(body, x, scale, cuts);
3715        }
3716        _ => {}
3717    }
3718}
3719
3720fn link_underline_segments(body_box: &LayoutBox) -> Vec<(f64, f64)> {
3721    let mut cuts = Vec::new();
3722    link_underline_skip_cuts(body_box, 0.0, 1.0, &mut cuts);
3723    if cuts.is_empty() {
3724        return vec![(0.0, body_box.width)];
3725    }
3726    cuts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
3727
3728    let mut segments = Vec::new();
3729    let mut x = 0.0;
3730    for (cut_l, cut_r) in cuts {
3731        let l = cut_l.clamp(0.0, body_box.width);
3732        let r = cut_r.clamp(0.0, body_box.width);
3733        if l > x + 0.02 {
3734            segments.push((x, l - x));
3735        }
3736        x = x.max(r);
3737    }
3738    if body_box.width > x + 0.02 {
3739        segments.push((x, body_box.width - x));
3740    }
3741    if segments.is_empty() {
3742        vec![(0.0, body_box.width)]
3743    } else {
3744        segments
3745    }
3746}
3747
3748fn layout_link_underline_laid_out(
3749    body_box: LayoutBox,
3750    options: &LayoutOptions,
3751    color: Color,
3752) -> LayoutBox {
3753    let metrics = options.metrics();
3754    let rule = metrics.default_rule_thickness;
3755    let offset = 2.5 * rule;
3756    let segments = link_underline_segments(&body_box);
3757    if segments.len() <= 1 {
3758        return layout_underline_laid_out_with_rule(body_box, rule, offset, color);
3759    }
3760
3761    let height = body_box.height;
3762    let depth = body_box.depth + offset + 0.5 * rule;
3763    let rule_y = height + body_box.depth + offset;
3764    let width = body_box.width;
3765    LayoutBox {
3766        width,
3767        height,
3768        depth,
3769        content: BoxContent::ProofTree {
3770            children: vec![PlacedBox {
3771                box_: body_box,
3772                x: 0.0,
3773                baseline_y: height,
3774            }],
3775            rules: segments
3776                .into_iter()
3777                .map(|(x, width)| ProofRule {
3778                    x,
3779                    y: rule_y,
3780                    width,
3781                    thickness: rule,
3782                    dashed: false,
3783                })
3784                .collect(),
3785        },
3786        color,
3787    }
3788}
3789
3790// ============================================================================
3791// Spacing commands
3792// ============================================================================
3793
3794fn layout_spacing_command(text: &str, options: &LayoutOptions) -> LayoutBox {
3795    let metrics = options.metrics();
3796    let mu = metrics.css_em_per_mu();
3797
3798    let width = match text {
3799        "\\," | "\\thinspace" => 3.0 * mu,
3800        "\\:" | "\\medspace" => 4.0 * mu,
3801        "\\;" | "\\thickspace" => 5.0 * mu,
3802        "\\!" | "\\negthinspace" => -3.0 * mu,
3803        "\\negmedspace" => -4.0 * mu,
3804        "\\negthickspace" => -5.0 * mu,
3805        " " | "~" | "\\nobreakspace" | "\\ " | "\\space" => {
3806            // KaTeX renders these by placing the U+00A0 glyph (char 160) via mathsym.
3807            // Look up its width from MainRegular; fall back to 0.25em (the font-defined value).
3808            // Literal space in `\text{ … }` becomes SpacingNode with text " ".
3809            get_char_metrics(FontId::MainRegular, 160)
3810                .map(|m| m.width)
3811                .unwrap_or(0.25)
3812        }
3813        "\\quad" => metrics.quad,
3814        "\\qquad" => 2.0 * metrics.quad,
3815        "\\enspace" => metrics.quad / 2.0,
3816        _ => 0.0,
3817    };
3818
3819    LayoutBox::new_kern(width)
3820}
3821
3822// ============================================================================
3823// Measurement conversion
3824// ============================================================================
3825
3826fn measurement_to_em(m: &ratex_parser::parse_node::Measurement, options: &LayoutOptions) -> f64 {
3827    let metrics = options.metrics();
3828    match m.unit.as_str() {
3829        "em" => m.number,
3830        "ex" => m.number * metrics.x_height,
3831        "mu" => m.number * metrics.css_em_per_mu(),
3832        "pt" => m.number / metrics.pt_per_em,
3833        "mm" => m.number * 7227.0 / 2540.0 / metrics.pt_per_em,
3834        "cm" => m.number * 7227.0 / 254.0 / metrics.pt_per_em,
3835        "in" => m.number * 72.27 / metrics.pt_per_em,
3836        "bp" => m.number * 803.0 / 800.0 / metrics.pt_per_em,
3837        "pc" => m.number * 12.0 / metrics.pt_per_em,
3838        "dd" => m.number * 1238.0 / 1157.0 / metrics.pt_per_em,
3839        "cc" => m.number * 14856.0 / 1157.0 / metrics.pt_per_em,
3840        "nd" => m.number * 685.0 / 642.0 / metrics.pt_per_em,
3841        "nc" => m.number * 1370.0 / 107.0 / metrics.pt_per_em,
3842        "sp" => m.number / 65536.0 / metrics.pt_per_em,
3843        _ => m.number,
3844    }
3845}
3846
3847// ============================================================================
3848// Math class determination
3849// ============================================================================
3850
3851/// Determine the math class of a ParseNode for spacing purposes.
3852fn node_math_class(node: &ParseNode) -> Option<MathClass> {
3853    match node {
3854        ParseNode::MathOrd { .. } | ParseNode::TextOrd { .. } => Some(MathClass::Ord),
3855        ParseNode::Atom { family, .. } => Some(family_to_math_class(*family)),
3856        ParseNode::OpToken { .. } | ParseNode::Op { .. } | ParseNode::OperatorName { .. } => {
3857            Some(MathClass::Op)
3858        }
3859        ParseNode::OrdGroup { .. } => Some(MathClass::Ord),
3860        // KaTeX genfrac.js: with delimiters (e.g. \binom) → mord; without (e.g. \frac) → minner.
3861        ParseNode::GenFrac {
3862            left_delim,
3863            right_delim,
3864            ..
3865        } => {
3866            let has_delim = left_delim
3867                .as_ref()
3868                .is_some_and(|d| !d.is_empty() && d != ".")
3869                || right_delim
3870                    .as_ref()
3871                    .is_some_and(|d| !d.is_empty() && d != ".");
3872            if has_delim {
3873                Some(MathClass::Ord)
3874            } else {
3875                Some(MathClass::Inner)
3876            }
3877        }
3878        ParseNode::Sqrt { .. } => Some(MathClass::Ord),
3879        ParseNode::SupSub { base, .. } => base.as_ref().and_then(|b| node_math_class(b)),
3880        ParseNode::MClass { mclass, .. } => Some(mclass_str_to_math_class(mclass)),
3881        ParseNode::SpacingNode { .. } => None,
3882        ParseNode::Kern { .. } => None,
3883        ParseNode::HtmlMathMl { html, .. } => {
3884            // Derive math class from the first meaningful child in the HTML branch
3885            for child in html {
3886                if let Some(cls) = node_math_class(child) {
3887                    return Some(cls);
3888                }
3889            }
3890            None
3891        }
3892        ParseNode::Html { body, .. } => {
3893            for child in body {
3894                if let Some(cls) = node_math_class(child) {
3895                    return Some(cls);
3896                }
3897            }
3898            None
3899        }
3900        ParseNode::Lap { .. } => None,
3901        ParseNode::LeftRight { .. } => Some(MathClass::Inner),
3902        ParseNode::AccentToken { .. } => Some(MathClass::Ord),
3903        // \xrightarrow etc. are mathrel in TeX/KaTeX; without this they collapse to Ord–Ord (no kern).
3904        ParseNode::XArrow { .. } => Some(MathClass::Rel),
3905        // CD arrows are structural; treat as Rel for spacing.
3906        ParseNode::CdArrow { .. } => Some(MathClass::Rel),
3907        ParseNode::DelimSizing { mclass, .. } => Some(mclass_str_to_math_class(mclass)),
3908        ParseNode::Middle { .. } => Some(MathClass::Ord),
3909        _ => Some(MathClass::Ord),
3910    }
3911}
3912
3913fn mclass_str_to_math_class(mclass: &str) -> MathClass {
3914    match mclass {
3915        "mord" => MathClass::Ord,
3916        "mop" => MathClass::Op,
3917        "mbin" => MathClass::Bin,
3918        "mrel" => MathClass::Rel,
3919        "mopen" => MathClass::Open,
3920        "mclose" => MathClass::Close,
3921        "mpunct" => MathClass::Punct,
3922        "minner" => MathClass::Inner,
3923        _ => MathClass::Ord,
3924    }
3925}
3926
3927/// Check if a ParseNode is a single character box (affects sup/sub positioning).
3928/// KaTeX `getBaseElem` (`utils.js`): unwrap `ordgroup` / `color` with a single child, and `font`.
3929/// Used for TeX "character box" checks in superscript Rule 18a (`supsub.js`).
3930fn get_base_elem(node: &ParseNode) -> &ParseNode {
3931    match node {
3932        ParseNode::OrdGroup { body, .. } if body.len() == 1 => get_base_elem(&body[0]),
3933        ParseNode::Color { body, .. } if body.len() == 1 => get_base_elem(&body[0]),
3934        ParseNode::Html { body, .. } if body.len() == 1 => get_base_elem(&body[0]),
3935        ParseNode::Font { body, .. } => get_base_elem(body),
3936        _ => node,
3937    }
3938}
3939
3940fn is_character_box(node: &ParseNode) -> bool {
3941    matches!(
3942        get_base_elem(node),
3943        ParseNode::MathOrd { .. }
3944            | ParseNode::TextOrd { .. }
3945            | ParseNode::Atom { .. }
3946            | ParseNode::AccentToken { .. }
3947    )
3948}
3949
3950fn family_to_math_class(family: AtomFamily) -> MathClass {
3951    match family {
3952        AtomFamily::Bin => MathClass::Bin,
3953        AtomFamily::Rel => MathClass::Rel,
3954        AtomFamily::Open => MathClass::Open,
3955        AtomFamily::Close => MathClass::Close,
3956        AtomFamily::Punct => MathClass::Punct,
3957        AtomFamily::Inner => MathClass::Inner,
3958    }
3959}
3960
3961// ============================================================================
3962// Horizontal brace layout (\overbrace, \underbrace)
3963// ============================================================================
3964
3965fn layout_horiz_brace(
3966    base: &ParseNode,
3967    is_over: bool,
3968    func_label: &str,
3969    options: &LayoutOptions,
3970) -> LayoutBox {
3971    let body_box = layout_node(base, options);
3972    let w = body_box.width.max(0.5);
3973
3974    let is_bracket = func_label.trim_start_matches('\\').ends_with("bracket");
3975
3976    // `\overbrace`/`\underbrace` and mathtools `\overbracket`/`\underbracket`: KaTeX stretchy SVG (filled paths).
3977    let stretch_key = if is_bracket {
3978        if is_over {
3979            "overbracket"
3980        } else {
3981            "underbracket"
3982        }
3983    } else if is_over {
3984        "overbrace"
3985    } else {
3986        "underbrace"
3987    };
3988
3989    let (raw_commands, brace_h, brace_fill) =
3990        match crate::katex_svg::katex_stretchy_path(stretch_key, w) {
3991            Some((c, h)) => (c, h, true),
3992            None => {
3993                let h = 0.35_f64;
3994                (horiz_brace_path(w, h, is_over), h, false)
3995            }
3996        };
3997
3998    // Shift y-coordinates: centered commands → SVG-downward convention (height=0, depth=brace_h).
3999    // The raw path is centered at y=0 (range ±brace_h/2). Shift by +brace_h/2 so that:
4000    //   overbrace: peak at y=0 (top), feet at y=+brace_h (bottom)
4001    //   underbrace: feet at y=0 (top), peak at y=+brace_h (bottom)
4002    // Both use height=0, depth=brace_h so the rendering code's SVG accent path handles them.
4003    let y_shift = brace_h / 2.0;
4004    let commands = shift_path_y(raw_commands, y_shift);
4005
4006    let brace_box = LayoutBox {
4007        width: w,
4008        height: 0.0,
4009        depth: brace_h,
4010        content: BoxContent::SvgPath {
4011            commands,
4012            fill: brace_fill,
4013        },
4014        color: options.color,
4015    };
4016
4017    let gap = 0.1;
4018    let (height, depth) = if is_over {
4019        (body_box.height + brace_h + gap, body_box.depth)
4020    } else {
4021        (body_box.height, body_box.depth + brace_h + gap)
4022    };
4023
4024    let clearance = if is_over {
4025        height - brace_h
4026    } else {
4027        body_box.height + body_box.depth + gap
4028    };
4029    let total_w = body_box.width;
4030
4031    LayoutBox {
4032        width: total_w,
4033        height,
4034        depth,
4035        content: BoxContent::Accent {
4036            base: Box::new(body_box),
4037            accent: Box::new(brace_box),
4038            clearance,
4039            skew: 0.0,
4040            is_below: !is_over,
4041            under_gap_em: 0.0,
4042        },
4043        color: options.color,
4044    }
4045}
4046
4047// ============================================================================
4048// XArrow layout (\xrightarrow, \xleftarrow, etc.)
4049// ============================================================================
4050
4051fn layout_xarrow(
4052    label: &str,
4053    body: &ParseNode,
4054    below: Option<&ParseNode>,
4055    options: &LayoutOptions,
4056) -> LayoutBox {
4057    let sup_style = options.style.superscript();
4058    let sub_style = options.style.subscript();
4059    let sup_ratio = sup_style.size_multiplier() / options.style.size_multiplier();
4060    let sub_ratio = sub_style.size_multiplier() / options.style.size_multiplier();
4061
4062    let sup_opts = options.with_style(sup_style);
4063    let body_box = layout_node(body, &sup_opts);
4064    let body_w = body_box.width * sup_ratio;
4065
4066    let below_box = below.map(|b| {
4067        let sub_opts = options.with_style(sub_style);
4068        layout_node(b, &sub_opts)
4069    });
4070    let below_w = below_box
4071        .as_ref()
4072        .map(|b| b.width * sub_ratio)
4073        .unwrap_or(0.0);
4074
4075    // KaTeX `katexImagesData` minWidth on the stretchy SVG, plus `.x-arrow-pad { padding: 0 0.5em }`
4076    // on each label row (em = that row's font). In parent em: +0.5·sup_ratio + 0.5·sup_ratio, etc.
4077    let min_w = crate::katex_svg::katex_stretchy_min_width_em(label).unwrap_or(1.0);
4078    let upper_w = body_w + sup_ratio;
4079    let lower_w = if below_box.is_some() {
4080        below_w + sub_ratio
4081    } else {
4082        0.0
4083    };
4084    let arrow_w = upper_w.max(lower_w).max(min_w);
4085    let arrow_h = 0.3;
4086
4087    let (commands, actual_arrow_h, fill_arrow) =
4088        match crate::katex_svg::katex_stretchy_path(label, arrow_w) {
4089            Some((c, h)) => (c, h, true),
4090            None => (
4091                stretchy_accent_path(label, arrow_w, arrow_h),
4092                arrow_h,
4093                label == "\\xtwoheadrightarrow" || label == "\\xtwoheadleftarrow",
4094            ),
4095        };
4096    let arrow_box = LayoutBox {
4097        width: arrow_w,
4098        height: actual_arrow_h / 2.0,
4099        depth: actual_arrow_h / 2.0,
4100        content: BoxContent::SvgPath {
4101            commands,
4102            fill: fill_arrow,
4103        },
4104        color: options.color,
4105    };
4106
4107    // KaTeX positions xarrows centered on the math axis, with a 0.111em (2mu) gap
4108    // between the arrow and the text above/below (see amsmath.dtx reference).
4109    let metrics = options.metrics();
4110    let axis = metrics.axis_height; // 0.25em
4111    let arrow_half = actual_arrow_h / 2.0;
4112    let gap = 0.111; // 2mu gap (KaTeX constant)
4113
4114    // Center the arrow on the math axis by shifting it up.
4115    let base_shift = -axis;
4116
4117    // sup_kern: gap between arrow top and text bottom.
4118    // In the OpLimits renderer:
4119    //   sup_y = y - (arrow_half - base_shift) - sup_kern - sup_box.depth * ratio
4120    //         = y - (arrow_half + axis) - sup_kern - sup_box.depth * ratio
4121    // KaTeX: text_baseline = -(axis + arrow_half + gap)
4122    //   (with extra -= depth when depth > 0.25, but that's rare for typical text)
4123    // Matching: sup_kern = gap
4124    let sup_kern = gap;
4125    let sub_kern = gap;
4126
4127    let sup_h = body_box.height * sup_ratio;
4128    let sup_d = body_box.depth * sup_ratio;
4129
4130    // Height: from baseline to top of upper text
4131    let height = axis + arrow_half + gap + sup_h + sup_d;
4132    // Depth: arrow bottom below baseline = arrow_half - axis
4133    let mut depth = (arrow_half - axis).max(0.0);
4134
4135    if let Some(ref bel) = below_box {
4136        let sub_h = bel.height * sub_ratio;
4137        let sub_d = bel.depth * sub_ratio;
4138        // Lower text positioned symmetrically below the arrow
4139        depth = (arrow_half - axis) + gap + sub_h + sub_d;
4140    }
4141
4142    LayoutBox {
4143        width: arrow_w,
4144        height,
4145        depth,
4146        content: BoxContent::OpLimits {
4147            base: Box::new(arrow_box),
4148            sup: Some(Box::new(body_box)),
4149            sub: below_box.map(Box::new),
4150            base_shift,
4151            sup_kern,
4152            sub_kern,
4153            slant: 0.0,
4154            sup_scale: sup_ratio,
4155            sub_scale: sub_ratio,
4156        },
4157        color: options.color,
4158    }
4159}
4160
4161// ============================================================================
4162// \textcircled layout
4163// ============================================================================
4164
4165fn layout_textcircled(body_box: LayoutBox, options: &LayoutOptions) -> LayoutBox {
4166    // Draw a circle around the content, similar to KaTeX's CSS-based approach
4167    let pad = 0.1_f64; // padding around the content
4168    let total_h = body_box.height + body_box.depth;
4169    let radius = (body_box.width.max(total_h) / 2.0 + pad).max(0.35);
4170    let diameter = radius * 2.0;
4171
4172    // Build a circle path using cubic Bezier approximation
4173    let cx = radius;
4174    let cy = -(body_box.height - total_h / 2.0); // center at vertical center of content
4175    let k = 0.5523; // cubic Bezier approximation of circle: 4*(sqrt(2)-1)/3
4176    let r = radius;
4177
4178    let circle_commands = vec![
4179        PathCommand::MoveTo { x: cx + r, y: cy },
4180        PathCommand::CubicTo {
4181            x1: cx + r,
4182            y1: cy - k * r,
4183            x2: cx + k * r,
4184            y2: cy - r,
4185            x: cx,
4186            y: cy - r,
4187        },
4188        PathCommand::CubicTo {
4189            x1: cx - k * r,
4190            y1: cy - r,
4191            x2: cx - r,
4192            y2: cy - k * r,
4193            x: cx - r,
4194            y: cy,
4195        },
4196        PathCommand::CubicTo {
4197            x1: cx - r,
4198            y1: cy + k * r,
4199            x2: cx - k * r,
4200            y2: cy + r,
4201            x: cx,
4202            y: cy + r,
4203        },
4204        PathCommand::CubicTo {
4205            x1: cx + k * r,
4206            y1: cy + r,
4207            x2: cx + r,
4208            y2: cy + k * r,
4209            x: cx + r,
4210            y: cy,
4211        },
4212        PathCommand::Close,
4213    ];
4214
4215    let circle_box = LayoutBox {
4216        width: diameter,
4217        height: r - cy.min(0.0),
4218        depth: (r + cy).max(0.0),
4219        content: BoxContent::SvgPath {
4220            commands: circle_commands,
4221            fill: false,
4222        },
4223        color: options.color,
4224    };
4225
4226    // Center the content inside the circle
4227    let content_shift = (diameter - body_box.width) / 2.0;
4228    // Shift content to the right to center it
4229    let children = vec![
4230        circle_box,
4231        LayoutBox::new_kern(-(diameter) + content_shift),
4232        body_box.clone(),
4233    ];
4234
4235    let height = r - cy.min(0.0);
4236    let depth = (r + cy).max(0.0);
4237
4238    LayoutBox {
4239        width: diameter,
4240        height,
4241        depth,
4242        content: BoxContent::HBox(children),
4243        color: options.color,
4244    }
4245}
4246
4247// ============================================================================
4248// Path generation helpers
4249// ============================================================================
4250
4251// ============================================================================
4252// \imageof / \origof  (U+22B7 / U+22B6)
4253// ============================================================================
4254
4255/// Synthesise \imageof (•—○) or \origof (○—•).
4256///
4257/// Neither glyph exists in any KaTeX font.  We build each symbol as an HBox
4258/// of three pieces:
4259///   disk  : filled circle SVG path
4260///   bar   : Rule (horizontal segment at circle-centre height)
4261///   ring  : stroked circle SVG path
4262///
4263/// The ordering is reversed for \origof.
4264///
4265/// Dimensions are calibrated against the KaTeX reference PNG (DPR=2, 20px font):
4266///   ink bbox ≈ 0.700w × 0.225h em, centre ≈ 0.263em above baseline.
4267///
4268/// Coordinate convention in path commands:
4269///   origin = baseline-left of the box, x right, y positive → below baseline.
4270fn layout_imageof_origof(imageof: bool, options: &LayoutOptions) -> LayoutBox {
4271    // Disk radius: filled circle ink height = 2·r = 0.225em  →  r = 0.1125em
4272    let r: f64 = 0.1125;
4273    // Circle centre above baseline (negative = above in path coords).
4274    // Calibrated to the math axis (≈0.25em) so both symbols sit at the same height
4275    // as the reference KaTeX rendering.
4276    let cy: f64 = -0.2625;
4277    // Cubic-Bezier circle approximation constant (4*(√2−1)/3)
4278    let k: f64 = 0.5523;
4279    // Each circle sub-box is 2r wide; the circle centre sits at x = r within it.
4280    let cx: f64 = r;
4281
4282    // Box height/depth: symbol sits entirely above baseline.
4283    let h: f64 = r + cy.abs(); // 0.1125 + 0.2625 = 0.375
4284    let d: f64 = 0.0;
4285
4286    // The renderer strokes rings with width = 1.5 × DPR pixels.
4287    // At the golden-test resolution (font=40px, DPR=1) that is 1.5 px = 0.0375em.
4288    // To keep the ring's outer ink edge coincident with the disk's outer edge,
4289    // draw the ring path at r_ring = r − stroke_half so the outer ink = r − stroke_half + stroke_half = r.
4290    let stroke_half: f64 = 0.01875; // 0.75px / 40px·em⁻¹
4291    let r_ring: f64 = r - stroke_half; // 0.09375em
4292
4293    // Closed circle path (counter-clockwise) centred at (ox, cy) with radius rad.
4294    let circle_commands = |ox: f64, rad: f64| -> Vec<PathCommand> {
4295        vec![
4296            PathCommand::MoveTo { x: ox + rad, y: cy },
4297            PathCommand::CubicTo {
4298                x1: ox + rad,
4299                y1: cy - k * rad,
4300                x2: ox + k * rad,
4301                y2: cy - rad,
4302                x: ox,
4303                y: cy - rad,
4304            },
4305            PathCommand::CubicTo {
4306                x1: ox - k * rad,
4307                y1: cy - rad,
4308                x2: ox - rad,
4309                y2: cy - k * rad,
4310                x: ox - rad,
4311                y: cy,
4312            },
4313            PathCommand::CubicTo {
4314                x1: ox - rad,
4315                y1: cy + k * rad,
4316                x2: ox - k * rad,
4317                y2: cy + rad,
4318                x: ox,
4319                y: cy + rad,
4320            },
4321            PathCommand::CubicTo {
4322                x1: ox + k * rad,
4323                y1: cy + rad,
4324                x2: ox + rad,
4325                y2: cy + k * rad,
4326                x: ox + rad,
4327                y: cy,
4328            },
4329            PathCommand::Close,
4330        ]
4331    };
4332
4333    let disk = LayoutBox {
4334        width: 2.0 * r,
4335        height: h,
4336        depth: d,
4337        content: BoxContent::SvgPath {
4338            commands: circle_commands(cx, r),
4339            fill: true,
4340        },
4341        color: options.color,
4342    };
4343
4344    let ring = LayoutBox {
4345        width: 2.0 * r,
4346        height: h,
4347        depth: d,
4348        content: BoxContent::SvgPath {
4349            commands: circle_commands(cx, r_ring),
4350            fill: false,
4351        },
4352        color: options.color,
4353    };
4354
4355    // Connecting bar centred on the same axis as the circles.
4356    // Rule.raise = distance from baseline to the bottom edge of the rule.
4357    // bar centre at |cy| = 0.2625em  →  raise = 0.2625 − bar_th/2
4358    let bar_len: f64 = 0.25;
4359    let bar_th: f64 = 0.04;
4360    let bar_raise: f64 = cy.abs() - bar_th / 2.0; // 0.2625 − 0.02 = 0.2425
4361
4362    let bar = LayoutBox::new_rule(bar_len, h, d, bar_th, bar_raise);
4363
4364    let children = if imageof {
4365        vec![disk, bar, ring]
4366    } else {
4367        vec![ring, bar, disk]
4368    };
4369
4370    // Total width = 2r (disk) + bar_len + 2r (ring) = 0.225 + 0.25 + 0.225 = 0.700em
4371    let total_width = 4.0 * r + bar_len;
4372    LayoutBox {
4373        width: total_width,
4374        height: h,
4375        depth: d,
4376        content: BoxContent::HBox(children),
4377        color: options.color,
4378    }
4379}
4380
4381/// Build path commands for a horizontal ellipse (circle overlay for \oiint, \oiiint).
4382/// Box-local coords: origin at baseline-left, x right, y down (positive = below baseline).
4383/// Ellipse is centered in the box and spans most of the integral width.
4384fn ellipse_overlay_path(width: f64, height: f64, depth: f64) -> Vec<PathCommand> {
4385    let cx = width / 2.0;
4386    let cy = (depth - height) / 2.0; // vertical center
4387    let a = width * 0.402_f64; // horizontal semi-axis (0.36 * 1.2)
4388    let b = 0.3_f64; // vertical semi-axis (0.1 * 2)
4389    let k = 0.62_f64; // Bezier factor: larger = fuller ellipse (0.5523 ≈ exact circle)
4390    vec![
4391        PathCommand::MoveTo { x: cx + a, y: cy },
4392        PathCommand::CubicTo {
4393            x1: cx + a,
4394            y1: cy - k * b,
4395            x2: cx + k * a,
4396            y2: cy - b,
4397            x: cx,
4398            y: cy - b,
4399        },
4400        PathCommand::CubicTo {
4401            x1: cx - k * a,
4402            y1: cy - b,
4403            x2: cx - a,
4404            y2: cy - k * b,
4405            x: cx - a,
4406            y: cy,
4407        },
4408        PathCommand::CubicTo {
4409            x1: cx - a,
4410            y1: cy + k * b,
4411            x2: cx - k * a,
4412            y2: cy + b,
4413            x: cx,
4414            y: cy + b,
4415        },
4416        PathCommand::CubicTo {
4417            x1: cx + k * a,
4418            y1: cy + b,
4419            x2: cx + a,
4420            y2: cy + k * b,
4421            x: cx + a,
4422            y: cy,
4423        },
4424        PathCommand::Close,
4425    ]
4426}
4427
4428fn shift_path_y(cmds: Vec<PathCommand>, dy: f64) -> Vec<PathCommand> {
4429    cmds.into_iter()
4430        .map(|c| match c {
4431            PathCommand::MoveTo { x, y } => PathCommand::MoveTo { x, y: y + dy },
4432            PathCommand::LineTo { x, y } => PathCommand::LineTo { x, y: y + dy },
4433            PathCommand::CubicTo {
4434                x1,
4435                y1,
4436                x2,
4437                y2,
4438                x,
4439                y,
4440            } => PathCommand::CubicTo {
4441                x1,
4442                y1: y1 + dy,
4443                x2,
4444                y2: y2 + dy,
4445                x,
4446                y: y + dy,
4447            },
4448            PathCommand::QuadTo { x1, y1, x, y } => PathCommand::QuadTo {
4449                x1,
4450                y1: y1 + dy,
4451                x,
4452                y: y + dy,
4453            },
4454            PathCommand::Close => PathCommand::Close,
4455        })
4456        .collect()
4457}
4458
4459fn stretchy_accent_path(label: &str, width: f64, height: f64) -> Vec<PathCommand> {
4460    if let Some(commands) = crate::katex_svg::katex_stretchy_arrow_path(label, width, height) {
4461        return commands;
4462    }
4463    let ah = height * 0.35; // arrowhead size
4464    let mid_y = -height / 2.0;
4465
4466    match label {
4467        "\\overleftarrow" | "\\underleftarrow" | "\\xleftarrow" | "\\xLeftarrow" => {
4468            vec![
4469                PathCommand::MoveTo {
4470                    x: ah,
4471                    y: mid_y - ah,
4472                },
4473                PathCommand::LineTo { x: 0.0, y: mid_y },
4474                PathCommand::LineTo {
4475                    x: ah,
4476                    y: mid_y + ah,
4477                },
4478                PathCommand::MoveTo { x: 0.0, y: mid_y },
4479                PathCommand::LineTo { x: width, y: mid_y },
4480            ]
4481        }
4482        "\\overleftrightarrow"
4483        | "\\underleftrightarrow"
4484        | "\\xleftrightarrow"
4485        | "\\xLeftrightarrow" => {
4486            vec![
4487                PathCommand::MoveTo {
4488                    x: ah,
4489                    y: mid_y - ah,
4490                },
4491                PathCommand::LineTo { x: 0.0, y: mid_y },
4492                PathCommand::LineTo {
4493                    x: ah,
4494                    y: mid_y + ah,
4495                },
4496                PathCommand::MoveTo { x: 0.0, y: mid_y },
4497                PathCommand::LineTo { x: width, y: mid_y },
4498                PathCommand::MoveTo {
4499                    x: width - ah,
4500                    y: mid_y - ah,
4501                },
4502                PathCommand::LineTo { x: width, y: mid_y },
4503                PathCommand::LineTo {
4504                    x: width - ah,
4505                    y: mid_y + ah,
4506                },
4507            ]
4508        }
4509        "\\xlongequal" => {
4510            let gap = 0.04;
4511            vec![
4512                PathCommand::MoveTo {
4513                    x: 0.0,
4514                    y: mid_y - gap,
4515                },
4516                PathCommand::LineTo {
4517                    x: width,
4518                    y: mid_y - gap,
4519                },
4520                PathCommand::MoveTo {
4521                    x: 0.0,
4522                    y: mid_y + gap,
4523                },
4524                PathCommand::LineTo {
4525                    x: width,
4526                    y: mid_y + gap,
4527                },
4528            ]
4529        }
4530        "\\xhookleftarrow" => {
4531            vec![
4532                PathCommand::MoveTo {
4533                    x: ah,
4534                    y: mid_y - ah,
4535                },
4536                PathCommand::LineTo { x: 0.0, y: mid_y },
4537                PathCommand::LineTo {
4538                    x: ah,
4539                    y: mid_y + ah,
4540                },
4541                PathCommand::MoveTo { x: 0.0, y: mid_y },
4542                PathCommand::LineTo { x: width, y: mid_y },
4543                PathCommand::QuadTo {
4544                    x1: width + ah,
4545                    y1: mid_y,
4546                    x: width + ah,
4547                    y: mid_y + ah,
4548                },
4549            ]
4550        }
4551        "\\xhookrightarrow" => {
4552            vec![
4553                PathCommand::MoveTo {
4554                    x: 0.0 - ah,
4555                    y: mid_y - ah,
4556                },
4557                PathCommand::QuadTo {
4558                    x1: 0.0 - ah,
4559                    y1: mid_y,
4560                    x: 0.0,
4561                    y: mid_y,
4562                },
4563                PathCommand::LineTo { x: width, y: mid_y },
4564                PathCommand::MoveTo {
4565                    x: width - ah,
4566                    y: mid_y - ah,
4567                },
4568                PathCommand::LineTo { x: width, y: mid_y },
4569                PathCommand::LineTo {
4570                    x: width - ah,
4571                    y: mid_y + ah,
4572                },
4573            ]
4574        }
4575        "\\xrightharpoonup" | "\\xleftharpoonup" => {
4576            let right = label.contains("right");
4577            if right {
4578                vec![
4579                    PathCommand::MoveTo { x: 0.0, y: mid_y },
4580                    PathCommand::LineTo { x: width, y: mid_y },
4581                    PathCommand::MoveTo {
4582                        x: width - ah,
4583                        y: mid_y - ah,
4584                    },
4585                    PathCommand::LineTo { x: width, y: mid_y },
4586                ]
4587            } else {
4588                vec![
4589                    PathCommand::MoveTo {
4590                        x: ah,
4591                        y: mid_y - ah,
4592                    },
4593                    PathCommand::LineTo { x: 0.0, y: mid_y },
4594                    PathCommand::LineTo { x: width, y: mid_y },
4595                ]
4596            }
4597        }
4598        "\\xrightharpoondown" | "\\xleftharpoondown" => {
4599            let right = label.contains("right");
4600            if right {
4601                vec![
4602                    PathCommand::MoveTo { x: 0.0, y: mid_y },
4603                    PathCommand::LineTo { x: width, y: mid_y },
4604                    PathCommand::MoveTo {
4605                        x: width - ah,
4606                        y: mid_y + ah,
4607                    },
4608                    PathCommand::LineTo { x: width, y: mid_y },
4609                ]
4610            } else {
4611                vec![
4612                    PathCommand::MoveTo {
4613                        x: ah,
4614                        y: mid_y + ah,
4615                    },
4616                    PathCommand::LineTo { x: 0.0, y: mid_y },
4617                    PathCommand::LineTo { x: width, y: mid_y },
4618                ]
4619            }
4620        }
4621        "\\xrightleftharpoons" | "\\xleftrightharpoons" => {
4622            let gap = 0.06;
4623            vec![
4624                PathCommand::MoveTo {
4625                    x: 0.0,
4626                    y: mid_y - gap,
4627                },
4628                PathCommand::LineTo {
4629                    x: width,
4630                    y: mid_y - gap,
4631                },
4632                PathCommand::MoveTo {
4633                    x: width - ah,
4634                    y: mid_y - gap - ah,
4635                },
4636                PathCommand::LineTo {
4637                    x: width,
4638                    y: mid_y - gap,
4639                },
4640                PathCommand::MoveTo {
4641                    x: width,
4642                    y: mid_y + gap,
4643                },
4644                PathCommand::LineTo {
4645                    x: 0.0,
4646                    y: mid_y + gap,
4647                },
4648                PathCommand::MoveTo {
4649                    x: ah,
4650                    y: mid_y + gap + ah,
4651                },
4652                PathCommand::LineTo {
4653                    x: 0.0,
4654                    y: mid_y + gap,
4655                },
4656            ]
4657        }
4658        "\\xtofrom" | "\\xrightleftarrows" => {
4659            let gap = 0.06;
4660            vec![
4661                PathCommand::MoveTo {
4662                    x: 0.0,
4663                    y: mid_y - gap,
4664                },
4665                PathCommand::LineTo {
4666                    x: width,
4667                    y: mid_y - gap,
4668                },
4669                PathCommand::MoveTo {
4670                    x: width - ah,
4671                    y: mid_y - gap - ah,
4672                },
4673                PathCommand::LineTo {
4674                    x: width,
4675                    y: mid_y - gap,
4676                },
4677                PathCommand::LineTo {
4678                    x: width - ah,
4679                    y: mid_y - gap + ah,
4680                },
4681                PathCommand::MoveTo {
4682                    x: width,
4683                    y: mid_y + gap,
4684                },
4685                PathCommand::LineTo {
4686                    x: 0.0,
4687                    y: mid_y + gap,
4688                },
4689                PathCommand::MoveTo {
4690                    x: ah,
4691                    y: mid_y + gap - ah,
4692                },
4693                PathCommand::LineTo {
4694                    x: 0.0,
4695                    y: mid_y + gap,
4696                },
4697                PathCommand::LineTo {
4698                    x: ah,
4699                    y: mid_y + gap + ah,
4700                },
4701            ]
4702        }
4703        "\\overlinesegment" | "\\underlinesegment" => {
4704            vec![
4705                PathCommand::MoveTo { x: 0.0, y: mid_y },
4706                PathCommand::LineTo { x: width, y: mid_y },
4707            ]
4708        }
4709        _ => {
4710            vec![
4711                PathCommand::MoveTo { x: 0.0, y: mid_y },
4712                PathCommand::LineTo { x: width, y: mid_y },
4713                PathCommand::MoveTo {
4714                    x: width - ah,
4715                    y: mid_y - ah,
4716                },
4717                PathCommand::LineTo { x: width, y: mid_y },
4718                PathCommand::LineTo {
4719                    x: width - ah,
4720                    y: mid_y + ah,
4721                },
4722            ]
4723        }
4724    }
4725}
4726
4727// ============================================================================
4728// CD (amscd commutative diagram) layout
4729// ============================================================================
4730
4731/// Wrap a horizontal arrow cell with left/right kerns (KaTeX `.cd-arrow-pad`).
4732fn cd_wrap_hpad(inner: LayoutBox, pad_l: f64, pad_r: f64, color: Color) -> LayoutBox {
4733    let h = inner.height;
4734    let d = inner.depth;
4735    let w = inner.width + pad_l + pad_r;
4736    let mut children: Vec<LayoutBox> = Vec::with_capacity(3);
4737    if pad_l > 0.0 {
4738        children.push(LayoutBox::new_kern(pad_l));
4739    }
4740    children.push(inner);
4741    if pad_r > 0.0 {
4742        children.push(LayoutBox::new_kern(pad_r));
4743    }
4744    LayoutBox {
4745        width: w,
4746        height: h,
4747        depth: d,
4748        content: BoxContent::HBox(children),
4749        color,
4750    }
4751}
4752
4753/// Wrap a side label for a vertical CD arrow so it is vertically centered on the shaft.
4754///
4755/// The resulting box reports `height = box_h, depth = box_d` (same as the shaft) so it
4756/// does not change the row's allocated height.  The label body is raised/lowered via
4757/// `RaiseBox` so that the label's visual center aligns with the shaft's vertical center.
4758///
4759/// Derivation (screen coords, y+ downward):
4760///   shaft center  = (box_d − box_h) / 2
4761///   label center  = −shift − (label_h − label_d) / 2
4762///   solving gives  shift = (box_h − box_d + label_d − label_h) / 2
4763fn cd_vcenter_side_label(label: LayoutBox, box_h: f64, box_d: f64, color: Color) -> LayoutBox {
4764    let shift = (box_h - box_d + label.depth - label.height) / 2.0;
4765    LayoutBox {
4766        width: label.width,
4767        height: box_h,
4768        depth: box_d,
4769        content: BoxContent::RaiseBox {
4770            body: Box::new(label),
4771            shift,
4772        },
4773        color,
4774    }
4775}
4776
4777/// Side labels on vertical `{CD}` arrows: KaTeX `\\\\cdleft` / `\\\\cdright` both use
4778/// `options.style.sup()` (`cd.js` htmlBuilder), then our pipeline must scale like `OpLimits`
4779/// scripts — `RaiseBox` in `to_display` does not apply script size, so wrap in `Scaled`.
4780fn cd_side_label_scaled(body: &ParseNode, options: &LayoutOptions) -> LayoutBox {
4781    let sup_style = options.style.superscript();
4782    let sup_opts = options.with_style(sup_style);
4783    let sup_ratio = sup_style.size_multiplier() / options.style.size_multiplier();
4784    let inner = layout_node(body, &sup_opts);
4785    if (sup_ratio - 1.0).abs() < 1e-6 {
4786        inner
4787    } else {
4788        LayoutBox {
4789            width: inner.width * sup_ratio,
4790            height: inner.height * sup_ratio,
4791            depth: inner.depth * sup_ratio,
4792            content: BoxContent::Scaled {
4793                body: Box::new(inner),
4794                child_scale: sup_ratio,
4795            },
4796            color: options.color,
4797        }
4798    }
4799}
4800
4801/// Stretch ↑ / ↓ to span the CD arrow row (`total_height` = height + depth in em).
4802///
4803/// Reuses the same filled KaTeX stretchy path as horizontal `\cdrightarrow` (see
4804/// `katex_svg::katex_cd_vert_arrow_from_rightarrow`) so the head/shaft match the horizontal CD
4805/// arrows; `make_stretchy_delim` does not stack ↑/↓ to arbitrary heights.
4806fn cd_stretch_vert_arrow_box(total_height: f64, down: bool, options: &LayoutOptions) -> LayoutBox {
4807    let axis = options.metrics().axis_height;
4808    let depth = (total_height / 2.0 - axis).max(0.0);
4809    let height = total_height - depth;
4810    if let Some((commands, w)) =
4811        crate::katex_svg::katex_cd_vert_arrow_from_rightarrow(down, total_height, axis)
4812    {
4813        return LayoutBox {
4814            width: w,
4815            height,
4816            depth,
4817            content: BoxContent::SvgPath {
4818                commands,
4819                fill: true,
4820            },
4821            color: options.color,
4822        };
4823    }
4824    // Fallback (should not happen): `\cdrightarrow` is always in the stretchy table.
4825    if down {
4826        make_stretchy_delim("\\downarrow", SIZE_TO_MAX_HEIGHT[2], options)
4827    } else {
4828        make_stretchy_delim("\\uparrow", SIZE_TO_MAX_HEIGHT[2], options)
4829    }
4830}
4831
4832/// Render a single CdArrow cell.
4833///
4834/// `target_size`:
4835/// - `w > 0` for horizontal arrows: shaft length is exactly `w` em (KaTeX: per-cell natural width,
4836///   not the full column max — see `.katex .mtable` + `.stretchy { width: 100% }` where the cell
4837///   span is only as wide as content; narrow arrows stay at `max(labels, minCDarrowwidth)` and sit
4838///   centered in a wider column).
4839/// - `h > 0` for vertical arrows: shaft total height (height+depth) = `h`.
4840/// - `0.0` = natural size (pass 1).
4841///
4842/// `target_col_width`: when `> 0`, center the cell in this column width (horizontal: side kerns;
4843/// vertical: kerns around shaft + labels).
4844///
4845/// `target_depth` (vertical only): depth portion of `target_size` when `> 0`, so that
4846/// `box_h = target_size - target_depth` and `box_d = target_depth`.
4847fn layout_cd_arrow(
4848    direction: &str,
4849    label_above: Option<&ParseNode>,
4850    label_below: Option<&ParseNode>,
4851    target_size: f64,
4852    target_col_width: f64,
4853    _target_depth: f64,
4854    options: &LayoutOptions,
4855) -> LayoutBox {
4856    let metrics = options.metrics();
4857    let axis = metrics.axis_height;
4858
4859    // Vertical CD: kern between side label and shaft (KaTeX `cd-label-*` sits tight; 0.25em
4860    // widens object columns vs `tests/golden/fixtures` CD).
4861    const CD_VERT_SIDE_KERN_EM: f64 = 0.11;
4862
4863    match direction {
4864        "right" | "left" | "horiz_eq" => {
4865            // ── Horizontal arrow: reuse katex_stretchy_path for proper KaTeX shape ──
4866            let sup_style = options.style.superscript();
4867            let sub_style = options.style.subscript();
4868            let sup_opts = options.with_style(sup_style);
4869            let sub_opts = options.with_style(sub_style);
4870            let sup_ratio = sup_style.size_multiplier() / options.style.size_multiplier();
4871            let sub_ratio = sub_style.size_multiplier() / options.style.size_multiplier();
4872
4873            let above_box = label_above.map(|n| layout_node(n, &sup_opts));
4874            let below_box = label_below.map(|n| layout_node(n, &sub_opts));
4875
4876            let above_w = above_box
4877                .as_ref()
4878                .map(|b| b.width * sup_ratio)
4879                .unwrap_or(0.0);
4880            let below_w = below_box
4881                .as_ref()
4882                .map(|b| b.width * sub_ratio)
4883                .unwrap_or(0.0);
4884
4885            // KaTeX `stretchy.js`: CD uses `\\cdrightarrow` / `\\cdleftarrow` / `\\cdlongequal` (minWidth 3.0em).
4886            let path_label = if direction == "right" {
4887                "\\cdrightarrow"
4888            } else if direction == "left" {
4889                "\\cdleftarrow"
4890            } else {
4891                "\\cdlongequal"
4892            };
4893            let min_shaft_w =
4894                crate::katex_svg::katex_stretchy_min_width_em(path_label).unwrap_or(1.0);
4895            // Based on KaTeX `.cd-arrow-pad` (0.27778 / 0.55556 script-em); slightly trimmed so
4896            // `natural_w` matches golden KaTeX PNGs in our box model (e.g. 0150).
4897            const CD_LABEL_PAD_L: f64 = 0.22;
4898            const CD_LABEL_PAD_R: f64 = 0.48;
4899            let cd_pad_sup = (CD_LABEL_PAD_L + CD_LABEL_PAD_R) * sup_ratio;
4900            let cd_pad_sub = (CD_LABEL_PAD_L + CD_LABEL_PAD_R) * sub_ratio;
4901            let upper_need = above_box
4902                .as_ref()
4903                .map(|_| above_w + cd_pad_sup)
4904                .unwrap_or(0.0);
4905            let lower_need = below_box
4906                .as_ref()
4907                .map(|_| below_w + cd_pad_sub)
4908                .unwrap_or(0.0);
4909            let natural_w = upper_need.max(lower_need).max(0.0);
4910            let shaft_w = if target_size > 0.0 {
4911                target_size
4912            } else {
4913                natural_w.max(min_shaft_w)
4914            };
4915
4916            let (commands, actual_arrow_h, fill_arrow) =
4917                match crate::katex_svg::katex_stretchy_path(path_label, shaft_w) {
4918                    Some((c, h)) => (c, h, true),
4919                    None => {
4920                        // Fallback hand-drawn (should not happen for these labels)
4921                        let arrow_h = 0.3_f64;
4922                        let ah = 0.12_f64;
4923                        let cmds = if direction == "horiz_eq" {
4924                            let gap = 0.06;
4925                            vec![
4926                                PathCommand::MoveTo { x: 0.0, y: -gap },
4927                                PathCommand::LineTo {
4928                                    x: shaft_w,
4929                                    y: -gap,
4930                                },
4931                                PathCommand::MoveTo { x: 0.0, y: gap },
4932                                PathCommand::LineTo { x: shaft_w, y: gap },
4933                            ]
4934                        } else if direction == "right" {
4935                            vec![
4936                                PathCommand::MoveTo { x: 0.0, y: 0.0 },
4937                                PathCommand::LineTo { x: shaft_w, y: 0.0 },
4938                                PathCommand::MoveTo {
4939                                    x: shaft_w - ah,
4940                                    y: -ah,
4941                                },
4942                                PathCommand::LineTo { x: shaft_w, y: 0.0 },
4943                                PathCommand::LineTo {
4944                                    x: shaft_w - ah,
4945                                    y: ah,
4946                                },
4947                            ]
4948                        } else {
4949                            vec![
4950                                PathCommand::MoveTo { x: shaft_w, y: 0.0 },
4951                                PathCommand::LineTo { x: 0.0, y: 0.0 },
4952                                PathCommand::MoveTo { x: ah, y: -ah },
4953                                PathCommand::LineTo { x: 0.0, y: 0.0 },
4954                                PathCommand::LineTo { x: ah, y: ah },
4955                            ]
4956                        };
4957                        (cmds, arrow_h, false)
4958                    }
4959                };
4960
4961            // Arrow box centered at y=0 (same as layout_xarrow)
4962            let arrow_half = actual_arrow_h / 2.0;
4963            let arrow_box = LayoutBox {
4964                width: shaft_w,
4965                height: arrow_half,
4966                depth: arrow_half,
4967                content: BoxContent::SvgPath {
4968                    commands,
4969                    fill: fill_arrow,
4970                },
4971                color: options.color,
4972            };
4973
4974            // Total height/depth for OpLimits (mirrors layout_xarrow / KaTeX arrow.ts)
4975            let gap = 0.111;
4976            let sup_h = above_box
4977                .as_ref()
4978                .map(|b| b.height * sup_ratio)
4979                .unwrap_or(0.0);
4980            let sup_d = above_box
4981                .as_ref()
4982                .map(|b| b.depth * sup_ratio)
4983                .unwrap_or(0.0);
4984            // KaTeX arrow.ts: label depth only shifts the label up when depth > 0.25
4985            // (at the label's own scale). Otherwise the label baseline stays fixed and
4986            // depth extends into the gap without increasing the cell height.
4987            let sup_d_contrib = if above_box.as_ref().map(|b| b.depth).unwrap_or(0.0) > 0.25 {
4988                sup_d
4989            } else {
4990                0.0
4991            };
4992            let height = axis + arrow_half + gap + sup_h + sup_d_contrib;
4993            let sub_h_raw = below_box
4994                .as_ref()
4995                .map(|b| b.height * sub_ratio)
4996                .unwrap_or(0.0);
4997            let sub_d_raw = below_box
4998                .as_ref()
4999                .map(|b| b.depth * sub_ratio)
5000                .unwrap_or(0.0);
5001            let depth = if below_box.is_some() {
5002                (arrow_half - axis).max(0.0) + gap + sub_h_raw + sub_d_raw
5003            } else {
5004                (arrow_half - axis).max(0.0)
5005            };
5006
5007            let inner = LayoutBox {
5008                width: shaft_w,
5009                height,
5010                depth,
5011                content: BoxContent::OpLimits {
5012                    base: Box::new(arrow_box),
5013                    sup: above_box.map(Box::new),
5014                    sub: below_box.map(Box::new),
5015                    base_shift: -axis,
5016                    sup_kern: gap,
5017                    sub_kern: gap,
5018                    slant: 0.0,
5019                    sup_scale: sup_ratio,
5020                    sub_scale: sub_ratio,
5021                },
5022                color: options.color,
5023            };
5024
5025            // KaTeX HTML: column width is max(cell widths); each cell stays intrinsic width and is
5026            // centered in the column (`col-align-c`). Match with side kerns, not by stretching the
5027            // shaft to the column max.
5028            if target_col_width > inner.width + 1e-6 {
5029                let extra = target_col_width - inner.width;
5030                let kl = extra / 2.0;
5031                let kr = extra - kl;
5032                cd_wrap_hpad(inner, kl, kr, options.color)
5033            } else {
5034                inner
5035            }
5036        }
5037
5038        "down" | "up" | "vert_eq" => {
5039            // Pass 1: \Big (~1.8em). Pass 2: stretch ↑/↓ / ‖ to the full arrow-row span (em).
5040            let big_total = SIZE_TO_MAX_HEIGHT[2]; // 1.8em
5041
5042            let shaft_box = match direction {
5043                "vert_eq" if target_size > 0.0 => {
5044                    make_vert_delim_box(target_size.max(big_total), true, false, options)
5045                }
5046                "vert_eq" => make_stretchy_delim("\\Vert", big_total, options),
5047                "down" if target_size > 0.0 => {
5048                    cd_stretch_vert_arrow_box(target_size.max(1.0), true, options)
5049                }
5050                "up" if target_size > 0.0 => {
5051                    cd_stretch_vert_arrow_box(target_size.max(1.0), false, options)
5052                }
5053                "down" => cd_stretch_vert_arrow_box(big_total, true, options),
5054                "up" => cd_stretch_vert_arrow_box(big_total, false, options),
5055                _ => cd_stretch_vert_arrow_box(big_total, true, options),
5056            };
5057            let box_h = shaft_box.height;
5058            let box_d = shaft_box.depth;
5059            let shaft_w = shaft_box.width;
5060
5061            // Side labels: KaTeX uses `style.sup()` for both left and right; scale via `Scaled`
5062            // so `to_display::RaiseBox` does not leave them at display size (unlike `OpLimits`).
5063            let left_box = label_above.map(|n| {
5064                cd_vcenter_side_label(
5065                    cd_side_label_scaled(n, options),
5066                    box_h,
5067                    box_d,
5068                    options.color,
5069                )
5070            });
5071            let right_box = label_below.map(|n| {
5072                cd_vcenter_side_label(
5073                    cd_side_label_scaled(n, options),
5074                    box_h,
5075                    box_d,
5076                    options.color,
5077                )
5078            });
5079
5080            let left_w = left_box.as_ref().map(|b| b.width).unwrap_or(0.0);
5081            let right_w = right_box.as_ref().map(|b| b.width).unwrap_or(0.0);
5082            let left_part = left_w
5083                + if left_w > 0.0 {
5084                    CD_VERT_SIDE_KERN_EM
5085                } else {
5086                    0.0
5087                };
5088            let right_part = (if right_w > 0.0 {
5089                CD_VERT_SIDE_KERN_EM
5090            } else {
5091                0.0
5092            }) + right_w;
5093            let inner_w = left_part + shaft_w + right_part;
5094
5095            // Center shaft within the column width (pass 2) using side kerns.
5096            let (kern_left, kern_right, total_w) = if target_col_width > inner_w {
5097                let extra = target_col_width - inner_w;
5098                let kl = extra / 2.0;
5099                let kr = extra - kl;
5100                (kl, kr, target_col_width)
5101            } else {
5102                (0.0, 0.0, inner_w)
5103            };
5104
5105            let mut children: Vec<LayoutBox> = Vec::new();
5106            if kern_left > 0.0 {
5107                children.push(LayoutBox::new_kern(kern_left));
5108            }
5109            if let Some(lb) = left_box {
5110                children.push(lb);
5111                children.push(LayoutBox::new_kern(CD_VERT_SIDE_KERN_EM));
5112            }
5113            children.push(shaft_box);
5114            if let Some(rb) = right_box {
5115                children.push(LayoutBox::new_kern(CD_VERT_SIDE_KERN_EM));
5116                children.push(rb);
5117            }
5118            if kern_right > 0.0 {
5119                children.push(LayoutBox::new_kern(kern_right));
5120            }
5121
5122            LayoutBox {
5123                width: total_w,
5124                height: box_h,
5125                depth: box_d,
5126                content: BoxContent::HBox(children),
5127                color: options.color,
5128            }
5129        }
5130
5131        // "none" or unknown: empty placeholder
5132        _ => LayoutBox::new_empty(),
5133    }
5134}
5135
5136/// Layout a `\begin{CD}...\end{CD}` commutative diagram with two-pass stretching.
5137fn layout_cd(body: &[Vec<ParseNode>], options: &LayoutOptions) -> LayoutBox {
5138    let metrics = options.metrics();
5139    let pt = 1.0 / metrics.pt_per_em;
5140    // KaTeX CD uses `baselineskip = 3ex` (array.ts line 312), NOT the standard 12pt.
5141    let baselineskip = 3.0 * metrics.x_height;
5142    let arstrut_h = 0.7 * baselineskip;
5143    let arstrut_d = 0.3 * baselineskip;
5144
5145    let num_rows = body.len();
5146    if num_rows == 0 {
5147        return LayoutBox::new_empty();
5148    }
5149    let num_cols = body.iter().map(|r| r.len()).max().unwrap_or(0);
5150    if num_cols == 0 {
5151        return LayoutBox::new_empty();
5152    }
5153
5154    // `\jot` (3pt): added to every row depth below; include in vertical-arrow stretch span.
5155    let jot = 3.0 * pt;
5156
5157    // ── Pass 1: layout all cells at natural size ────────────────────────────
5158    let mut cell_boxes: Vec<Vec<LayoutBox>> = Vec::with_capacity(num_rows);
5159    let mut col_widths = vec![0.0_f64; num_cols];
5160    let mut row_heights = vec![arstrut_h; num_rows];
5161    let mut row_depths = vec![arstrut_d; num_rows];
5162
5163    for (r, row) in body.iter().enumerate() {
5164        let mut row_boxes: Vec<LayoutBox> = Vec::with_capacity(num_cols);
5165
5166        for (c, cell) in row.iter().enumerate() {
5167            let cbox = match cell {
5168                ParseNode::CdArrow {
5169                    direction,
5170                    label_above,
5171                    label_below,
5172                    ..
5173                } => {
5174                    layout_cd_arrow(
5175                        direction,
5176                        label_above.as_deref(),
5177                        label_below.as_deref(),
5178                        0.0, // natural size in pass 1
5179                        0.0, // natural column width
5180                        0.0, // natural depth split
5181                        options,
5182                    )
5183                }
5184                // KaTeX CD object cells are `styling` nodes; `sizingGroup` builds the body with
5185                // `buildExpression(..., false)` (see katex `functions/sizing.js`), so no inter-atom
5186                // math glue inside a cell — matching that avoids spurious Ord–Bin space (e.g. golden 0963).
5187                ParseNode::OrdGroup {
5188                    body: cell_body, ..
5189                } => layout_expression(cell_body, options, false),
5190                other => layout_node(other, options),
5191            };
5192
5193            row_heights[r] = row_heights[r].max(cbox.height);
5194            row_depths[r] = row_depths[r].max(cbox.depth);
5195            col_widths[c] = col_widths[c].max(cbox.width);
5196            row_boxes.push(cbox);
5197        }
5198
5199        // Pad missing columns
5200        while row_boxes.len() < num_cols {
5201            row_boxes.push(LayoutBox::new_empty());
5202        }
5203        cell_boxes.push(row_boxes);
5204    }
5205
5206    // Column targets after pass 1 (max natural width per column). Horizontal shafts use per-cell
5207    // `target_size`, not this max — same as KaTeX: minCDarrowwidth is min-width on the glyph span,
5208    // not “stretch every row to column max”.
5209    let col_target_w: Vec<f64> = col_widths.clone();
5210
5211    #[cfg(debug_assertions)]
5212    {
5213        eprintln!("[CD] pass1 col_widths={col_widths:?} row_heights={row_heights:?} row_depths={row_depths:?}");
5214        for (r, row) in cell_boxes.iter().enumerate() {
5215            for (c, b) in row.iter().enumerate() {
5216                if b.width > 0.0 {
5217                    eprintln!(
5218                        "[CD]   cell[{r}][{c}] w={:.4} h={:.4} d={:.4}",
5219                        b.width, b.height, b.depth
5220                    );
5221                }
5222            }
5223        }
5224    }
5225
5226    // ── Pass 2: re-layout arrow cells with target dimensions ───────────────
5227    for (r, row) in body.iter().enumerate() {
5228        let is_arrow_row = r % 2 == 1;
5229        for (c, cell) in row.iter().enumerate() {
5230            if let ParseNode::CdArrow {
5231                direction,
5232                label_above,
5233                label_below,
5234                ..
5235            } = cell
5236            {
5237                let is_horiz = matches!(direction.as_str(), "right" | "left" | "horiz_eq");
5238                let (new_box, col_w) = if !is_arrow_row && c % 2 == 1 && is_horiz {
5239                    let b = layout_cd_arrow(
5240                        direction,
5241                        label_above.as_deref(),
5242                        label_below.as_deref(),
5243                        cell_boxes[r][c].width,
5244                        col_target_w[c],
5245                        0.0,
5246                        options,
5247                    );
5248                    let w = b.width;
5249                    (b, w)
5250                } else if is_arrow_row && c % 2 == 0 {
5251                    // Vertical arrow: KaTeX uses a fixed `\Big` delimiter, not a
5252                    // stretchy arrow.  Match by using the pass-1 row span (without
5253                    // \jot) so the shaft height stays at the natural row h+d.
5254                    let v_span = row_heights[r] + row_depths[r];
5255                    let b = layout_cd_arrow(
5256                        direction,
5257                        label_above.as_deref(),
5258                        label_below.as_deref(),
5259                        v_span,
5260                        col_widths[c],
5261                        0.0,
5262                        options,
5263                    );
5264                    let w = b.width;
5265                    (b, w)
5266                } else {
5267                    continue;
5268                };
5269                col_widths[c] = col_widths[c].max(col_w);
5270                cell_boxes[r][c] = new_box;
5271            }
5272        }
5273    }
5274
5275    #[cfg(debug_assertions)]
5276    {
5277        eprintln!("[CD] pass2 col_widths={col_widths:?} row_heights={row_heights:?} row_depths={row_depths:?}");
5278    }
5279
5280    // KaTeX `environments/cd.js` sets `addJot: true` for CD; `array.js` adds `\jot` (3pt) to each
5281    // row's depth (same as `layout_array` when `add_jot` is set).
5282    for rd in &mut row_depths {
5283        *rd += jot;
5284    }
5285
5286    // ── Build the final Array LayoutBox ────────────────────────────────────
5287    // KaTeX CD uses `pregap: 0.25, postgap: 0.25` per column (cd.ts line 216-217),
5288    // giving 0.5em between adjacent columns.  `hskipBeforeAndAfter` is unset (false),
5289    // so no outer padding.
5290    let col_gap = 0.5;
5291
5292    // Column alignment: objects are centered, arrows are centered
5293    let col_aligns: Vec<u8> = (0..num_cols).map(|_| b'c').collect();
5294
5295    // No vertical separators for CD
5296    let col_separators = vec![None; num_cols + 1];
5297
5298    let mut total_height = 0.0_f64;
5299    let mut row_positions = Vec::with_capacity(num_rows);
5300    for r in 0..num_rows {
5301        total_height += row_heights[r];
5302        row_positions.push(total_height);
5303        total_height += row_depths[r];
5304    }
5305
5306    let offset = total_height / 2.0 + metrics.axis_height;
5307    let height = offset;
5308    let depth = total_height - offset;
5309
5310    // Total width: sum of col_widths + col_gap between each
5311    let total_width =
5312        col_widths.iter().sum::<f64>() + col_gap * (num_cols.saturating_sub(1)) as f64;
5313
5314    // Build hlines_before_row (all empty for CD)
5315    let hlines_before_row: Vec<Vec<bool>> = (0..=num_rows).map(|_| vec![]).collect();
5316
5317    LayoutBox {
5318        width: total_width,
5319        height,
5320        depth,
5321        content: BoxContent::Array {
5322            cells: cell_boxes,
5323            col_widths,
5324            col_aligns,
5325            row_heights,
5326            row_depths,
5327            col_gap,
5328            offset,
5329            content_x_offset: 0.0,
5330            col_separators,
5331            hlines_before_row,
5332            rule_thickness: 0.04 * pt,
5333            double_rule_sep: metrics.double_rule_sep,
5334            array_inner_width: total_width,
5335            tag_gap_em: 0.0,
5336            tag_col_width: 0.0,
5337            row_tags: (0..num_rows).map(|_| None).collect(),
5338            tags_left: false,
5339        },
5340        color: options.color,
5341    }
5342}
5343
5344struct ProofTreeLayout {
5345    width: f64,
5346    height: f64,
5347    depth: f64,
5348    root_width: f64,
5349    root_center_x: f64,
5350    children: Vec<PlacedBox>,
5351    rules: Vec<ProofRule>,
5352}
5353
5354/// MathJax `bussproofs` typesets each axiom / inference **cell** with upright letters
5355/// (roman), not math italic. RaTeX otherwise lays out cell bodies like ordinary math
5356/// (`P` → Math-Italic). Wrap the cell in `\mathrm{...}` so single-letter conclusions
5357/// match reference PNGs; relation symbols like `\Rightarrow` from `\fCenter` still
5358/// fall through `layout_with_font` to default `layout_node` and keep correct spacing.
5359fn layout_proof_roman_content(nodes: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
5360    if nodes.is_empty() {
5361        return LayoutBox::new_empty();
5362    }
5363    let group = ParseNode::OrdGroup {
5364        mode: Mode::Math,
5365        body: nodes.to_vec(),
5366        semisimple: None,
5367        loc: None,
5368    };
5369    let wrapped = ParseNode::Font {
5370        mode: Mode::Math,
5371        font: "mathrm".to_string(),
5372        body: Box::new(group),
5373        loc: None,
5374    };
5375    layout_node(&wrapped, options)
5376}
5377
5378fn layout_proof_cell_content(nodes: &[ParseNode], options: &LayoutOptions) -> LayoutBox {
5379    let mut box_ = layout_proof_roman_content(nodes, options);
5380    box_.depth = box_.depth.max(0.20);
5381    box_
5382}
5383
5384fn layout_proof_tree(tree: &ProofBranch, options: &LayoutOptions) -> LayoutBox {
5385    let laid = layout_proof_branch(tree, options);
5386    LayoutBox {
5387        width: laid.width,
5388        height: laid.height,
5389        depth: laid.depth,
5390        content: BoxContent::ProofTree {
5391            children: laid.children,
5392            rules: laid.rules,
5393        },
5394        color: options.color,
5395    }
5396}
5397
5398fn layout_proof_branch(tree: &ProofBranch, options: &LayoutOptions) -> ProofTreeLayout {
5399    let metrics = options.metrics();
5400    let rule_thickness = (metrics.default_rule_thickness * 2.0).max(0.08);
5401    // Bussproofs spacing constants (in em).
5402    // Tune premise spacing to better match MathJax/bussproofs horizontal spread.
5403    //   premise_gap  → horizontal gap between sibling premises
5404    //   label_gap    → gap between inference rule and its left/right label
5405    //   vertical_gap → vertical gap between rule and surrounding content
5406    let premise_gap = 2.0;
5407    let label_gap = 0.08;
5408    let vertical_gap = 0.32;
5409
5410    let conclusion = layout_proof_cell_content(&tree.conclusion, options);
5411    let conclusion_width = conclusion.width;
5412
5413    if tree.premises.is_empty() {
5414        let width = conclusion.width;
5415        let height = conclusion.height;
5416        let depth = conclusion.depth;
5417        return ProofTreeLayout {
5418            width,
5419            height,
5420            depth,
5421            root_width: width,
5422            root_center_x: width / 2.0,
5423            children: vec![PlacedBox {
5424                box_: conclusion,
5425                x: 0.0,
5426                baseline_y: height,
5427            }],
5428            rules: Vec::new(),
5429        };
5430    }
5431
5432    let premise_layouts: Vec<ProofTreeLayout> = tree
5433        .premises
5434        .iter()
5435        .map(|p| layout_proof_branch(p, options))
5436        .collect();
5437
5438    let premise_width = premise_layouts.iter().map(|p| p.width).sum::<f64>()
5439        + premise_gap * premise_layouts.len().saturating_sub(1) as f64;
5440    let premise_height = premise_layouts
5441        .iter()
5442        .map(|p| p.height)
5443        .fold(0.0_f64, f64::max);
5444    let premise_depth = premise_layouts
5445        .iter()
5446        .map(|p| p.depth)
5447        .fold(0.0_f64, f64::max);
5448    let premise_total = premise_height + premise_depth;
5449
5450    let mut premise_root_left = f64::INFINITY;
5451    let mut premise_root_right = f64::NEG_INFINITY;
5452    let mut cursor = 0.0_f64;
5453    for premise in &premise_layouts {
5454        let root_left = cursor + premise.root_center_x - premise.root_width / 2.0;
5455        let root_right = cursor + premise.root_center_x + premise.root_width / 2.0;
5456        premise_root_left = premise_root_left.min(root_left);
5457        premise_root_right = premise_root_right.max(root_right);
5458        cursor += premise.width + premise_gap;
5459    }
5460    let rule_padding = if tree.premises.len() > 1 { 0.65 } else { 0.0 };
5461    let premise_rule_width = premise_root_right - premise_root_left + rule_padding;
5462    let premise_root_center = (premise_root_left + premise_root_right) / 2.0;
5463    let rule_width = premise_rule_width.max(conclusion_width).max(1.12);
5464    let core_width = premise_width.max(rule_width).max(conclusion_width);
5465    let center_x = core_width / 2.0;
5466    let premise_start_x = center_x - premise_width / 2.0;
5467    let root_center_x = premise_start_x + premise_root_center;
5468    let rule_x = root_center_x - rule_width / 2.0;
5469    let rule_y = premise_total + vertical_gap + rule_thickness / 2.0;
5470    let conclusion_x = root_center_x - conclusion_width / 2.0;
5471    let conclusion_baseline_y = rule_y + rule_thickness / 2.0 + vertical_gap + conclusion.height;
5472
5473    let mut children = Vec::new();
5474    let mut rules = Vec::new();
5475    let mut min_x = 0.0_f64;
5476    let mut max_x = core_width;
5477
5478    if tree.root_at_top {
5479        let conclusion_baseline_y = conclusion.height;
5480        let rule_y = conclusion.height + conclusion.depth + vertical_gap + rule_thickness / 2.0;
5481        let premise_top_y = rule_y + rule_thickness / 2.0 + vertical_gap;
5482        let premise_baseline_y = premise_top_y + premise_height;
5483
5484        min_x = min_x.min(conclusion_x);
5485        max_x = max_x.max(conclusion_x + conclusion_width);
5486        children.push(PlacedBox {
5487            box_: conclusion,
5488            x: conclusion_x,
5489            baseline_y: conclusion_baseline_y,
5490        });
5491
5492        let mut cursor = premise_start_x;
5493        for premise in premise_layouts {
5494            let child_top_y = premise_baseline_y - premise.height;
5495            for child in premise.children {
5496                let x = cursor + child.x;
5497                let y = child_top_y + child.baseline_y;
5498                min_x = min_x.min(x);
5499                max_x = max_x.max(x + child.box_.width);
5500                children.push(PlacedBox {
5501                    box_: child.box_,
5502                    x,
5503                    baseline_y: y,
5504                });
5505            }
5506            for rule in premise.rules {
5507                min_x = min_x.min(cursor + rule.x);
5508                max_x = max_x.max(cursor + rule.x + rule.width);
5509                rules.push(ProofRule {
5510                    x: cursor + rule.x,
5511                    y: child_top_y + rule.y,
5512                    width: rule.width,
5513                    thickness: rule.thickness,
5514                    dashed: rule.dashed,
5515                });
5516            }
5517            cursor += premise.width + premise_gap;
5518        }
5519
5520        if !matches!(tree.line_style, ProofLineStyle::None) {
5521            rules.push(ProofRule {
5522                x: rule_x,
5523                y: rule_y,
5524                width: rule_width,
5525                thickness: rule_thickness,
5526                dashed: matches!(tree.line_style, ProofLineStyle::Dashed),
5527            });
5528        }
5529
5530        if let Some(label) = tree.left_label.as_ref() {
5531            let label_opts = options.with_style(options.style.text());
5532            let label_box = layout_proof_roman_content(label, &label_opts);
5533            let x = rule_x - label_gap - label_box.width;
5534            let baseline_y = rule_y + (label_box.height - label_box.depth) / 2.0;
5535            min_x = min_x.min(x);
5536            max_x = max_x.max(x + label_box.width);
5537            children.push(PlacedBox {
5538                box_: label_box,
5539                x,
5540                baseline_y,
5541            });
5542        }
5543
5544        if let Some(label) = tree.right_label.as_ref() {
5545            let label_opts = options.with_style(options.style.text());
5546            let label_box = layout_proof_roman_content(label, &label_opts);
5547            let x = rule_x + rule_width + label_gap;
5548            let baseline_y = rule_y + (label_box.height - label_box.depth) / 2.0;
5549            min_x = min_x.min(x);
5550            max_x = max_x.max(x + label_box.width);
5551            children.push(PlacedBox {
5552                box_: label_box,
5553                x,
5554                baseline_y,
5555            });
5556        }
5557
5558        let mut root_center_x = root_center_x;
5559        if min_x < 0.0 {
5560            let shift_x = -min_x;
5561            for child in &mut children {
5562                child.x += shift_x;
5563            }
5564            for rule in &mut rules {
5565                rule.x += shift_x;
5566            }
5567            root_center_x += shift_x;
5568        }
5569
5570        return ProofTreeLayout {
5571            width: max_x - min_x,
5572            height: conclusion_baseline_y,
5573            depth: children
5574                .iter()
5575                .map(|c| c.baseline_y + c.box_.depth - conclusion_baseline_y)
5576                .fold(0.0_f64, f64::max)
5577                .max(0.0),
5578            root_width: conclusion_width,
5579            root_center_x,
5580            children,
5581            rules,
5582        };
5583    }
5584
5585    let mut cursor = premise_start_x;
5586    for premise in premise_layouts {
5587        let baseline_y = premise_height;
5588        let child_top_y = baseline_y - premise.height;
5589        for child in premise.children {
5590            let x = cursor + child.x;
5591            let y = child_top_y + child.baseline_y;
5592            min_x = min_x.min(x);
5593            max_x = max_x.max(x + child.box_.width);
5594            children.push(PlacedBox {
5595                box_: child.box_,
5596                x,
5597                baseline_y: y,
5598            });
5599        }
5600        for rule in premise.rules {
5601            min_x = min_x.min(cursor + rule.x);
5602            max_x = max_x.max(cursor + rule.x + rule.width);
5603            rules.push(ProofRule {
5604                x: cursor + rule.x,
5605                y: child_top_y + rule.y,
5606                width: rule.width,
5607                thickness: rule.thickness,
5608                dashed: rule.dashed,
5609            });
5610        }
5611        cursor += premise.width + premise_gap;
5612    }
5613
5614    min_x = min_x.min(conclusion_x);
5615    max_x = max_x.max(conclusion_x + conclusion_width);
5616    children.push(PlacedBox {
5617        box_: conclusion,
5618        x: conclusion_x,
5619        baseline_y: conclusion_baseline_y,
5620    });
5621
5622    if !matches!(tree.line_style, ProofLineStyle::None) {
5623        rules.push(ProofRule {
5624            x: rule_x,
5625            y: rule_y,
5626            width: rule_width,
5627            thickness: rule_thickness,
5628            dashed: matches!(tree.line_style, ProofLineStyle::Dashed),
5629        });
5630    }
5631
5632    if let Some(label) = tree.left_label.as_ref() {
5633        let label_opts = options.with_style(options.style.text());
5634        let label_box = layout_proof_roman_content(label, &label_opts);
5635        let x = rule_x - label_gap - label_box.width;
5636        let baseline_y = rule_y + (label_box.height - label_box.depth) / 2.0;
5637        min_x = min_x.min(x);
5638        max_x = max_x.max(x + label_box.width);
5639        children.push(PlacedBox {
5640            box_: label_box,
5641            x,
5642            baseline_y,
5643        });
5644    }
5645
5646    if let Some(label) = tree.right_label.as_ref() {
5647        let label_opts = options.with_style(options.style.text());
5648        let label_box = layout_proof_roman_content(label, &label_opts);
5649        let x = rule_x + rule_width + label_gap;
5650        let baseline_y = rule_y + (label_box.height - label_box.depth) / 2.0;
5651        min_x = min_x.min(x);
5652        max_x = max_x.max(x + label_box.width);
5653        children.push(PlacedBox {
5654            box_: label_box,
5655            x,
5656            baseline_y,
5657        });
5658    }
5659
5660    let mut root_center_x = root_center_x;
5661    if min_x < 0.0 {
5662        let shift = -min_x;
5663        for child in &mut children {
5664            child.x += shift;
5665        }
5666        for rule in &mut rules {
5667            rule.x += shift;
5668        }
5669        root_center_x += shift;
5670    }
5671
5672    ProofTreeLayout {
5673        width: max_x - min_x,
5674        height: conclusion_baseline_y,
5675        depth: children
5676            .iter()
5677            .map(|c| c.baseline_y + c.box_.depth - conclusion_baseline_y)
5678            .fold(0.0_f64, f64::max),
5679        root_width: conclusion_width,
5680        root_center_x,
5681        children,
5682        rules,
5683    }
5684}
5685
5686fn horiz_brace_path(width: f64, height: f64, is_over: bool) -> Vec<PathCommand> {
5687    let mid = width / 2.0;
5688    let q = height * 0.6;
5689    if is_over {
5690        vec![
5691            PathCommand::MoveTo { x: 0.0, y: 0.0 },
5692            PathCommand::QuadTo {
5693                x1: 0.0,
5694                y1: -q,
5695                x: mid * 0.4,
5696                y: -q,
5697            },
5698            PathCommand::LineTo {
5699                x: mid - 0.05,
5700                y: -q,
5701            },
5702            PathCommand::LineTo { x: mid, y: -height },
5703            PathCommand::LineTo {
5704                x: mid + 0.05,
5705                y: -q,
5706            },
5707            PathCommand::LineTo {
5708                x: width - mid * 0.4,
5709                y: -q,
5710            },
5711            PathCommand::QuadTo {
5712                x1: width,
5713                y1: -q,
5714                x: width,
5715                y: 0.0,
5716            },
5717        ]
5718    } else {
5719        vec![
5720            PathCommand::MoveTo { x: 0.0, y: 0.0 },
5721            PathCommand::QuadTo {
5722                x1: 0.0,
5723                y1: q,
5724                x: mid * 0.4,
5725                y: q,
5726            },
5727            PathCommand::LineTo {
5728                x: mid - 0.05,
5729                y: q,
5730            },
5731            PathCommand::LineTo { x: mid, y: height },
5732            PathCommand::LineTo {
5733                x: mid + 0.05,
5734                y: q,
5735            },
5736            PathCommand::LineTo {
5737                x: width - mid * 0.4,
5738                y: q,
5739            },
5740            PathCommand::QuadTo {
5741                x1: width,
5742                y1: q,
5743                x: width,
5744                y: 0.0,
5745            },
5746        ]
5747    }
5748}
5749
5750#[cfg(test)]
5751mod missing_glyph_width_em_tests {
5752    use super::{missing_glyph_height_em, missing_glyph_width_em};
5753    use ratex_font::get_global_metrics;
5754
5755    #[test]
5756    fn supplementary_plane_emoji_is_one_em() {
5757        assert_eq!(missing_glyph_width_em('😊'), 1.0);
5758        assert_eq!(missing_glyph_width_em('🚀'), 1.0);
5759    }
5760
5761    #[test]
5762    fn supplementary_plane_emoji_uses_shorter_box_height() {
5763        let m = get_global_metrics(0);
5764        let emoji_h = missing_glyph_height_em('😊', m);
5765        let default_h = (m.quad * 0.92).max(m.x_height);
5766        assert!(
5767            emoji_h < default_h,
5768            "tall placeholder box must not push \\sqrt past KaTeX's small-surd threshold"
5769        );
5770        assert!((emoji_h - 0.74).abs() < 1e-9);
5771    }
5772
5773    #[test]
5774    fn dingbats_block_is_one_em() {
5775        assert_eq!(missing_glyph_width_em('\u{2708}'), 1.0); // AIRPLANE
5776    }
5777
5778    #[test]
5779    fn miscellaneous_symbols_is_one_em() {
5780        assert_eq!(missing_glyph_width_em('\u{2605}'), 1.0); // ★ BLACK STAR
5781        assert_eq!(missing_glyph_width_em('\u{2615}'), 1.0); // ☕ HOT BEVERAGE
5782    }
5783
5784    #[test]
5785    fn misc_symbols_and_arrows_is_one_em() {
5786        assert_eq!(missing_glyph_width_em('\u{2B50}'), 1.0); // ⭐ WHITE MEDIUM STAR
5787        assert_eq!(missing_glyph_width_em('\u{2B1B}'), 1.0); // ⬛ BLACK LARGE SQUARE
5788    }
5789
5790    #[test]
5791    fn latin_without_metrics_stays_half_em() {
5792        assert_eq!(missing_glyph_width_em('z'), 0.5);
5793    }
5794}
5795
5796#[cfg(test)]
5797mod cjk_font_switching_tests {
5798    use super::super::to_display::to_display_list;
5799    use super::*;
5800    use ratex_parser::parser::parse;
5801    use ratex_types::display_item::DisplayItem;
5802
5803    fn first_glyph_font_name(latex: &str) -> Option<String> {
5804        let ast = parse(latex).ok()?;
5805        let lbox = layout(&ast, &LayoutOptions::default());
5806        let dl = to_display_list(&lbox);
5807        for item in &dl.items {
5808            if let DisplayItem::GlyphPath { font, .. } = item {
5809                return Some(font.clone());
5810            }
5811        }
5812        None
5813    }
5814
5815    #[test]
5816    fn cjk_in_text_uses_cjk_regular() {
5817        assert_eq!(
5818            first_glyph_font_name(r"\text{中}").as_deref(),
5819            Some("CJK-Regular")
5820        );
5821    }
5822
5823    #[test]
5824    fn emoji_in_text_uses_cjk_regular() {
5825        assert_eq!(
5826            first_glyph_font_name(r"\text{😊}").as_deref(),
5827            Some("CJK-Regular")
5828        );
5829    }
5830
5831    #[test]
5832    fn latin_in_text_is_not_cjk() {
5833        assert_ne!(
5834            first_glyph_font_name(r"\text{a}").as_deref(),
5835            Some("CJK-Regular")
5836        );
5837    }
5838
5839    #[test]
5840    fn hiragana_in_text_uses_cjk_regular() {
5841        assert_eq!(
5842            first_glyph_font_name(r"\text{あ}").as_deref(),
5843            Some("CJK-Regular")
5844        );
5845    }
5846}